本文主要分析了 etcd 中一个读请求的具体执行流程。
下面是一张 etcd 的简要基础架构图:
按照分层模型,etcd 可分为 Client 层、API 网络层、Raft 算法层、逻辑层和存储层。这些层的功能如下:
具体流程如下图所示:
以下面的命令进行分析:
# --endpoint=http://127.0.0.1:2379 用于指定后端的 etcd 地址 /usr/local/bin # etcdctl --endpoint=http://127.0.0.1:2379 put hello world ok /usr/local/bin # etcdctl --endpoint=http://127.0.0.1:2379 get hello world |
1)首先,etcdctl 会对命令中的参数进行解析。
通常,生产环境下中需要配置多个 endpoints,这样在 etcd 节点出现故障后,client 就可以自动重连到其它正常的节点,从而保证请求的正常执行。
2)在解析完请求中的参数后,etcdctl 会创建一个 clientv3 库对象,使用 KVServer 模块的 API 来访问 etcd server。
etcd clientv3 库采用的负载均衡算法为 Round-robin。针对每一个请求,Round-robin 算法通过轮询的方式依次从 endpoint 列表中选择一个 endpoint 访问 (长连接),使 etcd server 负载尽量均衡。
client 发送 Range RPC 请求到了 server 后就进入了 KVServer 模块。
etcd 通过拦截器以非侵入式的方式实现了许多特性,例如:丰富的 metrics、日志、请求行为检查、所有请求的执行耗时及错误码、来源 IP 等。
拦截器提供了在执行一个请求前后的 hook 能力,除了 debug 日志、metrics 统计、对 etcd Learner 节点请求接口和参数限制等能力,etcd 还基于它实现了以下特性:
server 收到 client 的 Range RPC 请求后,根据 ServiceName 和 RPC Method 将请求转发到对应的 handler 实现,handler 首先会将上面描述的一系列拦截器串联成一个拦截器再执行(具体实现见这里),在拦截器逻辑中,通过调用 KVServer 模块的 Range 接口获取数据。
etcd 为了保证服务高可用,生产环境一般部署多个节点,多节点之间的数据由于延迟等关系可能会存在不一致的情况。
所有的写操作,都要经过leader节点,一旦leader被选举成功,就可以对客户端提供服务了。客户端提交每一条命令都会被按顺序记录到leader的日志中,每一条命令都包含term编号和顺序索引,然后向其他节点并行发送AppendEntries RPC用以复制命令(如果命令丢失会不断重发),当复制成功也就是大多数节点(n/2 + 1)成功复制后,leader就会提交命令,即执行该命令并且将执行结果返回客户端,raft保证已经提交的命令最终也会被其他节点成功执行。
因为日志是顺序记录的,并且有严格的确认机制,所以可以认为写是满足线性一致性的。
由于在Raft算法中,写操作成功仅仅意味着日志达成了一致(已经落盘),而并不能确保当前状态机也已经apply了日志。状态机apply日志的行为在大多数Raft算法的实现中都是异步的,所以此时读取状态机并不能准确反应数据的状态,很可能会读到过期数据。
当 client 发起一个写请求后具体步骤如下:
根据业务场景对数据一致性差异的接受程度,etcd 中有两种读模式。
在 etcd 3.1 时引入了 ReadIndex 机制,保证在串行读的时候,也能读到最新的数据。
具体流程如下:
所以根据ReadIndex的特性,如果出现网络分区,少数派所在的部分读写都将会失败。
MVCC 即多版本并发控制 (Multiversion concurrency control) ,MVCC模块是为了解决 etcd v2 不支持保存 key 的历史版本、不支持多 key 事务等问题而产生的。
它核心由内存树形索引模块 treeIndex 和嵌入式的 KV 持久化存储库 boltdb 组成。
etcd MVCC 具体方案如下:
每次修改操作,生成一个新的版本号 (revision),以版本号为 key, value 为用户 key-value 等信息组成的结构体存储到 blotdb。
读取时先从 treeIndex 中获取 key 的版本号,再以版本号作为 boltdb 的 key,从 boltdb 中获取其 value 信息。
treeIndex
treeIndex 模块是基于 Google 开源的内存版 btree 库实现的,treeIndex 模块只会保存用户的 key 和相关版本号信息,用户 key 的 value 数据存储在 boltdb 里面,所以对内存要求相对较低。
buffer
在获取到版本号信息后,就可从 boltdb 模块中获取用户的 key-value 数据了。
etcd 出于数据一致性、性能等考虑,在访问 boltdb 前,首先会从一个内存读事务 buffer 中,二分查找你要访问 key 是否在 buffer 里面,若命中则直接返回。
若 buffer 未命中,此时就真正需要向 boltdb 模块查询数据了。
boltdb 通过 bucket 隔离集群元数据与用户数据。
boltdb 使用 B+ tree 来组织用户的 key-value 数据,获取 bucket key 对象后,通过 boltdb 的游标 Cursor 可快速在 B+ tree 找到 key hello 对应的 value 数据,返回给 client。
到这里,一个读请求之路执行完成。
Q:readIndex 需要请求 leader,那为什么不直接让 leader 返回读请求的结果?
A:主要是性能因素,如果将所有读请求都转发到 Leader,会导致 Leader 负载升高,内存、cpu、网络带宽资源都很容易耗尽。特别是expensive request场景,会让 Leader 节点性能会急剧下降。read index 机制的引入,使得每个follower节点都可以处理读请求,极大扩展提升了写性能。
一个读请求从 client 通过 Round-robin 负载均衡算法,选择一个 etcd server 节点,发出 gRPC 请求,经过 etcd server 的 KVServer 模块、线性读模块、MVCC 的 treeIndex 和 boltdb 模块紧密协作,完成了一个读请求。