你好,我是唐聪。

在前面的etcd读写流程学习中,我和你多次提到了etcd是基于Raft协议实现高可用、数据强一致性的。

那么etcd是如何基于Raft来实现高可用、数据强一致性的呢?

这节课我们就以上一节中的hello写请求为案例,深入分析etcd在遇到Leader节点crash等异常后,Follower节点如何快速感知到异常,并高效选举出新的Leader,对外提供高可用服务的。

同时,我将通过一个日志复制整体流程图,为你介绍etcd如何保障各节点数据一致性,并介绍Raft算法为了确保数据一致性、完整性,对Leader选举和日志复制所增加的一系列安全规则。希望通过这节课,让你了解etcd在节点故障、网络分区等异常场景下是如何基于Raft算法实现高可用、数据强一致的。

如何避免单点故障

在介绍Raft算法之前,我们首先了解下它的诞生背景,Raft解决了分布式系统什么痛点呢?

首先我们回想下,早期我们使用的数据存储服务,它们往往是部署在单节点上的。但是单节点存在单点故障,一宕机就整个服务不可用,对业务影响非常大。

随后,为了解决单点问题,软件系统工程师引入了数据复制技术,实现多副本。通过数据复制方案,一方面我们可以提高服务可用性,避免单点故障。另一方面,多副本可以提升读吞吐量、甚至就近部署在业务所在的地理位置,降低访问延迟。

多副本复制是如何实现的呢?

多副本常用的技术方案主要有主从复制和去中心化复制。主从复制,又分为全同步复制、异步复制、半同步复制,比如MySQL/Redis单机主备版就基于主从复制实现的。

全同步复制是指主收到一个写请求后,必须等待全部从节点确认返回后,才能返回给客户端成功。因此如果一个从节点故障,整个系统就会不可用。这种方案为了保证多副本的一致性,而牺牲了可用性,一般使用不多。

异步复制是指主收到一个写请求后,可及时返回给client,异步将请求转发给各个副本,若还未将请求转发到副本前就故障了,则可能导致数据丢失,但是可用性是最高的。

半同步复制介于全同步复制、异步复制之间,它是指主收到一个写请求后,至少有一个副本接收数据后,就可以返回给客户端成功,在数据一致性、可用性上实现了平衡和取舍。

跟主从复制相反的就是去中心化复制,它是指在一个n副本节点集群中,任意节点都可接受写请求,但一个成功的写入需要w个节点确认,读取也必须查询至少r个节点。

你可以根据实际业务场景对数据一致性的敏感度,设置合适w/r参数。比如你希望每次写入后,任意client都能读取到新值,如果n是3个副本,你可以将w和r设置为2,这样当你读两个节点时候,必有一个节点含有最近写入的新值,这种读我们称之为法定票数读(quorum read)。

AWS的Dynamo系统就是基于去中心化的复制算法实现的。它的优点是节点角色都是平等的,降低运维复杂度,可用性更高。但是缺陷是去中心化复制,势必会导致各种写入冲突,业务需要关注冲突处理。

从以上分析中,为了解决单点故障,从而引入了多副本。但基于复制算法实现的数据库,为了保证服务可用性,大多数提供的是最终一致性,总而言之,不管是主从复制还是异步复制,都存在一定的缺陷。

如何解决以上复制算法的困境呢?

答案就是共识算法,它最早是基于复制状态机背景下提出来的。 下图是复制状态机的结构(引用自Raft paper), 它由共识模块、日志模块、状态机组成。通过共识模块保证各个节点日志的一致性,然后各个节点基于同样的日志、顺序执行指令,最终各个复制状态机的结果实现一致。

共识算法的祖师爷是Paxos, 但是由于它过于复杂,难于理解,工程实践上也较难落地,导致在工程界落地较慢。standford大学的Diego提出的Raft算法正是为了可理解性、易实现而诞生的,它通过问题分解,将复杂的共识问题拆分成三个子问题,分别是:

下面我以实际场景为案例,分别和你深入讨论这三个子问题,看看Raft是如何解决这三个问题,以及在etcd中的应用实现。

Leader选举

当etcd server收到client发起的put hello写请求后,KV模块会向Raft模块提交一个put提案,我们知道只有集群Leader才能处理写提案,如果此时集群中无Leader, 整个请求就会超时。

那么Leader是怎么诞生的呢?Leader crash之后其他节点如何竞选呢?

首先在Raft协议中它定义了集群中的如下节点状态,任何时刻,每个节点肯定处于其中一个状态:

上图是节点状态变化关系图,当Follower节点接收Leader节点心跳消息超时后,它会转变成Candidate节点,并可发起竞选Leader投票,若获得集群多数节点的支持后,它就可转变成Leader节点。

下面我以Leader crash场景为案例,给你详细介绍一下etcd Leader选举原理。

