之前的文章浅析了一下Raft算法的核心内容,包括领导选举、日志复制、日志压缩、nextIndex
回退等。相比于之前、、那篇文章,这篇文章的主要目的为补充一下节点变更部分的介绍,以及一些对于选举和读命令的优化。全文主要参考Raft作者的博士论文:CONSENSUS: BRIDGING THEORY AND PRACTICE
往期文章:
分布式一致性共识算法之Raft算法浅析
Raft算法实现之领导选举与日志复制
Raft算法实现之状态持久化与日志压缩
节点变更
上一篇浅析文章中的所有内容,都是假定server成员不变的情况下进行的。但是,实际开始发中,难免会遇到需要进行节点变更的情况,包括节点增加、删除以及替换等。最直接的做法就是在需要进行节点变更时,将整个系统关机,然后人工进行成员的配置。这种做法的问题在于关机的这段时间无法响应客户点请求,而且人工的参与可能会由于疏忽导致出现问题。因此,论文中提出使用代码层面的改动来自动化节点变更这一过程。相应RPC如下图所示。
论文中将节点变更时的对象称为configuration (the set of servers participating in the consensus algorithm)
,即集群内的所有节点。首先,论文中提出对于一个以上的成员变更时,不能草率地直接进行成员的删减。我们以一个三节点集群增加两节点为例看一下原因:
如图,当节点变更的configuration
信息已被S3收到,而S1、S2未收到时,可能会出现脑裂现象。此时S1发起选举,S2投票给S1。由于S1、S2目前认为集群中只有三个节点,导致S1成功转变为Leader。同时,S3发起选举,S4、S5投票给S3,S3成功转变为Leader,使得同一个Term内出现了两个Leader。
解决办法就是每次只变更一个节点,这样可以保证对于边更前后的configuration
而言,“过半数的节点”都是唯一的。即,不会像上图那样出现两种“过半数节点”的情况,可以看下面的图对比体会一下。
更多时候,作者都推荐采用上面的这种方法,但作者也确实提供了一种能够一次性变更多个节点的方法(相比于上一种,作者更先想到的其实是这种方法),如下图所示:
图中虚线表示已被append但未被commit的LogEntry
,实线则表示最新的已被commit的LogEntry
。作者在这种方法里提出了一种过渡状态: joint consensus 。
当Leader收到了一个节点变更请求configuration
时,会将configuration
封装为一条LogEntry
(图中的Cold,new
)并进入joint consensus状态。当任何节点Append了Cold,new
后(这一步可能会对节点的配置做额外的操作,例如修改节点所知的集群节点数),就转变为联合configuration的成员。联合成员,我的理解为即属于old成员,也属于new成员。
如果在这一期间Leader Crash,那么新的Leader可能会从旧configuration中产生,也可能从新的configuration中产生,取决于Candidate是否收到
Cold,new
。无论从哪一部分产生,均可以保证只会出现一个Leader(joint consensus 要求“两个过半数”节点均需要共识的性质)。此外,这一期间old configuration的LogEntry
的Commit只需要旧configuration的过半数节点共识,Cold,new
的提交则需要“两个过半数”节点达成共识。
一旦Cold,new
被提交,意味着过半数的旧configuration成员已经接收到了需要变更的configuration。因此,此时如果有新configuration的LogEntry
(Cnew
)被append,那么这条LogEntry
只需要过半数的新configuration成员共识即可。任何appendCnew
的节点即刻转变为新configuration节点,最终实现节点变更。
在Cold,new
提交之后、新configuration的LogEntry
被append之前的任何LogEntry
依旧需要“两个过半数”节点共识(一般情况下这两条LogEntry
之间不会有非新configuration的LogEntry
,因为Cold,new
意味着开始节点变更,那么之后的LogEntry
都是对于新configuration而言的)。
说实话,由于没有去实际实现Membership Change,这一部分我的理解也只是停留在理论阶段,理解较浅,远不如其他部分。如有错误,欢迎指出讨论~
单步节点变更存在的问题
尽管Raft的作者Ongaro先生更推荐前一种每次只变更一个节点的变更方法,但这种方法实际上存在一个bug。2015年,Ongaro先生在论坛上公布了这个bug(链接在这里):当多个节点变更请求并行执行时,可能会因为网络分区问题造成已提交的LogEntry
(包含了新的配置)被覆盖,下面简单介绍一下其中的一个例子:
如图中的Fa
部分所示,起初为一个四节点集群。在Term1,S1为Leader,其余为Follower。LogEntry C
中的配置为{1, 2, 3, 4}
,此时S1要向集群中加一个节点,即append一个LogEntry D: {1, 2, 3, 4, 5}
。在S1顺利将LogEntry D
append到S5后,在append至S2、S3、S4之前,S1发生网络分区从集群中下线(如图Fb
所示)。此时,S2发起选举,成为Term2的Leader(图Fc
),并接收到从集群中remove S1的请求。
S2 append一个LogEntry E: {2, 3, 4}
至自身,并将LogEntry E
成功Append至S3(图Fd
)。由于对S2而言,LogEntry E
已经成功append至过半数节点S2、S3
,因此,提交LogEntry E
。
在S2 appendLogEntry E
至S4前,S1从分区中恢复,收到更大Term的AppendEntries RPC
,转换为Leader。同时,由于网络问题,S1没有收到S2的AppendEntries RPC
,在Term3发起选举,获得S4、S5的选票成为新的Leader(下图Fe
)。
S1继续将Term1的LogEntry D
复制至S2、S3,使得S2、S3中已提交的LogEntry E
被覆盖。
解决方法的思路也很明晰:每个Leader在刚上任时,均先尝试提交一条不包含任何指令的LogEntry
,即空的no-op LogEntry
。提交了no-op
之后,再进行其他LogEntry
的流程。这么一来,上图中S2刚上任时,会先append一个Term2的no-op
至S2、S3、S4。若成功提交,那么S1在Term3就不会当选(只有S5会投票);若没有成功,在no-op
append过程中S1发起选举,此时包含了remove配置的LogEntry E
还没有提交,可以接收被覆盖。
其实Leader刚当选时先尝试提交no-op
的方法对于另一个地方也有一定的作用:在上一篇文章中说到过,为了解决已提交的LogEntry
可能会被覆盖的问题,Leader只能提交当前Term的LogEntry
。这就意味着,之前Term的LogEntry
直到当前Term的Leader提交当前Term的LogEntry
之前,都无法提交。极限情况就是当前Term一直没有Client发起指令,那么之前Term的LogEntry
就一直无法提交。这种情况下,也可以通过no-op
来解决。
当然了,实际实现时,我在Client端设置了重试的机制:如果一条指令一定时间后都没有响应,会换个节点重试,这种方法跟no-op
均可以解决之前Term的LogEntry
迟迟无法提交的问题,只是实现上从不同的端来实现。
选举优化:PreVote
我自己在实现Raft的时候,出现过以下这种情况:
如图,在一个三节点的集群中,Term1时S0为Leader,S1、S2为Follower。在某一时刻,S1因为出现了网络分区造成无法收到Leader的心跳而超时,发起选举。此时S1是收不到任何选票的,但依旧会无休止地递增自己的currentTerm
并发起一轮又一轮的选举。之后,在某一时刻网络分区恢复,S1此时的Term已经增大到了Term9
,导致原先的Leader S0回退为Follower,并重新选举。
显然,上述的网络分区造成的S1 Term的增加以及网络分区结束后造成的重新选举是没有必要的,甚至有些多余。因为过半数的节点正常运行,S1的恢复打断了正常的运行状态。并且,在重新选举的这段时间内,是无法响应客户端请求的。因此,论文提出了一种名为Pre-Vote
的优化措施。
在一个Follower的选举计时器超后,准备自增currentTerm
并发起选举前,Pre-Vote
会要求这个节点发送预选举RPC来确定自己是否有被选举为Leader的可能。具体的参数、Reply与RequestVote
的一致,不同之处在于发送Pre-Vote
RPC时并不改变自己的currentTerm
(Pre-Vote
RPC的参数为currentTerm + 1
),也不转变自己的身份为Candidate。其他节点收到Pre-Vote
RPC后,处理逻辑与RequestVote
RPC一致,不同在于对Pre-Vote
的处理并不会改变任何自身的State(currentTerm
、VoteFor
等)。当发送了Pre-Vote
RPC的server收到过半数的预投票应答时,再自增currentTerm
、转变为Candidate并发起选举。
有了这个优化以后,上图中的S1在网络分区时就不会一直自增currentTerm
。并且,当网络分区结束后,S1的currentTerm
依旧为1,依旧以S0为Leader,响应AppendEntries RPC
。
但仅仅这样的PreVote机制在某些情况下依旧是不够的,我们考虑这样的情况:
如图,图中的LogEntry
里的数字代表Term,假设S0和S2之间的网络由于物理原因非常不稳定。在Term2,S2收到一条新的指令,还没来得及复制至Follower,由于网络关系S0没有收到S2的AppendEntries RPC
导致超时发起选举。此时的PreVote是满足S0的当选要求的。但实际上,当前的Leader S2一切正常,没有分区也没有故障。假如S0在Term3当选,那么S2也可能会因为S0、S2之间不可靠网络的问题在Leader S0正常的情况下超时发起选举,如此往复。因此,我们需要对PreVote添加一些条件。
添加的条件很简单,即判断当前有没有活跃的Leader:如果Follower在选举周期内正常接收到了一个AppendEntries RPC
,那么认为对于这个Follower而言,在当前选举周期内,当前Leader是活跃的(正常的)。换句话说,如果PreVote
RPC的接收者在过去一个选举周期内没有收到AppendEntries RPC
,那么会回复PreVote
success的reply。
有了这个限制后,上图中的S1在PreVote阶段就不会给S0投票了。当然,这一限制导致PreVote需要多等一个选举周期的时间,会有一定的时间成本。
总结:PreVote要满足两个条件才会获得选票:
- 发起者的Term更新,或者
LogEntry
更新; - 接收者在过去一个选举周期内没有收到
AppendEntries RPC
。
CheckQuorum
其实,上面提到的PreVote的第二条限制在某些情况下可能会给集群带来副作用(;༎ຶД༎ຶ/)看下面这种情况:
如图,在一个五节点的集群中,某一时刻,节点5发生网络分区,无法与其他节点通信。在PreVote
机制,节点5不会无限自增自己的currentTerm
,这没问题;Leader4发生某种网络故障,使得Leader4只能和节点1正常通信,而无法与节点2、3正常通信。此时,只有节点1、2、3一切正常。因此期望的逻辑应为节点1、2、3中的某一节点成为新的Leader。
节点2、3由于收不到Leader4的AppendEntries RPC
,选举计时器超时发起选举。但在PreVote
阶段,由于限制2的加入,节点1会持续收到Leader4的AppendEntries RPC
,认为当前Leader“活跃”,故不会投票给节点2、3。因此,节点2、3无法获得足够的预选票发起选举。
显然,整个集群只能一直“僵持”在当前的状态下,而当前状态的Leader是无法提交任何指令的(LogEntry
无法append至过半数节点上)。解决方法也很简单:我们让Leader在没有收到足够(过半数)的AppendEntries RPC
时能够自动“退位”,使得S1不再认为Leader4活跃,以推动投票。这一机制就是CheckQuorum
。
当然,实际实现中,考虑到网络不稳定的问题,我们可以让Leader连续多次没有收到过半数的successAppendEntries
响应时再回退为Follower。
可见,为了保持一致性,我们在原先的Raft基础上加了很多额外的机制,使得整个算法相对地复杂了一些。这也不可避免地会增加一些消息的响应延迟。在实际实现时,我们需要视情况而定做一定的权衡。
读命令优化
我们知道,Get命令对于状态机的状态是没有影响的,即,读请求并不会改变状态机的状态。因此,在一个大量命令均为Get命令、只有少数命令为Put/Append命令的场景下,每次都让Get命令走一遍Raft的日志复制流程会显得响应时间过长,故需要进行相应的优化。
ReadIndex
ReadIndex的核心思想就是绕过Raft的日志复制过程,直接在Leader的State Machine上进行读取。在Leader收到客户端的一个Get命令后,主要包含以下步骤:
- 如果Leader没有commit任何当前Term的
LogEntry
,那么等待Leader提交一个当前Term的LogEntry
。特别地,如果Leader刚刚当选,可以尝试在自己的Log中添加一个空的LogEntry
(LogEntry
的Term值对应当前Term,Command为空,即上面提到的no-op
)。当no-op
被提交时,意味着在当前Term,Leader的commitIndex大于等于任何其他Server的commitIndex
。 - 之后,将此时的
commitIndex
暂存起来,称之为readIndex
。 - Leader发送一轮心跳,如果Leader收到过半数的Follower的心跳success答复,说明此时没有更新的Leader产生,也说明此时
readIndex
为所有节点中最大的commitIndex
。 - 等待
readIndex
的LogEntry
被apply。 - 直接在State Machine上使用Get命令查询并返回给客户端结果。
第一步的目的是确保Leader的commitIndex
大于等于任何其他Server的commitIndex
,即确保Leader的commitIndex
为最新。如果没有第一步,可能会出现以下的情况:三个节点的集群中,S0为Leader,S1、S2为Follower。client发送一条write:x -> 1
命令给Leader,Leader通过Raft层将这条LogEntry
提交,但在两个Follower收到更新commitIndex
的AppendEntries RPC
前,Leader宕机(一条命令需要至少两次AppendEntries RPC
才能在Follower上apply,此时write:x -> 1
在Leader上已apply,在Follower上还没有)。余下两个Follower发起新的选举,S1当选。此时如果没有步骤一,那么readIndex肯定小于write:x -> 1
命令对应的LogEntry
。此时如果client再发送一条Get: x
命令给S1,得到的reply自然也不会为1
。
第三步的目的是确认没有新的Leader产生,因为Leader可能会因为网络分区的原因造成无法与Follower通信,另一个网络分区包含更多的节点,选举出新的Leader。显然,Client的指令此时应该发送给新的Leader。
此外,论文还提到,Follower也可以帮助Leader分担一些Get命令的负载,同时增加整个系统的吞吐量。换句话说,Follower也可以响应Client的读命令。具体做法上,当Follower收到Client的一条Get请求后,会向Leader发送一个请求readIndex
的RPC。Leader在执行了上面提到的1-3步骤后返回给Follower当前readIndex
;之后,Follower执行第4、5步内容。
Lease
前面提到,读命令优化的主要思路就是绕过Raft的日志复制过程,以便更快地响应客户端的Get请求。但,ReadIndex方法依旧需要发送一轮心跳来确定readIndex
,存在一定延迟。因此,论文中提出了一种名为Lease
的方法。
我们知道,在选举计时器到期前Follower是不会发起选举的,这就意味着Leader在某一轮心跳结束后直到最小的那个follower选举周期结束前,自己都是Leader,哪怕在这期间发生了网络分区。因此,在这期间的客户端读请求,Leader不需要发送心跳来确认自己的Leader身份。
如上图所示,具体地,当Leader在Start时刻发送一轮心跳并收到过半数的success时,会认为直到start + lease
的时间段内自己依旧是Leader。这个lease
的时间论文中给的公式是election timeout / clock drift bound
,保证小于一个选举周期。clock drift bound
直译过来为时钟漂移界限,论文中也说这个界限比较难获得和维护,这里也就不再深入研究了,我们只需要知道lease
的值小于election timeout
即可。
一些思考
为什么Raft的部分成员需要持久化?
这其实是上一篇浅析中应该思考的问题,看优化的时候突然想到:首先,log[]
的持久化显而易见,如果每restart一次所有的LogEntry
都丢了那么肯定称不上“共识”了。
而currentTerm
需要持久化最大的原因就是要保持整个集群的一致性,包括但不限于在一个Term内仅会给一个server投票、不接收旧Leader的AppendEntries RPC
等,举几个例子:
- 假设有一个节点数为3的集群。Term 3时,Follower 1收到了同一个Term = 3的Candidate 0的
RequestVote
请求,并投票给Candidate。此时Follower 1故障重启,由于currentTerm
没有持久化,重启后的server 1的currentTerm
小于3(我们就以2为例)。此时,Term = 3的另一个Candidate 2也发送了RequestVote
给Server 1,server 1发现请求的Term更大,所以更新自己的Term并投票给Candidate2。这么一来,server0、2均得到了两票选票,成为Leader,使得Term3出现了两个Leader,造成脑裂。 - 在一个节点数为3的集群中,
currentTerm
为3的Follower0的日志中最后一条LogEntry
的Term为2,Term3的Leader为server1,且Server2为Term1的发生了网络分区的Leader。假设此时S0、S1均发生故障重启,同时S2的网络分区恢复。那么由于没有保存currentTerm
,使得S0、S1的currentTerm
初始化为0。此时S0、S1的日志会在接收到S2的AppendEntries RPC
时被错误地覆盖。
最后是votedFor
。votedFor
需要持久化的主要原因还是防止脑裂现象的发生,即防止一个Term内出现两个Leader。我们还是考虑一个三节点的系统。假如不持久化votedFor
,Follower0在收到Server1的RequestVote
并投票给S1后宕机,并在极短时间内恢复,votedFor
为none
。假如此时再收到S2的RequestVote
,显然满足投票条件,S0会投票给S2,使得S1、S2均收到了两票投票,转变为Leader。
读命令优化前后对比
这里我想对比一下上文中读命令优化前后的执行比较,纯个人理解,欢迎讨论。
如图,假设有两个client,并行地向Leader发送命令。client 1先发送write: x -> 1
的命令,之后发送Get: x
的命令;client2则发送write: x-> 2
的命令,三条命令被append至Leader的log[]
的先后顺序如Leader的log[]
所示,并且假设client 1发送Get: x
时,Leader的commitIndex
为1,即 write: x -> 1
命令对应的LogEntry
。
不考虑apply或者commit失败的情况,当没有进行读命令优化时,显然,最终Get: x
返回的结果为2
。因为Leader会按append至log[]
的顺序commit LogEntry
。
但是,如果进行了读命令优化(以ReadIndex)为例,client 1发送Get: x
时,Leader执行完步骤1-3后得到的readIndex
为1,对应 write: x -> 1
命令的LogEntry
。所以,返回的结果为1
。即,两种情况下client 1发送的Get: x
请求返回的结果不一样,孰对孰错?
先说结论,两种情况的结果都可以接受。这个其实就涉及到了“线性一致性”的问题,我们看一下论文里关于这个是怎么说的:
Linearizability requires the results of a read to reflect a state of the system sometime after the read was initiated; each read must at least return the results of the latest committed write.
注意,这里说的是对任何的读请求,需要能够反映出最新的已提交的写请求的结果,即已提交。换句话说,虽然跟优化前的结果不同,但ReadIndex优化符合线性一致性,所以结果可以接受。关于线性一致性,能展开的东西比较多,后面打算写一篇分布式一致性的文章具体介绍,这里先简单说一下其中的两个必要要求:1) 任何一条读请求的结果,都要与之前的读请求结果一样新,或者更新。2) 任何一个读操作,都要能够找到最近一次已提交的写操作的结果。更具体的内容可参见书籍Designing Data-Intensive Applications的9.2节。
后记
虽然相比Paxos,Raft已经“化简”了一些。但要部署在实际场景中,在很多细节上还是需要优化的。这篇文章也是自己对于Raft共识算法的一些拓展学习,都属于个人理解,欢迎讨论~
最后,这篇文章将近一万两千字,花了六个多小时。如果能帮到你,那就是有价值的。
(预祝屏幕前的你新年快乐~~~)
参考
CONSENSUS: BRIDGING THEORY AND PRACTICE
Ongaro’s post: a bug of single-server change
《Designing Data-Intensive Applications》 section 9.2
《深入理解分布式系统》 4.8.13、4.8.14节