ETCD 是一个典型的读多写少的存储,在我们实际业务场景中,读一般占用 2/3 以上的请求。
我们先从 ETCD 默认的线性读来入手。

首先介绍一个好用的进程管理工具 goreman,基于它我们可以快速创建、停止本地的多节点 etcd 集群。
你可以通过 go install命令快速安装 goreman,然后从 etcd release 页面下载 etcd v3.4.9 的二进制文件,再从 etcd 源码 中下载 goreman Procfile 文件,它描述了 etcd 进程名、节点数、参数等信息。最后通过 goreman -f Procfile start 命令就可以快速启动一个 3 节点的本地集群了。
go install github.com/mattn/goreman启动完 etcd 集群后,当你使用 etcdctl 执行一个 get hello 命令时,etcdctl 是如何工作的呢?
etcdctl get hello --endpoints http://127.0.0.1:2379首先,etcd 会对命令中的参数进行解析。
我们先看下这组参数的含意:
get:请求的方法,它是 KVServer 模块的 API;
hello:查询的 key 名;
endpoints:后端 etcd 的地址,通常一个生产过环境会配置多个 endpoint,这样在 etcd 节点出现故障后,client 能够自动连接到其他正常的节点,从而保证请求的正常执行。
在 etcd v3.4.9 版本中,etcdctl 是通过 clientv3 库来访问 etcd server 的,clientv3 库基于 gRPC client API 封装了操作 etcd KVServer、Cluster、Auth、Lease、Watch 等模块的 api,同时还包含了负载均衡、健康探测和故障切换等特性。
在解析完请求中的参数后,etcdctl 会创建一个 clientv3 库对象,使用 KVServer 模块的 API 来访问 etcd server。
接下来,etcd 会通过负载均衡算法来为 get hello 请求选择一个合适的 etcd server 节点。
在 etcd 3.4 中,clientv3 库使用的负载均衡算法为 Round-robin。针对每个请求,Round-robin 算法通过轮询的方式依次从 endpoint 列表中选择一个 endpoint 访问(长连接),使 etcd server 负载尽量均衡
关于负载均衡算法需要注意下面两点:
如果你的 client 版本 <= 3.3,那么当时配置多个 endpoint 时,负载均衡算法仅会从中选择一个 IP 并创建一个连接(Pinned endpoint),这样可以节省服务器总连接数。但在 heavy useage 场景,这可能会造成 server 负载不均衡。 在 client 3.4 之前的版本中,负载均衡算法有个严重的 bug:如果第一个节点故障了,可能会导致你的 client 访问 etcd server 异常,特别是 Kubernetes 场景中会导致 APIServer 不可用。不过该 BUG 在 Kubernetes 1.16 版本后修复。
为请求选择好 etcd server 节点后,client 就可以调用 etcd server 的 KVServer 模块的 Range RPC 方法,把请求发送给 etcd server。
另外有一点需要注意的是,client 和 server 之间的通信,使用的是基于 HTTP/2 的 GRPC 协议,相比 etcd v2 使用的 HTTP/1.x,HTTP/2 是基于二进制而不是文本、支持多路复用而不在有序且阻塞、支持数据压缩以减少包大小、支持 server push 等特性。因此,基于 HTTP/2 的 GRPC 协议具有低延迟、高性能的特点,有效解决了 etcd v2 中的 HTTP/1.x 性能问题。
client 发送 Range RPC 发送请求到 server 后,就到了 KVServer 模块。
etcd 提供了丰富的 metrics、日志、请求行为检查等机制,可记录所有请求的执行耗时及错误码、来源 IP 等,也可控制请求是否允许通过。比如 etcd Learner 节点只允许指定接口和参数访问,帮助大家定位问题、提高服务可观测性等。而这些特性是通过拦截器来非侵入式实现的。
etcd server 定义了如下的 Server KV 和 Range 方法,启动的时候它会将实现 KV 各方法的对象注册到 GRPC Server,并在其上注册对应的拦截器。下面这段低吗中的 Range 借口就是负责读取 etcd key-value 的 RPC 接口。
service KV {
// Range gets the keys in the range from the key-value store.
rpc Range(RangeRequest) returns (RangeResponse) {
option (google.api.http) = {
post: "/v3/kv/range"
body: "*"
};
}
....
}
拦截器提供了在执行一个请求前后的 hook 能力,除了我们上面提到的 debug 日志、metrics 统计、对 etcd Learner 节点请求接口和参数限制能力,etcd 还基于它实现了以下特性:
要求执行一个操作前集群必须有 Leader;
请求延时超过指定阈值的,打印包含来源 IP 的慢查询日志(3.5 版本)。
server 受到 client 的 Range RPC 请求后,根据 ServiceName 和 RPC Method 将请求转发到对应的 handler 实现,handler 首先会将上面描述的一系列拦截器串联成一个执行,在拦截器逻辑中,通过调用 KVServer 模块的 Range 接口获取数据。
进入 KVServer 模块后,我们就进入核心的读流程了,对应架构图中的流程三和四。我们知道 etcd 为了保证服务高可用,生产环境一般部署多个节点,那各个节点数据在任意时间点读出来都是一致的吗?什么情况下会读到旧数据呢?
如下图所示,当 client 发起一个更新 hello 为 world 请求后,若 Leader 受到写请求,它会将此请求持久化到 WAL 日志,并广播给各个节点,若一半以上节点持久化成功,则该请求对应的日志条目被标记为已提交,etcdserver 模块异步从 Raft 模块获取已提交的日志条目,应用到状态机(boltdb 等)。

