与Paxos不同,Raft相对来说易于理解,在Raft中把复杂的问题分解,分为三个子问题:选举(Leader election)、日志复制(Log replication)、安全性(Safety)。
Raft的流程:
Raft开始时在集群中选举出Leader负责日志复制的管理,Leader接受来自客户端的事务请求(日志),并将它们复制给集群的其他节点,然后负责通知集群中其他节点提交日志,Leader负责保证其他节点与他的日志同步,当Leader宕掉后集群其他节点会发起选举选出新的Leader。
Raft把集群中的节点分为三种状态:Leader、 Follower 、Candidate,理所当然每种状态下负责的任务也是不一样的,Raft运行时只存在Leader与Follower两种状态。
Term
在Raft中使用了一个可以理解为周期/任期的概念,用Term作为一个周期,每个Term都是一个连续递增的编号,每一轮选举都是一个Term周期,在一个Term中只能产生一个Leader;先简单描述下Term的变化流程: Raft开始时所有Follower的Term为1,其中一个Follower逻辑时钟到期后转换为Candidate,Term加1这是Term为2,然后开始选举,这时候有几种情况会使Term发生改变:
每次Term的递增都将发生新一轮的选举,Raft保证一个Term只有一个Leader,在Raft正常运转中所有的节点的Term都是一致的,如果节点不发生故障一个Term(任期)会一直保持下去,当某节点收到的请求中Term比当前Term小时则拒绝该请求;
下面两种情况会发起选举
Raft初始状态时所有节点都处于Follower状态,并且随机睡眠一段时间,这个时间在0~1000ms之间。最先醒来的节点 进入Candidate状态,并且发起投票,即向其它所有节点发出requst_vote请求,同时投自己一票,这个过程会有三种结果:
自己被选成了主。当收到了大多数的投票后,状态切成leader,并且定期给其它的所有server发心跳消息(其实是不带log的AppendEntriesRPC)以告诉对方自己是current_term_id所标识的term的leader。每个term最多只有一个leader,term id作为logical clock,在每个RPC消息中都会带上,用于检测过期的消息,一个server收到的RPC消息中的rpc_term_id比本地的current_term_id更大时,就更新current_term_id为rpc_term_id,并且如果当前state为leader或者candidate时,将自己的状态切成follower。如果rpc_term_id比本地的current_term_id更小,则拒绝这个RPC消息。
别人成为了主。如1所述,当candidate在等待投票的过程中,收到了大于或者等于本地的current_term_id的声明对方是leader的AppendEntriesRPC时,则将自己的state切成follower,并且更新本地的current_term_id。
没有选出主。当投票被瓜分,没有任何一个candidate收到了majority的vote时,没有leader被选出。这种情况下,每个candidate等待的投票的过程就超时了,接着candidates都会将本地的current_term_id再加1,发起RequestVoteRPC进行新一轮的leader election。
投票策略:
一个任期内,一个节点只能投一张票,具体的是否同意和后续的Safety有关。
Leader选举出来后,就可以开始处理客户端请求。流程如下:
日志由有序编号的日志条目组成。每个日志条目包含它被创建时的任期号(每个方块中的数字),并且包含用于状态机执行的命令。任期号用来检测在不同服务器上日志的不一致性。
如上图所示,索引 1-7 的日志至少在其他两个节点上复制成功,就认为该日志是 commited 状态,而索引为8 的日志并未复制到多数节点,是 uncommitted 状态。
leader 对自己的日志不能覆盖和删除,只能进行 append 新日志的操作。
Raft 的日志机制来维护不同服务器的日志之间的高层次的一致性。有下面两条特性
第一个特性来自这样的一个事实,领导人最多在一个任期里在指定的一个日志索引位置创建一条日志条目,同时日志条目在日志中的位置也从来不会改变。
第二个特性由附加日志 RPC 的一个简单的一致性检查所保证。在发送附加日志 RPC 的时候,领导人会把新的日志条目紧接着之前的条目的索引位置和任期号包含在里面。如果跟随者在它的日志中找不到包含相同索引位置和任期号的条目,那么他就会拒绝接收新的日志条目。一致性检查就像一个归纳步骤:一开始空的日志状态肯定是满足日志匹配特性的,然后一致性检查保护了日志匹配特性当日志扩展的时候。因此,每当附加日志 RPC 返回成功时,领导人就知道跟随者的日志一定是和自己相同的了。
当一个新的leader选出来的时候,它的日志和其它的follower的日志可能不一样,这个时候,就需要一个机制来保证日志是一致的。如下图所示,一个新leader产生时,集群状态可能如下:
最上面这个是新leader,a~f是follower,都出现了日志不一致的情况。
在 Raft 算法中,领导人处理不一致是通过强制跟随者直接复制自己的日志来解决了。这意味着在跟随者中的冲突的日志条目会被领导人的日志覆盖。leader会为每个follower维护一个nextIndex,表示leader给各个follower发送的下一条log entry在log中的index,初始化为leader的最后一条log entry的下一个位置。leader给follower发送AppendEntriesRPC消息,带着{term_id, (nextIndex-1)}, follower接收到AppendEntriesRPC后,会从自己的log中找是不是存在这样的log entry,如果不存在,就给leader回复拒绝消息,然后leader则将nextIndex减1,再重复发送AppendEntriesRPC,直到AppendEntriesRPC消息被接收。
举个例子
以leader和b为例:初始化,nextIndex为11,leader给b发送AppendEntriesRPC(6,10),b在自己log的10号槽位中没有找到term_id为6的log entry。则给leader回应一个拒绝消息。接着,leader将nextIndex减一,变成10,然后给b发送AppendEntriesRPC(6, 9),b在自己log的9号槽位中同样没有找到term_id为6的log entry。循环下去,直到leader发送了AppendEntriesRPC(4,4),b在自己log的槽位4中找到了term_id为4的log entry。接收了消息。随后,leader就可以从槽位5开始给b推送日志了。
当附加日志 RPC 的请求被拒绝的时候,跟随者可以返回冲突的条目的任期号和自己存储的那个任期的最早的索引地址。借助这些信息,领导人可以减小 nextIndex 越过所有那个任期冲突的所有日志条目;这样就变成每个任期需要一次附加条目 RPC 而不是每个条目一次。
Raft中除了日志复制的原则,还有一些原则来保证一致性。主要有以下几点。
候选人日志至少跟大多数一样新,即拥有最新的已提交的log entry的Follower才有资格成为Leader。
这个保证是在RequestVoteRPC阶段做的,候选人在发送RequestVoteRPC时,会带上自己的最后一条log entry的term_id和index,server在接收到RequestVoteRPC消息时,如果发现自己的日志比RPC中的更新,就拒绝投票。日志比较的原则是,如果本地的最后一条log entry的term id越大越新,如果term id一样大,则日志更长的的越新。
Leader只能提交当前term的已经复制到大多数服务器上的日志,旧term日志的提交要等到提交当前term的日志来间接提交。
在阶( a ),term为2,S1是Leader,且S1写入日志(term, index)为(2, 2),并且日志被同步写入了S2;
在阶段( b ),S1离线,触发一次新的选主,此时S5被选为新的Leader,此时系统term为3,且写入了日志(term, index)为(3, 2);
在阶段( c), S5尚未将日志推送到Followers就离线了,进而触发了一次新的选主,而之前离线的S1经过重新上线后被选中变成Leader,此时系统term为4,此时S1会将自己的日志同步到Followers,按照上图就是将日志(2, 2)同步到了S3,而此时由于该日志已经被同步到了多数节点(S1, S2, S3),因此,此时日志(2,2)可以被提交了。
在阶段( d ),S1又下线了,触发一次选主,而S5有可能被选为新的Leader(这是因为S5可以满足作为主的一切条件:1. term = 5 > 4,2. 最新的日志为(3,2),比大多数节点(如S2/S3/S4的日志都新),然后S5会将自己的日志更新到Followers,于是S2、S3中已经被提交的日志(2,2)被覆盖了。
为了解决这个问题,增加以下限制,即使日志(2,2)已经被大多数节点(S1、S2、S3)确认了,但是它不能被提交,因为它是来自之前term(2)的日志,直到S1在当前term(4)产生的日志(4, 4)被大多数Followers确认,S1方可提交日志(4,4)这条日志,当然,根据Raft定义,(4,4)之前的所有日志也会被提交。此时即使S1再下线,重新选主时S5不可能成为Leader,因为它没有包含最新的日志(4,4)。
如果领导人或者候选人崩溃,一段时间以后,某个节点会出发超时,重新发起选举,一切就回复正常了。
如果一个追随者崩溃,会被 leader 感知。 leader 会一直重试,直到追随者恢复,并同步所有日志。
集群中的成员是通过配置(成员的集合)来感知其他成员,即在每个节点都会存储一份配置,新添加或者减少成员也可以看作新的配置覆盖旧的配置。raft让这一过程自动且安全的执行。下面看它是如何实现的。
在集群发生变化时,不能一次性的把所有的server配置信息从老的替换为新的,因为,每台server的替换进度是不一样的,可能会导致出现双主的情况,如下图:
Server 1和Server 2可能以Cold配置选出一个主,而Server 3,Server 4和Server 5可能以Cnew选出另外一个主,导致出现双主。
Raft使用两阶段的过程来完成上述转换,下面称就配置为C-old,新配置为C-new,他们的并集为C-old-new,leader向follower复制配置时,也是也log entry的方式方式。
当 leader 接收到配置从 C-old 切换到 C-new 的请求时,它先将 C-old-new存储并复制给大多数节点。
一旦某个节点将更新的配置存到它的日志中,其后所有决策都使用该配置(节点使用的配置总是最新的,不管是否已提交);即 leader 要用 C-old-new 的规则来决定何时提交 C-old-new 配置日志。如果 leader 崩溃,那新选举的 leader 的配置不是 C-old 就是 C-old-new。在这一阶段,C-new 的配置不会应用。
一旦 C-old-new 被提交,意思是C-old-new被大多数节点接受,假如此时lead掉线,新选举的lead的配置只能是C-old-new。随后leader 将 C-new 配置日志复制到集群中,当节点收到新的配置后即刻生效。当 C-new 日志被提交后,其他配置的也不会生效了。
补充:
新加入的server一开始没有存储任何的log entry,当它们加入到集群中,可能有很长一段时间在追加日志的过程中,导致配置变更的log entry一直无法提交。阶段新的server不作为选举的server,但是会从leader接受日志,当新加的server追上leader时,才开始做配置变更。
原来的主可能不在新的配置中,在这种场景下,原来的主在提交了C-new后,会变成follower状态。
移除的server不会收到新的leader的心跳,从而导致它们election timeout,然后重新开始选举,这会可能导致新的leader变成follower状态。Raft的解决方案是,当一台server接收到选举RPC时,如果此次接收到的时间跟leader发的心跳的时间间隔不超过最小的electionTimeout,则会拒绝掉此次选举。这个不会影响正常的选举过程,因为,每个server会在最小electionTimeout后发起选举,而可以避免老的server的干扰。
随着系统的持续运转,节点中的日志信息不断膨胀,影响节点恢复时状态重放的效率,同时也制约日志查找性能。因此,日志压缩是比不可少的功能。分布式系统中有多种压缩策略,Raft采用了快照(Snapshot)的形式进行日志压缩。当上层应用apply日志时,会侦测到节点日志的长度,如果太大向Raft发送日志压缩命令。Raft在本地执行日志压缩,将已经提交并且Apply的日志截断之前的状态(state machine)作成快照,保留快照后面的日志信息。
虽然每个server是独立地做快照的,但是也有可能存在需要leader向follower发送整个快照的情况,例如,一个follower的日志处于leader的最近一次快照之前,恰好leader做完快照之后把其快照中的log entry都删除了,这时,leader就无法通过发送log entry来同步了,只能通过发送完整快照。
leader通过InstallSnapshot RPC来完成发送快照的功能,follower收到此RPC后,根据不同情况会有不同的处理:
当follower中缺失快照中的日志时
follower会删除掉其上所有日志,并清空状态机
当follower中拥有快照中所有的日志时
follower会删掉快照所覆盖的log entry,但快照后所有日志都保留。备注:这里论文中没有提是否还是从leader接受快照,个人觉得follower可以自己做快照,并拒绝掉leader发快照的RPC请求
对于Raft快照,关于性能需要考虑的点有:
server何时做快照,太频繁地做快照会浪费磁盘I/O;太不频繁会导致server当掉后回放时间增加,可能的方案为当日志大小到一定空间时,开始快照。备注:如果所有server做快照的阈值空间都是一样的,那么快照点也不一定相同,因为,当server检测到日志超过大小,到其真正开始做快照中间还存在时间间隔,每个server的间隔可能不一样
写快照花费的时间很长,不能让其影响正常的操作。可以采用copy-on-write操作,例如linux的fork
关于和客户端交互的部分我将会放在Raft的实现部分去描述,至此Raft的所有内容基本说完,至于细节的问题,比如论文中提到的Raft各个RPC的格式等问题都会在实现不问讲述。接下来的文章将会讲述每个模块的实现。
项目地址:https://github.com/TheLudlows/four-raft
动画理解Raft
Raft论文中文版