假设集群总共3个节点,A节点为Leader,B、C节点为Follower。

如上Leader选举图左边部分所示, 正常情况下,Leader节点会按照心跳间隔时间,定时广播心跳消息(MsgHeartbeat消息)给Follower节点,以维持Leader身份。 Follower收到后回复心跳应答包消息(MsgHeartbeatResp消息)给Leader。

细心的你可能注意到上图中的Leader节点下方有一个任期号(term), 它具有什么样的作用呢?

这是因为Raft将时间划分成一个个任期,任期用连续的整数表示,每个任期从一次选举开始,赢得选举的节点在该任期内充当Leader的职责,随着时间的消逝,集群可能会发生新的选举,任期号也会单调递增。

通过任期号,可以比较各个节点的数据新旧、识别过期的Leader等,它在Raft算法中充当逻辑时钟,发挥着重要作用。

了解完正常情况下Leader维持身份的原理后,我们再看异常情况下,也就Leader crash后,etcd是如何自愈的呢?

如上Leader选举图右边部分所示,当Leader节点异常后,Follower节点会接收Leader的心跳消息超时,当超时时间大于竞选超时时间后,它们会进入Candidate状态。

这里要提醒下你,etcd默认心跳间隔时间(heartbeat-interval)是100ms, 默认竞选超时时间(election timeout)是1000ms, 你需要根据实际部署环境、业务场景适当调优,否则就很可能会频繁发生Leader选举切换,导致服务稳定性下降,后面我们实践篇会再详细介绍。

进入Candidate状态的节点,会立即发起选举流程,自增任期号,投票给自己,并向其他节点发送竞选Leader投票消息(MsgVote)。

C节点收到Follower B节点竞选Leader消息后,这时候可能会出现如下两种情况:

Leader选出来后,它什么时候又会变成Follower状态呢? 从上面的状态转换关系图中你可以看到,如果现有Leader发现了新的Leader任期号,那么它就需要转换到Follower节点。A节点crash后,再次启动成为Follower,假设因为网络问题无法连通B、C节点,这时候根据状态图,我们知道它将不停自增任期号,发起选举。等A节点网络异常恢复后,那么现有Leader收到了新的任期号,就会触发新一轮Leader选举,影响服务的可用性。

然而A节点的数据是远远落后B、C的,是无法获得集群Leader地位的,发起的选举无效且对集群稳定性有伤害。

那如何避免以上场景中的无效的选举呢?

在etcd 3.4中,etcd引入了一个PreVote参数(默认false),可以用来启用PreCandidate状态解决此问题,如下图所示。Follower在转换成Candidate状态前,先进入PreCandidate状态,不自增任期号, 发起预投票。若获得集群多数节点认可,确定有概率成为Leader才能进入Candidate状态,发起选举流程。

因A节点数据落后较多,预投票请求无法获得多数节点认可,因此它就不会进入Candidate状态,导致集群重新选举。

这就是Raft Leader选举核心原理,使用心跳机制维持Leader身份、触发Leader选举,etcd基于它实现了高可用,只要集群一半以上节点存活、可相互通信,Leader宕机后,就能快速选举出新的Leader,继续对外提供服务。

日志复制

假设在上面的Leader选举流程中,B成为了新的Leader,它收到put提案后,它是如何将日志同步给Follower节点的呢? 什么时候它可以确定一个日志条目为已提交,通知etcdserver模块应用日志条目指令到状态机呢?

这就涉及到Raft日志复制原理,为了帮助你理解日志复制的原理,下面我给你画了一幅Leader收到put请求后,向Follower节点复制日志的整体流程图,简称流程图,在图中我用序号给你标识了核心流程。

我将结合流程图、后面的Raft的日志图和你简要分析Leader B收到put hello为world的请求后,是如何将此请求同步给其他Follower节点的。

首先Leader收到client的请求后,etcdserver的KV模块会向Raft模块提交一个put hello为world提案消息(流程图中的序号2流程), 它的消息类型是MsgProp。

Leader的Raft模块获取到MsgProp提案消息后,为此提案生成一个日志条目,追加到未持久化、不稳定的Raft日志中,随后会遍历集群Follower列表和进度信息,为每个Follower生成追加(MsgApp)类型的RPC消息,此消息中包含待复制给Follower的日志条目。

这里就出现两个疑问了。第一,Leader是如何知道从哪个索引位置发送日志条目给Follower,以及Follower已复制的日志最大索引是多少呢?第二,日志条目什么时候才会追加到稳定的Raft日志中呢?Raft模块负责持久化吗?

首先我来给你介绍下什么是Raft日志。下图是Raft日志复制过程中的日志细节图,简称日志图1。