此时若 client 发起一个读取 hello 的请求,假设此请求直接从状态机中读取,如果连接到的是 C 节点,若 C 节点的磁盘 I/O 出现波动,可能导致它应用已提交的日志条目很慢,则会出现更新 hello 为 world 的写命令,在 client 读 hello 的时候还未被提交到状态机,因此就可能读取到旧数据,如上图查询 hello 流程所示。
我们先来看下面两个场景:
对数据敏感度较低的场景:
假如老板让你做一个旁路数据统计服务,希望你每分钟统计下 etcd 里的服务、配置信息等,这种场景其实对数据时效性要求不高,读请求可以直接从节点的状态机获取数据。即使数据落后一点,也不影响业务,毕竟这只是一个定时统计的旁路服务而已。
这种直接读状态机数据返回,无须通过 Raft 协议与集群进行交互的模式,在 etcd 里叫做串行读(Serializable),它具有低延时、高吞吐量的特点,适合对数据一致性要求不高的场景。
数据敏感性高的场景:
当你发布服务,更新服务的镜像的时候,提交的时候显示更新成功,结果你一刷新页面,发现显示的镜像还是旧的,再刷新优势新的,这就会导致混乱。再比如说一个转账场景,A 给 B 转账,前被正常扣除,一刷新页面发现钱又回来了,这也是令人不可接受的。
以上业务场景就对数据准确性要求极高了,在 etcd 里面,提供了一种线性读模式来解决对数据一致性要求高的场景。
你可以理解一旦一个值更新成功,随后任何通过线性读的 client 都能及时访问到。虽然级群众又多个节点,但 client 通过线性读就像访问一个节点一样。etcd 默认读模式是线性读,它需要经过 Raft 协议模块,反应的是集群共识,因此在延时和吞吐量上相比串行读略差一些,适用于对数据一致性较高的场景。
如果你的 etcd 读请求显示指定了是串行读,就不会经过架构图中的流程三、四。
前面我们提及串行读能够读到旧数据,主要原因是 Follower 节点受到 Leader 节点同步的写请求后,应用日志条目到状态机是一个异步过程,那么我们能否有一种机制在读取的时候,确保最新的数据已经应用到状态机中呢?
其实这个机制就是叫 ReadIndex,它是在 etcd 3.1 中引入的,简化后的原理图如下。

