<p data-nodeid="25385">我们在前面几讲介绍了 etcd 的整体架构以及 etcd 常用的通信接口。在介绍 etcd 整体架构时，我们梳理了 etcd 的分层架构以及交互概览。这一讲我们将会聚焦 etcd 存储如何实现键值对的读写操作。</p>
<p data-nodeid="25386">下面我们围绕 etcd 底层读写的实现展开，首先简要介绍客户端访问 etcd 服务端读写的整个过程，然后我们来重点介绍读写的实现细节。这一讲内容环环相扣，希望你仔细阅读。</p>
<h3 data-nodeid="25387">读写操作过程概述</h3>
<p data-nodeid="25388">我们在<a href="https://kaiwu.lagou.com/course/courseInfo.htm?courseId=613#/detail/pc?id=6402" data-nodeid="25468"> 08 讲《etcd 的架构是什么样的？》</a>中介绍了 etcd 各个模块交互的总览，如下图所示。</p>
<p data-nodeid="26390" class=""><img src="https://s0.lgstatic.com/i/image6/M01/07/57/Cgp9HWAzZPCAOvp9AAA152E_9xA032.png" alt="Drawing 0.png" data-nodeid="26393"></p>

<p data-nodeid="25390">虽然有些细节在图中没有标出，但是总体上的请求流程从上至下依次为客户端 → API 接口层 → etcd Server → etcd raft 算法库。</p>
<p data-nodeid="25391">对于读请求来说，客户端通过<strong data-nodeid="25483">负载均衡</strong>选择一个 etcd 节点发出读请求，API 接口层提供了 Range RPC 方法，etcd 服务端<strong data-nodeid="25484">拦截到 gRPC 读请求</strong>后，调用相应的处理器处理请求。</p>
<p data-nodeid="25392">写请求相对复杂一些，客户端通过<strong data-nodeid="25498">负载均衡</strong>选择一个 etcd 节点发起写请求，etcd 服务端<strong data-nodeid="25499">拦截到 gRPC 写请求</strong>，涉及一些校验和监控，之后<strong data-nodeid="25500">KVServer 向 raft 模块发起提案</strong>，内容即为写入数据的命令。经过网络转发，当集群中的多数节点达成一致并持久化数据后，状态变更且 MVCC 模块执行提案内容。</p>
<p data-nodeid="25393">下面我们就分别看一下读写请求的底层存储实现。</p>
<h3 data-nodeid="25394">读操作</h3>
<p data-nodeid="25395">在 etcd 中读请求占了大部分，是高频的操作。我们使用 etcdctl 命令行工具进行读操作：</p>
<pre class="lang-java" data-nodeid="25396"><code data-language="java">$ etcdctl --endpoints http:<span class="hljs-comment">//localhost:2379 get foo</span>
foo
bar
</code></pre>
<p data-nodeid="25397">将整个读操作划分成如下几个步骤：</p>
<ul data-nodeid="25398">
<li data-nodeid="25399">
<p data-nodeid="25400">etcdctl 会创建一个 clientv3 库对象，选取一个合适的 etcd 节点；</p>
</li>
<li data-nodeid="25401">
<p data-nodeid="25402">调用 KVServer 模块的 Range RPC 方法（上一课时有讲解），发送请求；</p>
</li>
<li data-nodeid="25403">
<p data-nodeid="25404">拦截器拦截，主要做一些校验和监控；</p>
</li>
<li data-nodeid="25405">
<p data-nodeid="25406">调用 KVServer 模块的 Range 接口获取数据；</p>
</li>
</ul>
<p data-nodeid="25407">接着就进入了读请求的核心步骤，会经过线性读 ReadIndex 模块、MVCC（包含 treeIndex 和 BlotDB）模块。</p>
<p data-nodeid="25408">这里要提一下线性读，线性读是相对串行读来讲的概念。集群模式下会有多个 etcd 节点，不同节点之间可能存在一致性问题，<strong data-nodeid="25519">串行读直接返回状态数据，不需要与集群中其他节点交互</strong>。这种方式速度快，开销小，但是会存在<strong data-nodeid="25520">数据不一致</strong>的情况。</p>
<p data-nodeid="25409">线性读则需要集群成员之间达成共识，存在开销，响应速度相对慢。但是能够保证数据的一致性，<strong data-nodeid="25526">etcd 默认读模式是线性读</strong>。我们将在后面的课时重点介绍如何实现分布式一致性。</p>
<p data-nodeid="25410">继续往下，看看如何读取 etcd 中的数据。etcd 中查询请求，查询单个键或者一组键，以及查询数量，到了底层实际都会调用 Range keys 方法。下面我们具体分析一下这个方式的实现。</p>
<p data-nodeid="25411">Range 请求的结构图如下所示：</p>
<p data-nodeid="26792" class=""><img src="https://s0.lgstatic.com/i/image6/M01/07/57/Cgp9HWAzZP6AeJMpAAA5nlj8jwI348.png" alt="Drawing 1.png" data-nodeid="26795"></p>