在日志图中,最上方的是日志条目序号/索引,日志由有序号标识的一个个条目组成,每个日志条目内容保存了Leader任期号和提案内容。最开始的时候,A节点是Leader,任期号为1,A节点crash后,B节点通过选举成为新的Leader, 任期号为2。

日志图1描述的是hello日志条目未提交前的各节点Raft日志状态。

我们现在就可以来回答第一个疑问了。Leader会维护两个核心字段来追踪各个Follower的进度信息,一个字段是NextIndex, 它表示Leader发送给Follower节点的下一个日志条目索引。一个字段是MatchIndex, 它表示Follower节点已复制的最大日志条目的索引,比如上面的日志图1中C节点的已复制最大日志条目索引为5,A节点为4。

我们再看第二个疑问。etcd Raft模块设计实现上抽象了网络、存储、日志等模块,它本身并不会进行网络、存储相关的操作,上层应用需结合自己业务场景选择内置的模块或自定义实现网络、存储、日志等模块。

上层应用通过Raft模块的输出接口(如Ready结构),获取到待持久化的日志条目和待发送给Peer节点的消息后(如上面的MsgApp日志消息),需持久化日志条目到自定义的WAL模块,通过自定义的网络模块将消息发送给Peer节点。

日志条目持久化到稳定存储中后,这时候你就可以将日志条目追加到稳定的Raft日志中。即便这个日志是内存存储,节点重启时也不会丢失任何日志条目,因为WAL模块已持久化此日志条目,可通过它重建Raft日志。

etcd Raft模块提供了一个内置的内存存储(MemoryStorage)模块实现,etcd使用的就是它,Raft日志条目保存在内存中。网络模块并未提供内置的实现,etcd基于HTTP协议实现了peer节点间的网络通信,并根据消息类型,支持选择pipeline、stream等模式发送,显著提高了网络吞吐量、降低了延时。

解答完以上两个疑问后,我们继续分析etcd是如何与Raft模块交互,获取待持久化的日志条目和发送给peer节点的消息。

正如刚刚讲到的,Raft模块输入是Msg消息,输出是一个Ready结构,它包含待持久化的日志条目、发送给peer节点的消息、已提交的日志条目内容、线性查询结果等Raft输出核心信息。

etcdserver模块通过channel从Raft模块获取到Ready结构后(流程图中的序号3流程),因B节点是Leader,它首先会通过基于HTTP协议的网络模块将追加日志条目消息(MsgApp)广播给Follower,并同时将待持久化的日志条目持久化到WAL文件中(流程图中的序号4流程),最后将日志条目追加到稳定的Raft日志存储中(流程图中的序号5流程)。

各个Follower收到追加日志条目(MsgApp)消息,并通过安全检查后,它会持久化消息到WAL日志中,并将消息追加到Raft日志存储,随后会向Leader回复一个应答追加日志条目(MsgAppResp)的消息,告知Leader当前已复制的日志最大索引(流程图中的序号6流程)。

Leader收到应答追加日志条目(MsgAppResp)消息后,会将Follower回复的已复制日志最大索引更新到跟踪Follower进展的Match Index字段,如下面的日志图2中的Follower C MatchIndex为6,Follower A为5,日志图2描述的是hello日志条目提交后的各节点Raft日志状态。

最后Leader根据Follower的MatchIndex信息,计算出一个位置,如果这个位置已经被一半以上节点持久化,那么这个位置之前的日志条目都可以被标记为已提交。

在我们这个案例中日志图2里6号索引位置之前的日志条目已被多数节点复制,那么他们状态都可被设置为已提交。Leader可通过在发送心跳消息(MsgHeartbeat)给Follower节点时,告知它已经提交的日志索引位置。

最后各个节点的etcdserver模块,可通过channel从Raft模块获取到已提交的日志条目(流程图中的序号7流程),应用日志条目内容到存储状态机(流程图中的序号8流程),返回结果给client。

通过以上流程,Leader就完成了同步日志条目给Follower的任务,一个日志条目被确定为已提交的前提是,它需要被Leader同步到一半以上节点上。以上就是etcd Raft日志复制的核心原理。

安全性

介绍完Leader选举和日志复制后,最后我们再来看看Raft是如何保证安全性的。

如果在上面的日志图2中,Leader B在应用日志指令put hello为world到状态机,并返回给client成功后,突然crash了,那么Follower A和C是否都有资格选举成为Leader呢?

从日志图2中我们可以看到,如果A成为了Leader那么就会导致数据丢失,因为它并未含有刚刚client已经写入成功的put hello为world指令。

Raft算法如何确保面对这类问题时不丢数据和各节点数据一致性呢?

这就是Raft的第三个子问题需要解决的。Raft通过给选举和日志复制增加一系列规则,来实现Raft算法的安全性。

选举规则

当节点收到选举投票的时候,需检查候选者的最后一条日志中的任期号,若小于自己则拒绝投票。如果任期号相同,日志却比自己短,也拒绝为其投票。