当收到一个线性读请求时,它首先会从 Leader 获取集群最新的已提交的日志索引(Committed index)(上图中的流程二)。
Leader 收到 ReadIndex 请求时,为防止脑裂等异常场景,会向 Follower 节点发送心跳确认,一半以上节点确认 Leader 身份后才能将已提交的索引(Committed index)返回给节点 C(上图中的流程三)。
C 节点则会等待,直到状态机已应用索引(applied index)大于等于 Leader 的已提交索引时(committed index)(上图中的流程四),然后去通知读请求,数据已赶上 Leader,你可以去状态机中访问数据了(上图中的流程五)。
以上就是线性读通过 ReadIndex 机制保证数据一致性的原理,当然还有其他机制也能实现线性读。如早期 etcd 3.0 中读请求通过走一遍 Raft 协议保证一致性,这种 Raft log read 机制依赖磁盘 IO,性能相比 ReadIndex 较差。
总体而言,KVServer 模块收到线性读请求后,通过架构图中流程三向 Raft 模块发起 ReadIndex 请求,Raft 模块将 Leader 最新的已提交日志索引封装在流程四的 RedState 结构体,通过 channel 层层返回给线性读模块,线性读模块等待本节点状态机赶上 Leader 进度,追赶完成后就通知 KVServer 模块,进行架构图中的流程五与状态机中的 MVCC 模块进行交互了。
流程五中的多版本并发控制(Multiversion concurrency control)模块是为了解决之前提到 etcd v2 不支持保存 key 的历史版本、不支持多 key 事物等问题产生的。
它核心由内存树形索引模块(treeIndex)和嵌入式的 KV 持久化存储库 boltdb 组成。
首先我们需要简单了解下 boltdb,他是一个基于 B+ tree 实现的 keyvalue 键值库,支持事务,提供 Get/Put 等简易 API 给 etcd 操作。boltdb 的 key 时全局递增的版本号(revision),value 时用户 key-value 等字段组成的结构体,然后通过 treeIndex 模块来保存用户 key 和版本号的映射关系。
treeIndex 与 boltdb 关系如下面的读事务流程图所示,从 treeIndex 中获取 key hello 的版本号,再以版本号作为 boltdb 的 key,从 boltdb 中获取其 value 信息。

treeIndex 模块是基于 Google 开源的内存版 btree 库实现的。
treeIndex 模块只会保存用户的 key 和相关版本号信息,用户 key 的 value 数据存储在 boltdb 里面,相比 zookeeper 和 etcd v2 的全内存存储,etcd v3 对内存要求更低。
回看架构图中的流程六,我们不难发现 etcd 需要从 treeIndex 模块中获取 hello 这个 key 对应的版本号信息。treeIndex 模块基于 B-tree 快速查找此 key,返回此 key 对应的索引项 keyIndex 即可。而索引项中包含版本号等信息。
在获取到版本号信息后,就可以从 boltdb 模块中获取用户的 key-value 数据了。不过并不是所有的请求都一定要从 boltdb 获取数据。
etcd 出于数据一致性、性能等考虑,在访问 boltdb 前,首先会从一个内存读事务 buffer 中,二分查找你想要访问的 key 是否在 buffer 里面,若命中则直接返回。
若 buffer 未命中,此时就真正需要向 boltdb 模块查询数据了,进入了流程七。
我们都知道 MySQL 通过 table 实现不同数据逻辑隔离,而在 boltdb 中则是通过 bucket 来隔离集群元数据与用户数据。
boltdb 里每个 bucket 类似对应 MySQL 一个表,用户的 key 数据存放的 bucket 名字是 key,etcd MVCC 元数据存放的 bucket 是 meta。
因为 boltdb 使用 B+tree 来组织用户的 key-value 数据,获取 bucket key 对象后,通过 boltdb 的游标 Cursor 可快速在 B+tree 中找到 key hello 对应的 value 数据,反会给 client。
到这里,一个读请求之路执行完毕
一个读请求从 client 通过 Round-robin 负载均衡算法,选择一个 etcd server 节点,发出 GRPC 请求,经过 etcd server 的KVServer 模块、线性读模块、MVCC 的 treeIndex 和 boltdb 模块的紧密协作,完成了一个读请求。
etcd 在执行读请求过程中涉及磁盘 IO 吗?如果涉及,是什么模块在什么场景下会触发呢?如果不涉及,又是什么原因呢?
答案:
从 boltdb 读时会产生磁盘 I/O,这是一个常见误区。
实际上,etcd 在启动的时候会通过 mmap 机制将 etcd db 文件映射到 etcd 进程地址空间,并设置了 mmap 的 MAP_POPULATE flag,它会告诉 Linux 内核预读文件,Linux 内核会将文件内容拷贝到物理内存中,此时会产生磁盘 I/O。节点内存足够的请求下,后续处理读请求过程中就不会产生磁盘 I/IO 了。若 etcd 节点内存不足,可能会导致 db 文件对应的内存页被换出,当读请求命中的页未在内存中时,就会产生缺页异常,导致读过程中产生磁盘 IO,你可以通过观察 etcd 进程的 majflt 字段来判断 etcd 是否产生了主缺页中断。
本文内容摘抄自极客时间专栏 etcd 实战课