<p data-nodeid="25413">从上至下，查询键值对的流程包括：</p>
<ul data-nodeid="25414">
<li data-nodeid="25415">
<p data-nodeid="25416">在 treeIndex 中根据键利用 BTree 快速查询该键对应的索引项 keyIndex，索引项中包含 Revision；</p>
</li>
<li data-nodeid="25417">
<p data-nodeid="25418">根据查询到的版本号信息 Revision，在 Backend 的缓存 Buffer 中利用二分法查找，如果命中则直接返回；</p>
</li>
<li data-nodeid="25419">
<p data-nodeid="25420">若缓存中不符合条件，在 BlotDB 中查找（基于 BlotDB 的索引），查询之后返回键值对信息。</p>
</li>
</ul>
<p data-nodeid="25421">图中 ReadTx 和 BatchTx 是两个接口，用于读写请求。在创建 Backend 结构体时，默认也会创建 readTx 和 batchTx，readTx 实现了 ReadTx ，负责处理只读请求；batchTx 实现了 BatchTx 接口，负责处理读写请求。</p>
<p data-nodeid="25422"><code data-backticks="1" data-nodeid="25537">rangeKeys</code>方法的实现如下所示：</p>
<pre class="lang-go" data-nodeid="25423"><code data-language="go"><span class="hljs-comment">// 位于 mvcc/kvstore_txn.go:117</span>
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(tr *storeTxnRead)</span> <span class="hljs-title">rangeKeys</span><span class="hljs-params">(key, end []<span class="hljs-keyword">byte</span>, curRev <span class="hljs-keyword">int64</span>, ro RangeOptions)</span> <span class="hljs-params">(*RangeResult, error)</span></span> {
	rev := ro.Rev
	<span class="hljs-keyword">if</span> rev &gt; curRev {
		<span class="hljs-keyword">return</span> &amp;RangeResult{KVs: <span class="hljs-literal">nil</span>, Count: <span class="hljs-number">-1</span>, Rev: curRev}, ErrFutureRev
	}
	<span class="hljs-keyword">if</span> rev &lt;= <span class="hljs-number">0</span> {
		rev = curRev
	}
	<span class="hljs-keyword">if</span> rev &lt; tr.s.compactMainRev {
		<span class="hljs-keyword">return</span> &amp;RangeResult{KVs: <span class="hljs-literal">nil</span>, Count: <span class="hljs-number">-1</span>, Rev: <span class="hljs-number">0</span>}, ErrCompacted
	}
  <span class="hljs-comment">// 获取索引项 keyIndex，索引项中包含 Revision</span>
	revpairs := tr.s.kvindex.Revisions(key, end, rev)
	tr.trace.Step(<span class="hljs-string">"range keys from in-memory index tree"</span>)
  <span class="hljs-comment">// 结果为空，直接返回</span>
	<span class="hljs-keyword">if</span> <span class="hljs-built_in">len</span>(revpairs) == <span class="hljs-number">0</span> {
		<span class="hljs-keyword">return</span> &amp;RangeResult{KVs: <span class="hljs-literal">nil</span>, Count: <span class="hljs-number">0</span>, Rev: curRev}, <span class="hljs-literal">nil</span>
	}
	<span class="hljs-keyword">if</span> ro.Count {
		<span class="hljs-keyword">return</span> &amp;RangeResult{KVs: <span class="hljs-literal">nil</span>, Count: <span class="hljs-built_in">len</span>(revpairs), Rev: curRev}, <span class="hljs-literal">nil</span>
	}
	limit := <span class="hljs-keyword">int</span>(ro.Limit)
	<span class="hljs-keyword">if</span> limit &lt;= <span class="hljs-number">0</span> || limit &gt; <span class="hljs-built_in">len</span>(revpairs) {
		limit = <span class="hljs-built_in">len</span>(revpairs)
	}
	kvs := <span class="hljs-built_in">make</span>([]mvccpb.KeyValue, limit)
	revBytes := newRevBytes()
	<span class="hljs-keyword">for</span> i, revpair := <span class="hljs-keyword">range</span> revpairs[:<span class="hljs-built_in">len</span>(kvs)] {
		revToBytes(revpair, revBytes)
    <span class="hljs-comment">// UnsafeRange 实现了 ReadTx，查询对应的键值对</span>
		_, vs := tr.tx.UnsafeRange(keyBucketName, revBytes, <span class="hljs-literal">nil</span>, <span class="hljs-number">0</span>)
		<span class="hljs-keyword">if</span> <span class="hljs-built_in">len</span>(vs) != <span class="hljs-number">1</span> {
			tr.s.lg.Fatal(
				<span class="hljs-string">"range failed to find revision pair"</span>,
				zap.Int64(<span class="hljs-string">"revision-main"</span>, revpair.main),
				zap.Int64(<span class="hljs-string">"revision-sub"</span>, revpair.sub),
			)
		}
		<span class="hljs-keyword">if</span> err := kvs[i].Unmarshal(vs[<span class="hljs-number">0</span>]); err != <span class="hljs-literal">nil</span> {
			tr.s.lg.Fatal(
				<span class="hljs-string">"failed to unmarshal mvccpb.KeyValue"</span>,
				zap.Error(err),
			)
		}
	}
	tr.trace.Step(<span class="hljs-string">"range keys from bolt db"</span>)
	<span class="hljs-keyword">return</span> &amp;RangeResult{KVs: kvs, Count: <span class="hljs-built_in">len</span>(revpairs), Rev: curRev}, <span class="hljs-literal">nil</span>
}
</code></pre>
<p data-nodeid="25424">在上述代码的实现中，我们需要通过<code data-backticks="1" data-nodeid="25540">Revisions</code>方法从 Btree 中获取范围内所有的 keyIndex，以此才能获取一个范围内的所有键值对。<code data-backticks="1" data-nodeid="25542">Revisions</code>方法实现如下：</p>
<pre class="lang-go" data-nodeid="25425"><code data-language="go"><span class="hljs-comment">// 位于 mvcc/index.go:106</span>
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(ti *treeIndex)</span> <span class="hljs-title">Revisions</span><span class="hljs-params">(key, end []<span class="hljs-keyword">byte</span>, atRev <span class="hljs-keyword">int64</span>)</span> <span class="hljs-params">(revs []revision)</span></span> {
	<span class="hljs-keyword">if</span> end == <span class="hljs-literal">nil</span> {
		rev, _, _, err := ti.Get(key, atRev)
		<span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
			<span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>
		}
		<span class="hljs-keyword">return</span> []revision{rev}
	}
	ti.visit(key, end, <span class="hljs-function"><span class="hljs-keyword">func</span><span class="hljs-params">(ki *keyIndex)</span></span> {
    <span class="hljs-comment">// 使用 keyIndex.get 来遍历整棵树</span>
		<span class="hljs-keyword">if</span> rev, _, _, err := ki.get(ti.lg, atRev); err == <span class="hljs-literal">nil</span> {
			revs = <span class="hljs-built_in">append</span>(revs, rev)
		}
	})
	<span class="hljs-keyword">return</span> revs
}
</code></pre>
<p data-nodeid="25426">如果只获取一个键对应的版本，使用 treeIndex 方法即可，但是一般会从 Btree 索引中获取多个 Revision 值，此时需要调用 keyIndex.get 方法来遍历整棵树并选取合适的版本。这是因为<strong data-nodeid="25549">BoltDB 保存一个 key 的多个历史版本</strong>，每一个 key 的 keyIndex 中其实都存储着多个历史版本，我们需要根据传入的参数返回正确的版本。</p>
<p data-nodeid="25427">对于上层的键值存储来说，它会利用这里返回的 Revision，从真正存储数据的 BoltDB 中查询当前 key 对应 Revision 的数据。BoltDB 内部使用的也是类似 bucket（桶）的方式存储，其实就是对应 MySQL 中的表结构，用户的 key 数据存放的 bucket 名字的是 key，etcd MVCC 元数据存放的 bucket 是 meta。</p>
<h3 data-nodeid="25428">写操作</h3>
<p data-nodeid="25429">介绍完读请求，我们回忆一下写操作的实现。使用 etcdctl 命令行工具进行写操作：</p>
<pre class="lang-java" data-nodeid="25430"><code data-language="java">$ etcdctl --endpoints http:<span class="hljs-comment">//localhost:2379 put foo bar</span>
</code></pre>
<p data-nodeid="25431">将整个写操作划分成如下几个步骤：</p>
<ul data-nodeid="25432">
<li data-nodeid="25433">
<p data-nodeid="25434">客户端通过负载均衡算法选择一个 etcd 节点，发起 gRPC 调用；</p>
</li>
<li data-nodeid="25435">
<p data-nodeid="25436">etcd Server 收到客户端请求；</p>
</li>
<li data-nodeid="25437">
<p data-nodeid="25438">经过 gRPC 拦截、Quota 校验，Quota 模块用于校验 etcd db 文件大小是否超过了配额；</p>
</li>
<li data-nodeid="25439">
<p data-nodeid="25440">接着 KVServer 模块将请求发送给本模块中的 raft，这里负责与 etcd raft 模块进行通信，发起一个提案，命令为<code data-backticks="1" data-nodeid="25558">put foo bar</code>，即使用 put 方法将 foo 更新为 bar；</p>
</li>
<li data-nodeid="25441">
<p data-nodeid="25442">提案经过转发之后，半数节点成功持久化；</p>
</li>
<li data-nodeid="25443">
<p data-nodeid="25444">MVCC 模块更新状态机。</p>
</li>
</ul>
<p data-nodeid="25445">我们重点关注最后一步，学习如何更新和插入键值对。与上面一张图相对应，我们来看下 put 接口的执行过程：</p>
<p data-nodeid="27194" class=""><img src="https://s0.lgstatic.com/i/image6/M01/07/58/Cgp9HWAzZT2AXZHsAABJ2oP8TZY732.png" alt="Drawing 2.png" data-nodeid="27197"></p>

