一文看懂Raft
- Raft是什么
Raft是一种管理日志复制、实现复制状态机系统(Replicated state machines)的共识算法,通俗的解释,是保证客户端发送的数据操作(日志)在集群的所有server中得到一致性执行的算法,从而保证数据的最终一致性。
共识算法的鼻祖是Paxos,但是Paxos在理解性和实现性比较弱。Raft相比Paxos最大优势是容易理解和实现。
工业界有哪些系统是复制状态机?基本上单个Leader的集群系统全部或者部分是复制状态机。GFS、HDFS用复制状态机子系统实现选举Leader和配置信息管理;Chubby、ZooKeeper本身就是复制状态机。
复制状态机是一个结果,集群中所有server的数据(状态)一致、相同,实现复制状态机的思路有很多,复制操作日志是一种常用的方式,而Raft是复制操作日志的一种实现方式。
- Raft算法
- 角色
集群中server可以拥有下面3种角色任意一种:
(1) Leader(领导者)
只允许有一个Leader,Leader负责处理client的数据操作请求以及复制日志。
(2) Candidate(候选人)
每个非Leader的server有一个随机时间的定时器,这个定时叫做election timeout,在election timeout时间内,没有收到Leader的任何消息(包括心跳、复制日志的RPC等),server会认为Leader挂了,它就会变成Candidate,给集群中其他server发竞选投票的RPC,竞选Leader。竞选结束后,Candidate或者成为Leader,或者重新做回Follower
(3) Follower(跟随者)
Leader选举完毕后,除Leader外的server都是Follower,Follower接收Leader的RPC(包括心跳、复制日志的RPC等)
图1是3种角色转换过程的简易图示:
图 1 Raft角色及转换关系
2. 选举(Leader election)
Raft把时间划分成连续的terms(任期),在一个term内,最多只有一个Leader,并且term的值会在所有server中存储(在Raft的各流程中,机器存的term都发挥着重要作用,每个机器的term不一定相同)。term以选举Leader作为起点。下面详述选举过程。
每个server被指定随机的election time out,这个时间内,没有收到任何来自Leader或者Candidate的消息,则该server启动为自己竞选投票的流程,角色变成Candidate.
如果Candidate的term是最新的(和当前Leader的term值相同),那么从这一时刻开始,当前Leader的任期(term)结束,新的term开始,election开始,选出新的Leader,之前的Leader在收到Candidate发来的投票RPC(RequestVote RPC)会转变成Follower。由于没有Leader,选举期间无法处理来自client的数据处理请求。client发现自己的请求一直未被处理,会在一定时间后再次发送。
Candidate发出的RPC和收到的消息格式如图2。
图2 RequestVote RPC的消息格式
发出的RPC中包含4个关键信息:
(1) Candidate的term
Follower转变成Candidate时,会把自己机器的term(如果该机器的网络和软硬件状况良好,那么它的term应该就是Leader的term,如果不是,term的值可能偏小)加1后的值发送到投票信息中。一般只有等于Leader的term的Candidate才可能竞选成功
(2) CandidateId
这个Id的含义,我理解为server的唯一标识符,图2给出的释义是投票的标识符,其实含义是一样的
(3) lastLogIndex、lastLogTerm
Candidate中最后一条log的信息(log的顺序号和term值)。这个信息非常关键,表明了日志“新的程度”
接收到voteRPC的服务器(Receiver)根据先来后到的顺序处理投票流程,并且对于某个term(voteRPC中那个term)只能给出一张同意票,就是说如果同意票投给了一个Candidate,那么之后收到的相同term的voteRPC,直接否定
Receiver投票的流程如下:
(1) 比较voteRPC中的term和Receiver的term
voteRPC中的term不能小于Receiver的term(正常情况下应该是voteRPC term=Receiver term+1),否则不通过。Raft允许voteRPC term=Receiver term通过(voteRPC term=Candidate原有term+1,说明Candidate比Receiver可能少一个term),一方面说明term不是一个严格的判断措施,另外一方面,也是为了兼容Candidate由于网络故障重发相同term的voteRPC这种情况
第(1)步通过后,刷新Receiver的term为voteRPC中term(这点Raft没有明显提出,但Raft中提到每个server的term是它看到过的最大的term),并且如果voteRPC term > Receiver term下,把之前投票存储的CandidateID值清空(如果是相等,不需要这个操作,因为同一term按照先来后到原则只给一张同意票)。然后才能进入第(2)步判断
(2) 没有发出过同意票或者发出的同意票给的是当前的Candidate
只有满足这个条件才进入第(3)步,否则不通过。
Candidate由于网络故障可能会重发相同的voteRPC,Receiver收到相同voteRPC的响应结果应该是相同的
第(2)步解决了同一term只给出一张同意票的逻辑
(3) 比较voteRPC的lastLogIndex和lastLogTerm和Receiver的log
只有和Receiver一样或者更新才通过(为什么比较这2个值就能判断日志的新旧在复制日志那部分会详述)
Candidate赢得选举的充要条件是获得集群中半数以上机器(也包括自己本身的同意票)的投票,因此最多只有一个Candidate能成为Leader。
当Leader产生后,Leader发送心跳(和复制日志RPC格式相同,只是内容为空)给集群中其他server,未获选的Candidate收到新Leader的RPC后角色立即转为Follower,选举结束。
并不是每次选举都能选出Leader,有失败的情形存在,比如有多个Candidate几乎同时竞选,然后都没有获得半数以上投票。在Raft中称这种情形为split votes(分裂的投票)。这时,election time out机制该发挥作用了。
从Candidate发出vote RPC开始,election time out机制启动,生成了一个新的随机时间t(Raft推荐值是150ms~300ms)。在t时间内Candidate没有获得半数以上投票,也没有收到新Leader发来的心跳,那么就会对当前的term加1,发起新一轮的选举。
由于每台机器的election time out是随机的,所以比较大概率避免几乎同时出现多个Candidate的情况,也就是splite votes的状态。实际测试结果显示确实是这样。
election time out的取值是有范围的,这个范围的来源决定于下面的公式:
broadcastTime << electionTimeout << MTBF(Mean Time Between Failure)
broadcastTime表示集群中机器发送广播消息到其他机器并收到回复的平均时间,就是网络传输的平均时间(一般在0.5ms~20ms),MTBF表示机器发生硬件故障的时间(一般是几个月)
只有满足这个公式,才能保证选举占用的时间是整个集群系统正常工作的很小一部分。
3. 复制日志(Log Replication)
集群选出Leader后,就可以接收client发来的数据操作请求。Raft中,client直接和Leader建立通讯,client在第一次和集群通讯时,并不知道Leader的网络地址,会随机选择一个server发送请求,这个server如果不是Leader会拒绝请求并告知Leader地址,后面client就直接和Leader通讯了。
我个人理解也可以采用通过Follower转发的方式:
client->Follower->Leader->Follower->client的链路
这种方式的好处有2个:
- 在读取数据的场景里,有可能转变成方式3: client->Follower->client,减少Leader的负载压力,提高整个集群的吞吐量和高可用
- 降低Leader的带宽压力,虽然最终client的请求都打到Leader上,但在Follower转发过程中会把多个client并发的请求转化成线性请求,而且连续请求的间隔时间Follower可控
Raft方式的优势是:client数量不多的情况下,响应速度更快,实现更简单
Leader收到数据请求后,如果是写数据请求,采用的是WriteAhead Log工作方式,先写日志,复制日志,再提交日志并同时写入数据到state machine(状态机,这是Raft场景中的称呼,现实中比如数据库系统中磁盘存储部分就是一种state machine),最后返回结果。如果是读数据请求,和复制日志就没有关系了。下面描述的都是写数据请求的场景。
Log的数据结构如图3所示,是一个队列,队列中每个节点存储一条Log Entry,Log除了包含数据处理的命令,还记录了这条Log是哪个任期(term)产生的:
图3 日志的数据结构
Raft关于日志有一条至关重要的原则,是Raft实现一致性的基石,Log Matching Property:
if two logs contain an entry with the same index and term,then the logs are identical in all entries up through the given index
(对于2个server上的日志队列,如果存在同一个位置的日志的term是相等的,那么这2个队列从这个位置往前追溯的所有日志是相同的)
这个Property有2层含义,Raft拆分成2个子Property描述:
(1) 对于不同日志队列,如果存在同一位置(index)的log entry的term相等,那么这2个log entry的数据操作command是相同的
(2) 对于不同日志队列,如果存在同一位置(index)的log entry的term相等,index位置之前的所有log entry的位置(index)、term都是相同的
如何保证这2个Property?
Raft是逐条复制日志的,复制时,Leader在发送RPC中包含当前复制log entry的前一位置log entry的index、term值(叫last log index, last log term),Follower收到RPC,做一致性检查:当且仅当Follower的日志队列在last log index位置处的log的term值等于last log term,才成功接收日志,否则拒绝,RPC执行失败(失败后会有修复机制,后面再说)。
有了这个检查机制后,任何机器上某个位置的日志的index、term信息就表明了日志来源:是来自任期为term的Leader的第index条日志。由于Leader在任期内是不会删除/移动已插入的日志,所以该位置的日志是唯一确定的。Property(1)得证。
Property(2)的证明:设2个队列的第n条日志的term相等,这条日志来自于同一个Leader,所以2个队列的第n-1条日志的term必然都和该Leader第n-1条日志的term相同,所以这2个队列的第n-1条日志的term相等。递归可推到第0个位置。得证。
收到一致性检查失败的消息,Leader启动对Follower日志的修复机制:把Follower的日志强制写成和Leader相同的日志。
具体实现如下:
把Leader的日志全部发送给Follower自然可行,但这样对网络带宽的压力会较大,执行效率较低。因此Raft采用的是倒推的方式测出最大从哪个位置开始,Follower和Leader日志是一致的,然后Leader把该位置往后的日志发给Follower,Follower删除从该位置往后的本地日志,接上Leader发过来的日志,就做到和Leader的日志一致了。
倒推过程仍然是依赖一致性检查:
Leader在本机对每个Follower记录一个nextIndex值,nextIndex表示下一个发送给该Follower的日志位置。
倒推开始,nextIndex初始化为Leader最后一条log的位置。发送复制日志RPC(日志内容为空即可),一致性检查失败后,nextIndex减1,继续发送直至一致性检查通过即为期望的日志位置。
提交日志
Leader收到半数以上server成功接收日志的消息后,就commit该日志以及之前所有未提交的日志,同时把日志command应用到本地state machine。
每个server(包括Leader)都存有一个变量commitIndex,表示已提交最新日志的序号, commitIndex之后的所有日志就是uncommitted。Leader的commitIndex在日志复制RPC和心跳中传输,Follower结合Leader的commitIndex和本机的commitIndex即可确定本机待提交的日志条目,并写入本地state machine。通过这种方式,在Leader提交过的所有日志,最终都会在所有Follower中也提交并写入state machine,所以最终集群中所有server的state machine状态是相同的。
Leader Completeness原则
某一任期(term)提交过的log,高于该任期的Leader必然包含这条log
这个原则是Raft安全性非常重要的一条,Raft是以Leader为基石的共识算法,保证Leader变化后log的完整性、一致性显然是最核心的。
Raft如何实现Leader Completeness?
事实上,在选举环节就已经实现了。证明方法如下:反证法!
任期T提交了一条日志X,假设任期U(U>T)的LeaderU不包含日志X,并且U是不包含日志X的最小任期
日志X已经提交,说明集群半数以上的机器包含日志X,LeaderU赢得选举的条件也是半数以上机器投了赞成票,因此集群中必然有一台机器Y,既包含日志X,又给LeaderU投了赞成票。机器Y不会在任期T到任期U之间(不含U)丢失日志X,因为根据假设,这段时间的Leader是包含日志X的,Follower只有在日志和Leader冲突的情况下才会删除相关日志,显然日志X不会被删除。因此,在给LeaderU选举时,机器Y仍然包含日志X。
机器Y投赞成票的的一个重要条件是: 根据lastLogIndex和lastLogTerm,比较Candidate和Receiver的log,只有和Receiver一样新或者更新才同意(Raft原话:candidate’s log is at least as up-to-date as receiver’s log).更新(up-to-date)的完整含义是:比较日志队列中最后一条entry的index和term,term更大的队列更新,如果term相同,那么index更大的队列更新。
如果LeaderU最后一条日志的term和机器Y相等,那么LeaderU最后一条日志的index>机器Y最后一条日志的index,LeaderU必然包含和机器Y最后一条entry相同的entry(基于Follower接收Leader日志的检查机制),根据LogMatchingProperty,LeaderU包含机器Y所有的日志,也就包含日志X。和假设矛盾。
如果LeaerU最后一条日志的term>机器Y,由于机器Y包含日志X,机器Y最后一条日志的term>=T,那么LeaderU最后一条日志的term>T,那么LeaerU最后的日志必然来源于任期T到U之间的一个Leader(设是LeaderM).LeaderM根据假设包含日志X,根据LogMatchingProperty,LeaderU最后一条日志之前的日志和LeaderM一致,因此最后一条日志之前也包含日志X,和假设矛盾。
得证!
基于Leader Completeness和LogMatching 两个Property可以推导出State Machine Safety Property:if a server has applied a log entry at a given index to its state machine, no other server will ever apply a different log entry for the same index。意思就是集群中机器对本地状态机的应用日志的执行顺序和内容是相同的。
推导过程:既然是应用到状态机的日志,那必然是commited的日志,commited的日志即使在Leader变化过程中,仍然在Leader中存在的并且位置也是同样的(Leader Completeness)。Follower在提交该位置日志时(无论是在哪个term提交),根据Log Matching Property,它的日志和Leader在该位置的日志是相同的。得证。
由于机器是按照日志顺序应用到,因此所有机器的状态机的内容最终也是一致的。
4. 集群成员变化(Cluster Membership Changes)
上面选举和复制日志过程的描述都是基于集群中机器配置不变的前提,如果集群需要替换机器会有什么情况发生?如何保证替换过程是安全可靠的?
集群的机器配置在每台机器中都需要保存,这个过程因此可以描述成配置(configuration)升级的过程(从旧配置升级成新配置)。
由于每台机器升级配置的时间是无法保证统一的,因此没有过渡得直接对整个集群强行升级配置必然是不安全的。如下图所示。
图4 直接升级配置的问题
图中描述的是集群中增加server4和server5的情形。在图中箭头所示时刻,集群中有3,4,5这3台机器是新配置,1,2这2台机器还是旧配置,如果这时进行选举,有2个Candidate比如是server1和server5,那么这2个Candidate都可能会被选举成为Leader,因为新旧配置对应的majority的范围是不同的。对于Raft来说,2个Leader就意味着系统出现分裂,是一个坏了的系统。
为了解决配置升级的安全问题,Raft提出2阶段的升级方式:
增加一个过渡阶段:joint consensus(共同协商一致),过渡阶段提交后,进入新配置Cnew。
joint consensus阶段,旧配置Cold和新配置Cnew共同参与集群的决策,比如选举,日志复制。
下图是2阶段升级的时序图:
图5 两阶段配置升级的时序图
Raft把配置当成一条的日志entry,由Leader发送给Follower,任意机器(包括Leader本身)从收到配置日志entry开始,就以这条日志作为决策活动的配置(注意,是以成功接收日志为准,不需要日志已经提交)
初始状态下,所有机器(包括新加入集群的server)都是旧配置Cold,Leader也是Cold。这时,集群中的决策活动和新机器完全无关。
从图5的Cold,new虚线的起始点开始,Leader把Cold,new配置写入自己的日志队列,Leader也从这时开始,决策活动(选举、日志复制)都是采用Cold,new配置。Cold,new配置的含义影响到日志正确commit的含义:必须是旧配置和新配置对应的2个机器集合的半数以上都正确接收到日志,Leader才认为该日志可以commit。
在Cold,new配置完成提交之前,如果Leader挂了,会不会出现选出2个Candidate同时成为Leader的可能性?不会。这时Candidate的配置会是Cold和Cold,new中的一种,无论是哪种,都需要赢得Cold对应机器集合半数以上同意,显然只有一个Candidate能胜出。
如果是Cold配置的Candidate成为Leader,这个Leader需要重新发送Cold,new配置这条特殊日志entry。
Cold,new配置提交完之后,在Cnew进入时序图之前,是一个joint consensus处于提交的状态。这个时段Leader挂了,再选出的Candidate只能是Cold,new配置,为什么?因为无论是配置old还是new对应的机器,都已经是半数以上都包含了Cold,new配置,那么根据Leader Completeness,无论是哪种配置要胜出,都只能是包含Cold,new配置的Candidate才可能,并且也只能是一个胜出。
从Leader开始复制Cnew配置日志时刻起,Leader只把Cnew发送到新配置对应的机器集合。到Cnew日志完成提交前,集群中server可能存在Cnew、Cold,new、Cold3种配置的机器。如果Leader挂了,会不会出现选出2个Candidate同时成为Leader的可能性?不会。首先,旧配置的机器集合半数以上已经包含Cold,new配置,所以Cold配置的Candidate不可能赢得选举。赢得选举的Candidate可能是Cnew,Cold,new中一种,无论是哪种,都必须获得配置new对应机器集合大多数的同意票,保证了唯一胜出。
同样的,如果是Cold,new配置的Leader产生,那么这个Leader需要重新发送Cnew配置日志entry。
Cnew配置提交后,后续所有的Leader肯定也包含Cnew配置,这时不在新配置中的机器就彻底退出了集群。
配置升级有2个流程细节问题需要关注:
(1) 空白新机器
空白新机器新加入集群会有个追赶日志的时间(即把Leader中已提交日志写入本机并提交写入本机state machine)。这个时间可能会很长,以致于影响到添加新日志。因此Raft规定,在追赶日志完成之前,新机器不算入有投票权的majority范围内
(2) 移除旧机器
移除旧机器在Cnew提交后应该是要下线的,但是由于下线时间不确定,可能仍然会发起选举申请,扰乱集群的正常工作。因此选举启动条件引入一个条件限制:当集群中机器在minimum(最小)election time out内收到过Leader的RPC,那么它认为Leader还是存活的,拒绝新的选举请求。
5. 日志压缩(Log Compaction)
在Raft描述的集群中,每台机器都拥有所有的数据(日志+state machine),必然面临磁盘空间不足的问题,影响到系统的可用性。
为了解决这个问题,Raft提出了日志压缩机制,采用了快照技术(snapshot),即对:整个日志队列到最后一条提交日志为止和当前的state machine做一个快照,存储到其他stable storage中。然后本机只留下snapshot之后的日志(以及当前的state machine)。
为支持后续的复制日志RPC检查以及更新集群membership操作,snapshot同时必须存储快照最后一条日志的index和term,以及最后一条configuration日志内容。
Leader做了snapshot后,有些情况下需要把snapshot发送给Follower,比如刚加入集群的server以及日志落后太多的follower。这种RPC叫做InstallSnapshot RPC。详细的RPC格式如图6所示。
图 6 InstallSnapshot RPC消息格式
从消息格式的offset和done字段看出,Snapshot可能非常大,所以会拆成多次RPC完成。收到Leader整个snapshot后,Follower一般情况下会把本机的日志全删掉,并且全部应用snapshot中的state machine内容,把本机原有的state machine数据覆盖掉。虽然Raft描述了一种Follower的日志队列比snapshot长的可能情况,但是实际上如果有这种情况存在,Leader也不应该给该Follower发送InstallSnapshot RPC,应该是让Follower自身去做snapshot。
Raft尽可能让本机自身去执行snapshot,避免通过Leader发送snapshot到Follower,主要是出于网络带宽以及实现更简单的原因。
snapshot执行时间会比较长,为了不影响执行client的正常操作,Raft建议使用copy-on-write(写时复制)机制,Raft采用的是linux操作系统底层支持的技术。
6. 和client通讯
前面已经描述了client直接和Leader通讯,不通过中间Follower转发,链路更直接,通讯更快。
仍然有2个问题需要解决:
(1) 线性一致性
集群只能执行1次client的请求。
假如Leader在没有给client返回请求执行结果前crash了,那么新Leader选出来后,client会认为上一个请求执行超时而再次发送同一个请求。这时,如果保证系统不再重复执行请求?Raft给出的解决方案是给每次请求一个序列号(这个序列号每次递增+1),Leader在执行请求前,看属于该client的序列号是否已经在已提交日志中了(提交就是已经应用到state machine中)。如果已经提交,那就立即返回给client,不做任何处理。
这里有个点需要注意下,Leader中没有提交的日志,必然在Follower中也没有提交,这个点之所以单独强调下,是因为有时候会有这样的疑问:在Leader中执行的请求,在Follower中会不会是重复执行的请求?答案是不会,在提交日志的数量上,Follower最多和Leader持平,不会比Leader多。
(2) 只读请求返回旧数据
只读请求不操作日志,只是从Leader中拿到要读的数据返回给client。在Leader正常工作情况下,Leader上永远是最新的数据这没有问题。但是在Leader被新Leader代替,旧Leader还不知道,client也不知道的情况下,旧Leader可能返回旧数据(必然是有其他的client给新Leader发送了新的写请求)。
Raft为解决这个问题,设计了2个措施:
措施1:Leader的commit log index是集群中最新的
根据Leader Completeness Property, 选出来的Leader必然包含所有commit log,但它未必执行了提交这条日志 (因为commit log的时刻在拥有这个log的时刻之后)。所以Leader在它的任期开始,加一条空的日志entry,并复制给Follower。当提交这条空的日志时,就把从自己上次commit log index到这条空日志区间的所有日志都提交了。这样Leader的commit log index是集群中最新的。
措施2:
Leader在返回给client结果前,和集群中半数以上机器发送心跳信息
这样就能知道自己是否还是Leader。如果还是Leader,那么收到这条心跳的半数以上的机器也就不会在很短的时间内同意其他的Candidate成为新的Leader
到此,Raft的解读全部完毕!