接系列上一篇文章,这篇文章介绍Raft算法。
说算法之前,也先列举一些的具体例子,像mongodb使用的是raft算法的变种,另外etcd也是基于raft实现的。
相比前面的paxos算法,raft算法要简单很多,18页的算法论文中,仅第5章部分是描述算法过程的,篇幅大概两页多A4纸的样子。我们自己实现一个raft算法的门槛很低,以至于现在基于raft算法的分布式KV系统都烂大街了,github上能搜出各种语言版本的数十个项目出来。质量层次不齐,需要读者分辨一下。不过我觉得这是raft算法的优势之一,因为简单,更容易理解和传播。
和paxos算法类似,raft也分core-raft算法与multi-raft算法两种。区别在于paxos的核心算法只能确认一个值,而在工程实践中往往需要确定一系列值,所以才有了multi-paxos;而raft本身就能确定多个值,可以直接用于工程实践,multi-raft是为了解决性能问题才出现的。raft原论文只涉及core-raft算法部分,multi-raft是在工程实践中扩展出来的,并不属于原算法。这里的core-raft和multi-raft命名也只是这里为了区分才这么叫的,实际上并没有业内并没有统一权威的命名,随便在叫。在网络上如果仅用关键字“raft”搜索的话,搜出来都指的是core-raft部分的内容。
一、core-raft
1.1 算法角色
算法正常运行时有2个角色,Leader和Follower;Leader宕机时会出现第3个角色,Candidate,它是Follower节点在一定时间内没有收到Leader心跳后,触发定时器转变的,以试图竞选下一届Leader。所以整个算法流程会有3个角色出现,他们之间相互转换关系我从原论文中截取一个图来说明:
扭转过程如下:
1.系统启动时,所有节点都是Follower节点;
2.当Follower节点在规定时间(选举超时时间,一般在3到4秒左右,为什么是“左右”而不是一个准确值,我们后面细说)没有收到Leader心跳,会触发节点本地的定时任务,变成 Candidate;
3.Candidate向其他节点发出选举RPC,如果选举成功则进化为Leader,如果一轮竞选没有节点胜出则继续保持Candidate角色并开启下一轮竞选,否则就退化为Follower;
4.Leader节点在工作中如果发现有其他节点谋朝篡位,则主动退化为Follower;
需要注意的是,raft算法中的角色虽然使用了Leader、Follower命名,但是并不同paxos一样是主从模式,而是一个典型的 主 备 模 式 \color{#FF0000}{主备模式} 主备模式。raft的Follower是不干活的,仅仅作为容灾设计,在Leader宕机时通过竞选顶上去;当Follower收到客户端请求后,它会重定向到Leader上去,这个大家在完整读完raft算法后就可以轻松理解。如果有朋友对主从模式和主备模式的区别不是特别清楚的话,可以回到系列文章的第一篇《分布式系统(一)》,能够看到几种常见架构模式的对比介绍。
1.2 算法组件
与paxos混沌模式不一样,raft搞的是分而治之的思想,它将算法分成了选举、日志复制以及安全性三大组件,其中安全性是为了保证日志复制过程的数据一致性的,所以我这里会将他们两个合在一块儿介绍。
1.3 核心流程
为了说清楚流程,我将论文中最重要的一个图表截了过来,它全面说明了算法运行中各机器需要维护的基础数据、发送的消息、以及处理消息的逻辑,这个图就是raft的全貌。
简单介绍如下:
1.State: 指需要在各个节点维护的元数据,包括当前的term、index等信息
2.AppendEntriesRPC:指Leader发给Follower的日志复制命令和心跳RPC
3.RequestVoteRPC: 指Candidate竞选时,发给其他节点的RPC
4.Rules for Servers: 指各节点在不同情况下的运行规则,比如收到选举RPC了怎么办等等
这里我不详细翻译上面每一个参数的含义,这个网上都有,当各位有兴趣具体实现一个raft算法时可以参考上面的参数,我这里只是计划按组件来介绍算法运行的主流程:
1.3.1选举过程
选举发生的条件:在一定时间内,没有收到leader的日志复制请求(或者心跳请求),即发生超时了。
选举逻辑:满足上述条件后,Follower变成Candidate,并向其他所有节点发送投票请求;收到投票请求的Follower,会检查发送请求的Candiate是否符合要求:Candidate的term要大于等于自己term,并且index要至少比自己新,用伪码描述下:
boolean voteForCandiate =
Candiate.term >= this.term && Candiate.index >= this.index;
满足这个条件即投票,不满足则不投票;如果Candidate(包括它自己的1票)得到半数以上节点同意的话,就可以变成Leader。
主流程就是这个样子,但是仅这个流程,可能还出现一些问题,这里逐个补充一下细节:
1.如果多个Follower同时变成Candidate,选举选不出来怎么办?
Follower变成Candidate后,它首先是把票投给自己的,Candidate只会投自己不会投其他Candidate,所以Candidate就需要争取其他Follower的票数,Follower的票数才是真正有效票。
选举的条件是超时,一旦发生超时,岂不对集群中所有的Follower都生效(我们这里先不考虑网络分区),岂不所有的Follower在同一时刻都变成了Candidate,那都是自己投自己,集群就选不出Leader了。
考虑到这个情况,算法中设计了一个随机值的计时器,首先系统有一个全局统一的日志(心跳)超时时间3000毫秒,然后各个节点再各自产生一个随机值,产生一个150~300之间的随机数R;然后各个节点的实际定时器的超时时间是全局超时时间+随机数R;这样各个节点的超时时间错开,可减少Follower同时变成Candidate的几率,以便尽快选出Leader来。
2.节点的term是什么?
raft有一个任期的概念,即每次开始一个新的选举都会任期加1,初始值为0。即Follower变成Candidate后,这个节点的term会自加1,然后它会将新生的term通过RequestVoteRpc的方式广播到集群所有节点;其它Follower收到RPC后,发现对象的term比自己大,就会投票给它(每个Follower在同一term中只能投一个节点的票),并用对方的term替换成自己的term;如果对方的对方的term比自己小,就投反对票,并将自己的term连带返回给Candidate;Candidate收到投票回复后,会检查投票结果并计数,同时也查看返回的term,如果对方的term大于等于自己term,说明存在另外一个Candidate也将投票请求发到刚刚那个Follower了并已取得投票,则用这个返回的term替换自身的term,并直接退化成Follower退出竞选,以促进集群尽早选出新的Leader。
3.节点的index是什么?
raft的每个节点都是需要存储数据的,这个index就是存储数据的下标,下标值越大意味着这个节点的数据越新。所以在竞选Leader时,当term相等,就需要比较index值,以选出数据最新的节点作为新的Leader。
4.选出新的Leader后,原来的Leader又恢复了,出现脑裂怎么办?
算法规定,当Leader节点收到其他节点消息发现对方Term比自己term大的时候,Leader应该主动退化为Follower。新的Leader的term一定比老的Leader的term大,因为新的Leader是经过Candidate阶段的,它的term会自加运算,所以脑裂问题可以迅速避免。
5.即使设置了随机日志超时时间,如果多个Follwer的超时时间接近,在复杂的网络环境下,剩余的Follower节点无法形成大多数,岂不选不出来Leader?
算法给选举也设置了超时时间,如果Candidate在规定时间内无法胜出,则随机回退,等待一个随机时间后,再将自加1后,开始下一轮选举。论文中的图是这个样子的:
6.多个Candidate竞选导致集群Leader切换频繁,集群稳定性出现问题?
因为采用了随机回退,会存在一种特殊情况,比如节点A被选举成了Leader,B节点落选,B节点把自己任期加1再次开始选举,成功成为Leader;节点A又在两次选举后,成为更大任期的Leader;节点B又在后续两次选举中成为更大任期的Leader,如此循环往复,以致集群Leader频繁变更,这个现象叫作活锁。这个和前面讲的paxos活锁的机理不太一样,paxos活锁是多个进程的PR请求与PM请求相互嵌套,以致相互取消对方请求无法确认一个值的现象。raft中是指选举过程中Leader频繁变动,无法对外提供稳定服务的现象。不过这个现象出现的可能非常小,只是理论上存在,在实际中,往往忽略不计。
1.3.2日志复制过程
一个Leader上任后第一件事就是马上向其他所有Follower节点发送一个日志复制请求,AppendEntriesRpc,以重置其他Follower的日志复制定时器,免得又有其他的Follwer节点定时器超时开始一轮新的选举。AppendEntriesRpc是日志复制时用的,如果刚上任的Leader节点没有收到客户端请求,也就没有日志可以同步了,这个时候它会同步一个空的AppendEntriesRpc给所有Follower,即没有数据RPC,这样的RPC也被用于心跳检测;Follower收到AppendEntriesRpc后,会检查请求的term、index以及节点ID,如果请求中的节点ID(即Leader的ID)和自己本地保存的Leader ID不一致时,会替换本地的Leader ID。
上面步骤还属于选举环节部分,只是涉及AppendEntriesRpc,所以我放到这里一并说明,下面是日志具体复制过程:
1.当Leader收到客户端请求后,会将这个Entry记录到日志中,这是追加方式的,每追加一个Entry, index加一,所以index是一个连续的整数;
2.Leader完成append操作后,会并向向所有Follower发起AppendEntriesRpc;
3.Follower收到请求后,将请求中的Entry追加到本地,并回复Leader成功;
4.Leader收到大多数成功回复后,这个Entry会被认为到达commited状态(对应维护一个commitedIndex),并将这个entry应用到状态机中(对应维护一个appliedIndex),同时回复客户端成功;
5.对于没有回复成功的Follower,Leader会不断重试,只到成功为止;
此时,Follower中的日志只是追加到了日志中,并没有commit和apply到状态机中。raft会在下面两个时机通知Follower这个Entry已经commited:
6.当Leader处理下一个客户端请求时,发送AppendEntriesRpc会带上prevLogIndex;Follower处理这个请求时,会将上一个Entry提交并应用到状态机;
7.当没有客户端请求时,Leader会以心跳的方式(也是一个AppendEntriesRpc)告知Follower。
这个过程的特点是,即使少数节点变慢或者网络出现问题,也不会影响整体效率。
按以往的风格,先给出正常流程,再说一下异常情况,刚好官网也提供了一个例子,我也贴在这里:
这个例子比较极端,情况有点复杂,因为官网没有给出例子的完整过程,这边简单还原一下可能的过程:
1.节点t是主节点,节点term=1,在term=1时写入3个Entry并复制到所有节点
2.节点t发生宕机,节点f成为主节点,然后立马发生网络分区,但是节点f写了3个Entry,这3个Entry并没有复制到其他节点
3.节点f重启,网络分区恢复,节点f又再次成为Leader,之后再次发生网络分区,节点f又写入4个Entry,同样没有复制到其他节点
4.节点a恢复,节点f宕机,节点e成为Leader,在index=4的位置写入一个Entry,并成功复制到所有节点
5.节点b宕机,节点e在index=5的位置又写了一个Entry,这个Entry被复制到了除节点b和f以外的所有节点
6.节点e发生网络分区,但是它仍然写了2个Entry,这两个Entry没有复制到其他节点
7.节点e发生宕机,节点a成为Leader,它写入2个Entry,他们被成功复制到所有存活的节点
8.节点a发生重启,节点c成为Leader,节点a重启后成为Follwer,节点c写入2个Entry,他们被成功复制到所有存活的节点
9.节点a宕机,节点c在index=10的位置写入一个Entry,这个Entry被复制到节点t和节点d
10.节点e发生网络分区,但它继续在index=11的位置写入一个Entry
11.节点e宕机,节点a和节点b恢复,节点d成为Leader,然后立即发生网络分区,但它仍然写入了两个Entry
12.节点d宕机,节点e和节点f恢复,节点t成为Leader
13.节点d和节点e恢复
经过多轮选举,最终集群中每个节点都和最终的Leader(节点a)的日志都不一致,存在以下三种情况:
1.比Leader少,比如节点a和节点b
2.比Leader多,比如节点c和节点d
3.比Leader多一部分,少一部分,比如节点e和节点f
这一段读下来需要相当的耐心,也可以直接跳过这个过程的阅读,不影响整个算法的理解,只需要知道上面那个主流程并不能保证数据一致性即可。
因为复制流程无法完全保证数据一致,所以算法有有了一致性检查的部分,就是安全性组件的职责。一致性检查有几个基本原则,原文是这么表述的:
1.对于一个给定的任期号,最多只会有一个Leader被选举出来(章节5.2)
2.Leader绝对不会删除或者覆盖自己的日志,只会增加(章节5.3)
3.如果两个日志在某一相同索引位置日志条目的任期号相同,那么我们就认为这两个日志从头到该索引位置之间的内容完全一致(章节5.3)
4.如果某个日志条目在某个任期号中已经被提交,那么这个条目必然出现在更大任期号的所有领导人中(章节5.4)
5.如果某一服务器已将给定索引位置的日志条目应用至其状态机中,则其他任何服务器在该索引位置不会应用不同的日志条目(章节5.4.3)
这个表述说的有些抽象,我这里尝试简单解释和扩展一下,我说的不一定都很准确,读者以原论文表述为主:
raft算法要求所有Follower与Leader保持强一致,换句话说,一致的数据保留,不一致的数据丢弃,替换成Leader的数据。只有Follower会主动丢弃数据,Leader永远不会主动丢弃数据。因为Leader是数据最新的节点(index值最大的节点才能获胜),故所有已提交(一半节点数以上)的Entry在Leader上面都有,强制Follower把缺少的数据补齐;对于Follower上已经提交的数据,Leader上一定存在;对于Follower上没有提交的数据,原则上可以直接遗弃(这里只是说“可以”,并不一定遗弃,如果和Leader上同index的数据冲突才会主动遗弃)。对于Leader上已提交的数据,毫无疑问保留;对于Leader上未提交的数据,通过AppendEntriesRpc的方式再逐步同步给所有Follower。具体同步过程如下:
1.Follower接受到AppendEntriesRpc请求后,会检查prevLogIndex和term,即当前Entry的前一个Entry,通过比较这个数值,来确认当前节点能否找到一样的数据,如果找不到则拒绝这个新的Entry请求;
2.如果Leader发现AppendEntriesRpc被拒绝以后,会尝试发送前面一个Entry给Follwer;如果还是拒绝,则再往前移动一位,只到成功为止。
3.如果Leader发现AppendEntriesRpc成功,则说明在此Entry之前的数据都是一致的,则开始从这个成功的Entry往后逐个再发送AppendEntriesRpc,直至全部数据达到一致。
4.在同步过程中,如果发现Follower上同index位置的数据与Leader不一致(只有可能是未提交的数据),则主动遗弃。
上面提到过,Leader上面未提交的数据,这是前任Leader同步过来尚未来得急提交的数据(在其他Follower上可能也会存在,只是Follower会在一致性检测中发现冲突时遗弃掉),新的Leader不会主动提交这些Entry,而是会追加新的Entry,在追加新的Entry达到提交状态时,会自动提交在此之前的所有未提交Entry。
至此,raft的核心算法说完了。至于要自己动手实现一个raft算法,还是需要参考原论文的细节部分,这个文章只是梳理了主流程。另外,完整的算法还有快照与节点变动的部分。
二、multi-raft
我们都知道,raft是一个强一致性算法,而强一致性算法不管怎么优化都是有性能上限的,不能再靠增加机器的方式来提高性能,因为raft算法的性能瓶颈在于Leader服务器的资源状况。
所以,就有了分库分表的思想,将raft算法的数据区分成多个分片,每一个分片对应一个raft集群,即raft group,因为有了多个raft group,所以就叫multi-raft。
这个想法很简单,但是会引入很多问题,比如元数据的管理、数据库合并与分裂、跨区数据处理等等,换句话说,分库分表中出现的问题都会原封不动的被带过来。但是现在已经有了好些实现multi-raft的中间件了,比如TiDB、CockroachDB等,跟分库分表一样的道理,方案花样有很多,具体细节有兴趣的朋友可以查看这些中间件的设计文档。
三、总结
这篇文章接上篇讲了raft算法核心流程,相对而言,这篇的内容有些单薄,因为raft算法确实是太容易理解了,大家一看就明白啥意思,没有太多可供夹带私货的环节。