比如在日志图2中,Folllower A和C任期号相同,但是Follower C的数据比Follower A要长,那么在选举的时候,Follower C将拒绝投票给A, 因为它的数据不是最新的。

同时,对于一个给定的任期号,最多只会有一个leader被选举出来,leader的诞生需获得集群一半以上的节点支持。每个节点在同一个任期内只能为一个节点投票,节点需要将投票信息持久化,防止异常重启后再投票给其他节点。

通过以上规则就可防止日志图2中的Follower A节点成为Leader。

日志复制规则

在日志图2中,Leader B返回给client成功后若突然crash了,此时可能还并未将6号日志条目已提交的消息通知到Follower A和C,那么如何确保6号日志条目不被新Leader删除呢? 同时在etcd集群运行过程中,Leader节点若频繁发生crash后,可能会导致Follower节点与Leader节点日志条目冲突,如何保证各个节点的同Raft日志位置含有同样的日志条目?

以上各类异常场景的安全性是通过Raft算法中的Leader完全特性和只附加原则、日志匹配等安全机制来保证的。

Leader完全特性是指如果某个日志条目在某个任期号中已经被提交,那么这个条目必然出现在更大任期号的所有Leader中。

Leader只能追加日志条目,不能删除已持久化的日志条目(只附加原则),因此Follower C成为新Leader后,会将前任的6号日志条目复制到A节点。

为了保证各个节点日志一致性,Raft算法在追加日志的时候,引入了一致性检查。Leader在发送追加日志RPC消息时,会把新的日志条目紧接着之前的条目的索引位置和任期号包含在里面。Follower节点会检查相同索引位置的任期号是否与Leader一致,一致才能追加,这就是日志匹配特性。它本质上是一种归纳法,一开始日志空满足匹配特性,随后每增加一个日志条目时,都要求上一个日志条目信息与Leader一致,那么最终整个日志集肯定是一致的。

通过以上的Leader选举限制、Leader完全特性、只附加原则、日志匹配等安全特性,Raft就实现了一个可严格通过数学反证法、归纳法证明的高可用、一致性算法,为etcd的安全性保驾护航。

小结

最后我们来小结下今天的内容。我从如何避免单点故障说起,给你介绍了分布式系统中实现多副本技术的一系列方案,从主从复制到去中心化复制、再到状态机、共识算法,让你了解了各个方案的优缺点,以及主流存储产品的选择。

Raft虽然诞生晚,但它却是共识算法里面在工程界应用最广泛的。它将一个复杂问题拆分成三个子问题,分别是Leader选举、日志复制和安全性。

Raft通过心跳机制、随机化等实现了Leader选举,只要集群半数以上节点存活可相互通信,etcd就可对外提供高可用服务。

Raft日志复制确保了etcd多节点间的数据一致性,我通过一个etcd日志复制整体流程图为你详细介绍了etcd写请求从提交到Raft模块,到被应用到状态机执行的各个流程,剖析了日志复制的核心原理,即一个日志条目只有被Leader同步到一半以上节点上,此日志条目才能称之为成功复制、已提交。Raft的安全性,通过对Leader选举和日志复制增加一系列规则,保证了整个集群的一致性、完整性。

思考题

好了,这节课到这里也就结束了,最后我给你留了一个思考题。

哪些场景会出现Follower日志与Leader冲突,我们知道etcd WAL模块只能持续追加日志条目,那冲突后Follower是如何删除无效的日志条目呢?

感谢你阅读,也欢迎你把这篇文章分享给更多的朋友一起阅读。

03思考题答案

在上一节课中,我给大家留了一个思考题:expensive request是否影响写请求性能。要搞懂这个问题,我们得回顾下etcd读写性能优化历史。

在etcd 3.0中,线性读请求需要走一遍Raft协议持久化到WAL日志中,因此读性能非常差,写请求肯定也会被影响。

在etcd 3.1中,引入了ReadIndex机制提升读性能,读请求无需再持久化到WAL中。

在etcd 3.2中, 优化思路转移到了MVCC/boltdb模块,boltdb的事务锁由粗粒度的互斥锁,优化成读写锁,实现“N reads or 1 write”的并行,同时引入了buffer来提升吞吐量。问题就出在这个buffer,读事务会加读锁,写事务结束时要升级锁更新buffer,但是expensive request导致读事务长时间持有锁,最终导致写请求超时。

在etcd 3.4中,实现了全并发读,创建读事务的时候会全量拷贝buffer, 读写事务不再因为buffer阻塞,大大缓解了expensive request对etcd性能的影响。尤其是Kubernetes List Pod等资源场景来说,etcd稳定性显著提升。在后面的实践篇中,我会和你再次深入讨论以上问题。

评论