<p data-nodeid="25447">调用 put 向 etcd 写入数据时，首先会使用传入的键构建 keyIndex 结构体，基于 currentRevision 自增生成新的 Revision 如{1,0}，并从 treeIndex 中获取相关版本 Revision 等信息；写事务提交之后，将本次写操作的缓存 buffer 合并（merge）到读缓存上（图中 ReadTx 中的缓存）。代码实现如下所示：</p>
<pre class="lang-go" data-nodeid="25448"><code data-language="go"><span class="hljs-comment">//位于 mvcc/index.go:53</span>
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(ti *treeIndex)</span> <span class="hljs-title">Put</span><span class="hljs-params">(key []<span class="hljs-keyword">byte</span>, rev revision)</span></span> {
	keyi := &amp;keyIndex{key: key}
  <span class="hljs-comment">// 加锁，互斥</span>
	ti.Lock()
	<span class="hljs-keyword">defer</span> ti.Unlock()
  <span class="hljs-comment">// 获取版本信息</span>
	item := ti.tree.Get(keyi)
	<span class="hljs-keyword">if</span> item == <span class="hljs-literal">nil</span> {
		keyi.put(ti.lg, rev.main, rev.sub)
		ti.tree.ReplaceOrInsert(keyi)
		<span class="hljs-keyword">return</span>
	}
	okeyi := item.(*keyIndex)
	okeyi.put(ti.lg, rev.main, rev.sub)
}
</code></pre>
<p data-nodeid="25449">treeIndex.Put 在获取 Btree 中的 keyIndex 结构之后，会通过 keyIndex.put 在其中加入新的 revision，方法实现如下：</p>
<pre class="lang-go" data-nodeid="25450"><code data-language="go"><span class="hljs-comment">// 位于 mvcc/key_index.go:77</span>
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(ki *keyIndex)</span> <span class="hljs-title">put</span><span class="hljs-params">(lg *zap.Logger, main <span class="hljs-keyword">int64</span>, sub <span class="hljs-keyword">int64</span>)</span></span> {
	rev := revision{main: main, sub: sub}
  <span class="hljs-comment">// 校验版本号</span>
	<span class="hljs-keyword">if</span> !rev.GreaterThan(ki.modified) {
		lg.Panic(
			<span class="hljs-string">"'put' with an unexpected smaller revision"</span>,
			zap.Int64(<span class="hljs-string">"given-revision-main"</span>, rev.main),
			zap.Int64(<span class="hljs-string">"given-revision-sub"</span>, rev.sub),
			zap.Int64(<span class="hljs-string">"modified-revision-main"</span>, ki.modified.main),
			zap.Int64(<span class="hljs-string">"modified-revision-sub"</span>, ki.modified.sub),
		)
	}
	<span class="hljs-keyword">if</span> <span class="hljs-built_in">len</span>(ki.generations) == <span class="hljs-number">0</span> {
		ki.generations = <span class="hljs-built_in">append</span>(ki.generations, generation{})
	}
	g := &amp;ki.generations[<span class="hljs-built_in">len</span>(ki.generations)<span class="hljs-number">-1</span>]
	<span class="hljs-keyword">if</span> <span class="hljs-built_in">len</span>(g.revs) == <span class="hljs-number">0</span> { <span class="hljs-comment">// 创建一个新的键</span>
		keysGauge.Inc()
		g.created = rev
	}
	g.revs = <span class="hljs-built_in">append</span>(g.revs, rev)
	g.ver++
	ki.modified = rev
}
</code></pre>
<p data-nodeid="25451">从上述代码我们可以知道，构造的 Revision 结构体写入 keyIndex 键索引时，会<strong data-nodeid="25573">改变 generation 结构体中的属性</strong>，generation 中包括一个键的多个不同的版本信息，包括创建版本、修改次数等参数。因此我们可以通过该方法了解 generation 结构体中的各个成员如何定义和赋值。</p>
<p data-nodeid="25452">revision{1,0} 是生成的全局版本号，作为 BoltDB 的 key，经过序列化包括 key 名称、key 创建时的版本号（create_revision）、value 值和租约等信息为二进制数据之后，将填充到 BoltDB 的 value 中，同时将该键和 Revision 等信息存储到 Btree。</p>
<h3 data-nodeid="25453">小结</h3>
<p data-nodeid="25454">这一讲我们主要介绍了 etcd 的底层如何实现读写操作。我们首先简单介绍了客户端与服务端读写操作的流程，之后重点分析了在 etcd 中如何读写数据。</p>
<p data-nodeid="25455">通过上面的分析不难发现，etcd 最底层的读写其实并不是很复杂。根据 etcd 读写流程图，可以知道读写操作依赖 MVCC 模块的 treeIndex 和 BoltDB，treeIndex 用来保存键的历史版本号信息，而 BoltDB 用来保存 etcd 的键值对数据。通过这两个模块之间的协作，实现了 etcd 数据的读取和存储。因此后续的课程将会进一步介绍 etcd 分布式一致性实现以及 MVCC 多版本控制实现的原理。</p>
<p data-nodeid="25456">本讲内容总结如下：</p>
<p data-nodeid="27596" class="te-preview-highlight"><img src="https://s0.lgstatic.com/i/image6/M00/07/58/Cgp9HWAzZUqAWFIuAAKAncvsiss728.png" alt="Drawing 3.png" data-nodeid="27599"></p>

<p data-nodeid="25458">学习完本课时，给大家留个小问题，etcd 写事务的提交会涉及 B+ 重新平衡，但这部分开销昂贵，该如何权衡呢？欢迎你在留言区提出自己的观点。</p>
<p data-nodeid="25459">当然，本课时仅是介绍了底层的存储，对于如何实现分布式数据一致性并没有展开讲解。我们将在下一讲介绍 etcd-raft 如何实现分布式一致性。</p>