#作者:程宏斌
etcd是典型的读多写少存储,在我们实际业务场景中,读一般占据2/3以上的请求。一个读请求从client通过Round-robin负载均衡算法,选择一个etcd server节点,发出 gRPC请求,经过etcd server的 KVServer模块(收集日志等处理,去做校验集群状态)、线性读模块(read index:保证强一致性,读到最新的数据)、MVCC(支持多版本的key)的treelndex模块和 boltdb模块:存储层,紧密协作,完成了一个读请求。
首先,ptcdctl 会对命令中的参数进行解析。
“get"是请求的方法,它是 KVServer 模块的 API;
"hello”是我们查询的 key 名;
“endpoints”是我们后端的 etcd 地址,通常,生产环境下中需要配置多个 endpoints,这样在etcd 节点出现故障后,client 就可以自动重连到其它正常的节点,从而保证请求的正常执行。
在解析完请求中的参数后,etcdctl 会创建一个 clientv3 库对象,使用 KVServer 模块的 API来访问 etcd server…
etcd cientv3 库采用的负载均衡算法为 Round-robin,针对每一个请求,Round-robin 算法通过轮询的方式依次从 endpoint 列表中选择一个 endpoint 访问(长连接),使 etcd server 负载尽量均衡。
client 发送 Range RPC 请求到了 server 后就进入了 KVServer 模块。
etcd 通过拦截器以非侵入式的方式实现了许多特性,例如:丰富的 metrics、日志、请求行为检查所有请求的执行耗时及错误码、来源 IP 等。拦截器提供了在执行一个请求前后的 hook 能力,除了debug 日志、metrics 统计、对 etcd Learner 节点请求接口和参数限制等能力,etcd 还基于它实现了以下特性:
要求执行一个操作前集群必须有 Leader;
请求延时超过指定阈值的,打印包含来源IP 的慢查询日志(3.5 版本)。
server 收到 client 的 Range RPC 请求后,根据 ServiceName 和 RPC Method 将请求转发到对应的handler 实现,handler 首先会将上面描述的一系列拦截器串联成一个拦截器再执行,在拦截器逻辑中,通过调用 KVServer 模块的 Range 接囗获取数据。
etcd 为了保证服务高可用,生产环境一般部署多个节点,多节点之间的数据由于延迟等关系可能会存在不一致的情况。
当 client 发起一个写请求后分为以下几个步骤:
此时若client 发起一个读取 hello的请求,假设此请求直接从状态机中读取,如果连接到的是C节点若C节点磁盘I0出现波动,可能导致它应用已提交的日志条目很慢,则会出现更新hello为world的写命令,在client读 hello 的时候还未被提交到状态机,因此就可能读取到旧数据,如上图查询hello流程所示。
所以在多节点etcd集群中,各个节点的状态机数据一致性存在差异。而我们不同业务场景的读请求对数据是否最新的容忍度是不一样的,有的场景它可以容忍数据落后几秒甚至几分钟,有的场景要求必须读到反映集群共识的最新数据。根据业务场景对数据一致性差异的接受程度,etcd 中有两种读模式。
在etcd的3.1引入,保证在串行读也能读到最新的数据
具体流程如下:
当收到一个线性读请求时,它首先会从Leader获取集群最新的已提交的日志索引(committed index),如上图中的流程二所示。
Leader收到Readlndex请求时,为防止脑裂等异常场景,会向Follower节点发送心跳确认一半以上节点确认Leader身份后才能将已提交的索引(committed index)返回给节点C(上图中的流程三)。
节点则会等待,直到状态机已应用索引(applied index)大于等于Leader的已提交索引时(commited Index)(上图中的流程四),然后去通知读请求,数据已赶上Leader,你可以去状态机中访问数据了(上图中的流程五)。
以上就是线性读通过Readindex机制保证数据一致性原理,当然还有其它机制也能实现线性读,如在早期etcd 3.0中读请求通过走一遍Raft 协议保证一致性,这种Raft log read机制依赖磁盘I0,性能相比 ReadIndex较差。
流程五中的多版本并发控制(Multiversion concurrency control)模块是为了解决etcd v2不支持保存key的历史版本、不支持多key事务等问题而产生的。它核心由内存树形索引模块(treelndex)和嵌入式的KV持久化存储库 (boltdb) 组成。boltdb是个基于B+ tree实现的 key-value键值库,支持事务,提供GetPut等简易API给etcd操作。
etcd MVCC 具体方案如下:
每次修改操作,生成一个新的版本号(revision),以版本号为 key, value 为用户 key-value等信息组成的结构体存储到 blotdb。
读取时先从 treelndex 中获取 key 的版本号,再以版本号作为 boltdb 的 key,从 boltdb 中获取其 value 信息。
treelndex
treelndex模块是基于Google开源的内存版btree 库实现的,treelndex模块只会保存用户的 key和相关版本号信息,用户 key 的value数据存储在boltdb里面,相比ZooKeeper和etcd v2全内存存储,etcdv3对内存要求更低。
简单介绍了etcd如何保存 key的历史版本后,架构图中流程六也就非常容易理解了,它需要从treelndex模块中获取 hello这个 key对应的版本号信息。treelndex模块基于 B-ree快速查找此, key,返回此 key对应的索引项keyIndex即可。索引项中包含版本号等信息。
buffer
在获取到版本号信息后,就可从boltdb模块中获取用户的key-value数据了。不过并不是所有请求都定要从 boltdb 获取数据。etcd出于数据一致性、性能等考虑,在访问boltdb前,首先会从一个内存读事务 buffer中,二分查找你要访问key是否在 buffer里面,若命中则直接返回.
boltdb
若buffer未命中,此时就真正需要向boltdb模块查询数据了,进入了流程七。我们知道MySQL通过 table 实现不同数据逻辑隔离,那么在boltdb是如何隔离集群元数据与用户数据的呢?答案是bucket。boltdb里每个 buckel类似对应MySQL 一个表,用户的key数据存放的 bucket名字的是 key,etcd MVCC元数据存放的 bucket是 meta。因boltdb使用B+ tree来组织用户的key-value数据,获取 bucketkey对象后,通过boltdb的游标Cursor可快速在B+ tree找到 key hello对应的value数据,返回给client。
求从client通过Round-robin负载均衡算法,选择一个etcd server节点,发出 gRPC请求,经过etcd server的的quota模块(检查当前存储够不够),如果ok再到KVServer模块(限速、鉴权),leader发提案到raft层(半数从节点同意,提案会持久化),通过apply模块经过MVCC模块进行存储,通过apply进行返回状态。
当etcd server收到client 发起的put hello写请求后,KV模块会向Raft模块提交一个put提案,我们知道只有集群Leader才能处理写提案,如果此时集群中无Leader,整个请求就会超时。
节点状态
首先在 Raft 协议中它定义了集群中的如下节点状态,任何时刻,每个节点肯定处于其中一个状态:
Follower,跟随者,同步从 Leader 收到的日志,etcd 启动的时候默认为此状态;
Candidate,竟选者,可以发起 Leader 选举;
Leader,集群领导者,唯一性,拥有同步日志的特权,需定时广播心跳给 Follower 节点以维持领导者身份。
term
Raft 协议将时间划分成一个个任期(Term),任期用连续的整数表示,每个任期从一次选举开始,赢得选举的节点在该任期内充当 Leader 的职责,随着时间的消逝,集群可能会发生新的选举,任期号也会单调递增。
通过任期号,可以比较各个节点的数据新旧、识别过期的Leader 等,它在 Raft 算法中充当逻辑时钟,发挥着重要作用。
当 Follower 节点接收 Leader 节点心跳消息超时后,它会转变成 Candidate 节点,并可发起竟选Leader 投票,若获得集群多数节点的支持后,它就可转变成 Leader 节点。
etcd 默认心跳间隔时间(heartbeat-interval)是 100ms, 默认竞选超时时间(electiontimeout)是 1000ms,
注意:你需要根据实际郅署环境、业务场景适当调优,否则就很可能会频繁发生 Leader 选举切换,导致服务稳定性下降。
我们以Leader crash场景为案例,详细介绍一下etcd Leader选举原理
假设集群总共3个节点,A节点为Leader,B、C节点为Follower。
Raft为了优化选票被瓜分导致选举失败的问题,引入随机数,每个节点等待发起选举的时间点不一致。
在 etcd 3.4 中,etcd 引入了一个 PreVote 参数(默认 false),可以用来启用 PreCandidate 状态解决此问题。Follower 在转换成 Candidate 状态前,先进入 PreCandidate 状态,不自增任期号,发起预投票。若获得集群多数节点认可,确定有概率成为 Leader 才能进入 Candidate 状态,发起选举流程。
Leader 收到写请求后,生成一个提案并提交给 Raft 模块
Leader 的Raft 模块为此提案生成一个日志条目,并追加到 Raft 日志中,此处有 WAL持久化
Leader 将新的日志发送给 Follower,Leader 会维护两个核心字段来追踪各个 Follower 的进度信息,一个字段Nextlndex, 它表示 Leader 发送给 Follower 节点的下一个日志条目索引。一个字段是 Matchindex, 它表示 Follower 节点已复制的最大日志条目的索引。
Follower 收到日志后先进行安全检测,通过检测后将该日志写入自己的 Raft 日志中,并回复 Leader 当前已复制的日志最大索引。此处也有WAL持久化。
最后 Leader 根据 Follower 的 Matchindex 信息,找出已经被半数以上的节点同步的位置,这个位置之前的所有日志条目都可以提交了。
Leader 通过消息告诉 Follower 那些日志条目可以执行提交了
Follower 根据 Leader 的信息从Raft模块中取出对应日志条目内容,并应用到状态机中通过以上流程,
Leader 就完成了同步日志条目给 Follower 的任务,一个日志条目被确定为已提交的前提是,它需要被 Leader 同步到一半以上节点上。以上就是 etcd Raft 日志复制的核心原理。
通过选举和日志复制增加一系列规则,实现Raft算法的安全性。
Leader 完全特性:是指如果某个日志条目在某个任期号中已经被提交,那么这个条目必然出现在更大任期号的所有 Leader 中。
只附加原则:Leader只能追加日志条目,不能删除已持久化的日志条目。因此 FollowerC成为新 Leader 后,会将前任的6号日志条目复制到A 节点。
日志匹配特性:Leader 在发送追加日志 RPC 消息时,会把新的日志条目紧接着之前的条目的索引位置和任期号包含在里面。Follower 节点会检査相同索引位置的任期号是否与 Leader 一致,一致才能追加。
它本质上是一种归纳法,一开始日志空满足匹配特性,随后每增加一个日志条目时,都要求上一个日志条目信息与Leader 一致,那么最终整个日志集肯定是一致的。通过以上的 Leader 选举限制、Leader 完全特性、只附加原则、日志匹配等安全特性,Raft 就实现了一个可严格通过数学反证法、归纳法证明的高可用、一致性算法,为 etcd 的安全性保驾护航。