背景

提到Raft不得不提到Paxos,Paxos 协议是由 Leslie Lamport在1989年提出的一种分布式一致性协议,它是分布式系统领域中的经典协议之一。Leslie Lamport 是分布式计算领域的著名科学家,曾获得图灵奖等多项荣誉。Paxos 协议被称为经典协议,它提供了一种在异步网络模型下实现分布式系统状态机复制的解决方案,而这种解决方案被认为是具有通用性的。Paxos协议是许多后来的分布式一致性算法的基础,例如 ZooKeeper 和 Raft 等。
但是Paxos协议难以理解并且很难实现,只有理论意义,没有实践意义。于是Diego Ongaro和John Ousterhout于2013年开发的一种基于领导者的共识算法,允许分布式系统中各节点在出现故障时可以针对一系列的数值达成一致,以可靠、复制、冗余、容错而闻名。即Raft算法,Raft在容错和性能上等同于Paxos,但是比Paxos简单易理解,并具有可以落地的实践意义(Paxos只有理论意义,难以实现)。

基础概念

领导者(Leader):客户端请求由leader处理,如果请求发送到follower,会被重定向到Leader,同时管理日志复制和不断地发送心跳信息。
跟随者(Follower):普通成员,处理领导者和候选人发来的消息,发现领导者心跳超时,推荐自己成为候选人。
候选人(Candidate):Follower竞选Leader的中间状态任期编号(Term):每任领导者都有任期编号。当领导者心跳超时,跟随者会变成候选人,任期编号 +1,然后发起投票。任期编号小的服从编号大的。
心跳超时(heartbeat timeout):每个跟随者节点都设置了随机心跳超时时间,目的是避免跟随者们同时成为候选人,同时发起投票。
选举超时(election timeout):Follower发起选举的超时时间,如果Follower在election timeout时间内没有收到Leader心跳,那么认为Leader fail,发起选举。每个Follower的选举超时时间是随机的,在(150ms-300ms)之间,随机选举超时时间可以避免多个候选人同时结束选举未果,然后同时发起下一轮选举

节点通信:

RequestVotes RPC:由候选节点发送,用于在选举期间向其他节点请求选票;
AppendEntries RPC:这个RPC由leader发起,携带最新收到的命令。它还用作心跳消息。当follower收到此消息时,选举计时器将被重置。

Raft将共识协议分成三个部分

领导者选举(Leader election): Raft 集群存在一个主节点(leader),客户端向集群发起的所有操作都必须经由主节点处理。主节点负责处理数据更新和复制的任务,因此没有leader,集群将无法工作。需要先进行领导者选举再进行下述操作;
日志复制(Log replication): Leader节点会负责接收客户端发过来的操作请求,将操作包装为日志条目并在集群中进行同步操作来确保其他节点的日志与自己的日志一致。在保证大部分节点都同步了本次操作后,就可以安全地给客户端回应响应了;
安全性(Saft) :分布式系统中有很多种情况可能发生,算法需要设置多项安全属性(限制)为系统的正确性以及可用性提供安全保证。具体有哪些关键的安全属性以及Raft如何确保,后面将详细介绍。

领导者选举

1、角色

Raft中的成员有三个身份:领导者(Leader)、跟随者(Follower)和候选者(Candidate)。
领导者(Leader):客户端请求由leader处理,如果请求发送到follower,会被重定向到Leader,同时管理日志复制和不断地发送心跳信息。
跟随者(Follower):普通成员,处理领导者和候选人发来的消息,发现领导者心跳超时,推荐自己成为候选人。
候选人(Candidate):Follower竞选Leader的中间状态

角色状态流转图:

2、任期

raft根据角色状态将时间划分为不同的任期,在一个任期内最多只有一个leader,每个任期由单调递增的数字(任期编号)标识。
每一轮选举任期编号都会+1,每个任期开始都是选举,有的任期只有选举阶段没有正常运行阶段,是因为选举失败没有Candidate当选为Leader。
任期如下图所示:

Raft 任期具有如下特点:

  • 跟随者在等待领导者心跳消息超时后,推举自己为候选人时,会增加自己的任期编号,比如节点 A 的当前任期编号为 0,那么在推举自己为候选人时,会将自己的任期编号增加为 1
  • 如果一个节点,发现自己的任期编号比其他节点小,那么它会更新自己的编号到较大的编号值。比如,节点 B 的任期编号为 0,当收到来自节点 A 的请求投票 RPC 消息,消息中包含节点的任期编号为 1,那么节点 B 将把自己的任期编号更新为 1
  • 如果一个候选人或者领导者,发现自己的任期编号比其他节点小,那么它会立即恢复成跟随者状态(可以参考上面的节点状态转换示意图)。比如网络分区错误,导致出现两个领导者,当分区错误恢复后,任期编号为 3 的领导者 B 接收到领导者 A 任期编号为 4 的心跳消息,那么节点 B 将立即恢复成跟随着状态,接受节点 A 为领导者
  • 如果一个节点接收到一个包含较小任期编号值的请求,那么它会直接拒绝这个请求。例如,节点 C 的任期编号为 4,接收到任期编号为 3 的 RPC 消息,那么节点 C 将拒绝这个消息

3、选举过程

3.1 选举规则

  • 领导者周期性地向跟随者发送心跳消息,告诉跟随者我是领导者,阻止跟随者发变成候选人发起新选举;
  • 如果跟随者在选举超时时间(150ms~300ms)内没有收到心跳,那么认为没有领导者了,推荐自己为候选人,发起新的选举;
  • 在一轮选举中,赢得一半以上选票的候选人,即成为新的领导者;
  • 在一轮选举中,每个节点对每个任期编号的选举只能投出一票,先来先服务原则;
  • 如果candidate或者Leader发现其任期号比其他节点小,则会立即恢复follower状态
  • 日志完整性高(也就是最后一条日志项对应的任期编号值更大,索引号更大)的跟随者A拒绝给完整性低的候选人B投票,即使B的任期编号大;

3.2、选举过程

3.2.1 发起选举,请求投票

Raft集群中的每个节点都有一个超时时间election timeout(选举超时),当Follower在选举超时时间内都没有收到Leader的心跳,Follower会把自己变成Candidate把term+1 发起一个新的选举任期。他首先投票给自己,然后向其他节点发起Request Vote消息,请求其他节点投票给自己。
RequestVote消息包含Candidate任期和日志索引。
如下图:初始Term=0, C节点将Term+1 并发起选举

3.2.2 投票

其他节点收到候选人请求投票的消息,重置自己的选举超时时间,满足三个条件就会给候选人投票

  • 1)他没有投过票
  • 2)任期不大于候选人任期
  • 3)日志索引小于候选人日志索引

3.2.3 当选

如果节点C获得大多数的选票,那么他就当选为本期Leader

3.2.4 发送心跳

节点C成为Leader后,会周期性的向其他节点发送心跳消息(即Append Entry消息),阻止其他节点发起选举

3.3 选举结果

raft集群的稳定状态中,leader会周期性的向follower会周期的收到leader的心跳包,如果follower在超时时间内收到心跳包,则保持follower状态。 如果超时没有收到leader的心跳包,则增加任期号,投自己一票,并向其他成员发起竞选投票,投票后会有三种最终结果:

  • 1)收到了大多数成员的投票,成为leader,并向其他成员发送leader心跳包;
  • 2)收到了其他成员作为leader的心跳包,且心跳包的任期大于等于当前任期,则退回collower状态。注:任期小于当前任期的心跳包直接忽略;
  • 3)超时未收到大多数成员的投票。此时等待一个随机的时间,然后增加任期号,重新发起新一轮的竞选。

    如何避免选举出多个Leader

    在一个任期内,一个follower最多只能为一个candidate投票,而candidate只有赢得了大多数选票才能竞选成功,所以不会选出多个leader

    竞选结果如何快速收敛

    如果碰巧两个Follower的选举超时时间相同,那么他们可能同时超时发起选举,并且可能两个Follower获得的票数一样多,选举失败。为了避免反复出现因为多个candidate竞争出现竞选失败的问题,在一个split vote出现后,每个candidate等待一个随机的时间(如150~300ms之间的任何一个值)后重新发起竞选,以减少竞选竞争的概率,最终快速收敛竞选结果。

    选举是否安全

    安全指的是总是返回正确的结果,选举安全指的是每个被选出来的leader中均保存了所有已经commit的日志。原因是follower收到的RequestVote请求中包含candidate的日志信息,如果follower发现自己的日志比candidate更新,就会投反对票。判断日志更新的办法为比较index和任期:如果任期更大,则LogEntry更新;如果任期相同,则index更大的LogEntry更新。即日志完整性更高优先选择

脑裂问题

脑裂问题(Brain Split),即任一任期Term内有超过一个Leader被选出(Raft要求在一个集群中任何时刻只能有一个Leader),这是非常严重的问题,会导致数据的覆盖丢失。
出现的场景,如下面的Raft集群,某个时刻出现网络分区,集群被隔开成两个网络分区,在不同的网络分区里会因为无法接收到原来的Leader发出的心跳而超时选主,这样就会造成多Leader现象。

在网络分区1和2中,出现了两个Leader:A和D,假设此时要更新分区2的值,因为分区2无法得到集群中的大多数节点的ACK,会导致复制失败。而网络分区1会成功,因为分区1中的节点更多,Leader A能得到大多数回应。
当网络恢复的时候,集群网络恢复,不再是双分区,Raft会有如下操作:

  • 任期安全性约束:Leader D 发现自己的Term小于Leader A,会自动下台(Step Down)成为Follower,Leader A保持不变依旧是集群中的主Leader角色(因为Leader A是重新选举出的,TermId至少会加1)
  • 分区中的所有节点会回滚自己的数据日志,并匹配新Leader的日志Log,然后实现同步提交更新自身的值。通知旧Leader 节点D也会主动匹配主Leader节点A的最新值,并加入到Follower中
  • 最终集群达到整体一致,集群存在唯一Leader(节点A)
    小结下,领导选举流程通过若干的投票原则,保证一次选举有且仅可能最多选出一个Leader,从而解决了脑裂问题

日志复制

1、复制状态机

复制状态机用于解决分布式系统中的各种容错问题,通常通过复制日志实现,每个服务器存储一个包含一系列命令的日志,其状态机按顺序执行日志中的命令。每个状态机处理日志的顺序相同,因此也能够得到相同的输出序列。

一致性算法的工作就是保证复制日志的一致性。每台服务器上的一致性模块接收来自客户端的命令,并将它们添加到其日志中。它与其他服务器上的一致性模块通信,以确保每个日志最终以相同的顺序包含相同的命令,即使有一些服务器失败。一旦命令被正确复制,每个服务器上的状态机按日志顺序处理它们,并将输出返回给客户端。这样就形成了高可用的复制状态机。

2、日志项(Log Entry)

日志项(Log Entry):是一种数据格式,它主要包含索引值(Log index)、任期编号(Term)和 指令(Command)。
索引值:它是用来标识日志项的,是一个连续的、单调递增的整数值。
任期编号:创建这条日志项的领导者的任期编号。
指令:一条由客户端请求指定的、服务需要执行的指令。

以leader的最后一个LogEntry为例,一个LogEntry包含三个信息:当前leader的任期(3);命令(x<-4),LogEntry的index( 8)。

3、日志复制过程

leader会将这个命令LogEntry添加到日志记录中,并且向其他成员发送AppendEntries请求来复制这条LogEntry。Follower收到AppendEntries请求后会将日志添加自己的记录中,并给Leader响应。
AppendEntries中除了携带LogEntries信息,还会携带当前已经执行(committed)的LogEntry的index,follower收到请求后,会同时执行index之前的LogEntry。

  • 如果follower节点宕机或者运行缓慢,再或者网络数据包丢失,leader会不断地重试AppendEntries RPC,直到follower节点最后存储了所有的日志条目。
  • 一旦Leader收到超过一半follower的确认,则表明该条目已被成功复制(比如上图中的log index 7)。Leader将该条目应用(apply)到其本地状态机(被视为committed)并将执行结果返回给客户端。此事件还会提交leader日志中之前存在的条目,包括前任leader创建的条目。
  • Leader会持续发送心跳包给 followers,心跳包中会携带当前已经安全复制(我们称之为committed)的日志索引,以便其他服务器知晓。一旦follower得知日志条目已提交,它就会将该条目应用到其本地状态机(按日志顺序)。

    Raft的日志机制(安全规则:日志匹配属性)确保了集群中所有服务器之间日志的高度一致性。日志匹配指的是说:

  • 如果不同日志中的两个条目拥有相同的term和index,则它们存储着相同的命令。原因就是因为raft要求leader在一个term内针对同一个index只能创建一条日志,并且永远不会修改,保证“持久化”;
  • 当发送AppendEntries RPC时,leader在其日志中会额外包含紧邻新条目之前的日志的index和term信息。如果followers在其日志中找不到具有相同索引和任期的日志条目,则它会拒绝新的日志条目。因此,如果不同日志中的两个条目如果拥有相同的index和term,则所有先前条目中的日志也都是相同的;
    AppendEntries会执行一致性检查保留上述属性。
    每当 AppendEntries 成功返回时,leader就可以知道follower的日志与自己的日志相同。正常运行时,leader和follower的日志保持一致,因此AppendEntries一致性检查不会失败,只会成功。
    但是,在leader崩溃的情况下,日志可能会不一致,前任leader可能没有完全复制其日志中的所有条目。这些不一致可能会引起一系列问题引起失败,比如下图所示的内容,followers的条目与leader不完全一致,要么多了,要么缺少。

为了避免上述情况的发生,Leader通过强制follower复制自己的日志来处理不一致的问题

  • 既然要保证follower的日志与其自己的一致,leader需要将其日志与follower日志进行比较,找到它们之间最后一次达到一致的条目,就像前面提到的日志匹配属性,因此这个条目如果一致,之前的日志也一定都是一致的。然后接下来删除follower日志中此关键条目之后的所有条目,并向follower发送该点之后自己的所有条目进行同步;
  • Leader会针对每个follower都维护一个nextindex,表示下一条需要发送给该follower的日志索引。在leader选举成功上任的时候,会将所有的nextIndex值初始化为其日志中最后一条日志的的日志索引+1;
  • 如果follower和leader的日志不一致,则下次AppendEntries RPC中的AppendEntries一致性检查将失败,Leader将递减nextIndex并重试AppendEntries RPC,直到nextIndex达到Leader和follower日志匹配的点为止;
    至此,AppendEntries成功,然后将删除follower日志中任何冲突的条目,并附加领导者日志中的条目(如果有)。
    这样的话,leader以及follower的日志就会保持一致直到term任期结束都会保持这种状态。

安全属性(Safety)

前面的部分主要描述了Raft的核心流程,也提及了个别机制比如说在给定任期内最多只能选举一名领导人。但是在分布式系统中有很多种情况可能发生,还需要更为详细的安全机制来确保每个状态机都可以以相同的顺序执行完全相同的命令。因此,我们需要针对“领导者选举”以及“日志复制”额外加上一些安全属性,来完善整个Raft算法。

选举限制

综上所述,Leader在整个Raft机制中真的充当着非常重要必不可少的角色,因此Leader的选举重中之重。

我们来试想一个场景:当leader提交了多个日志条目时,follower如果此时不可用,还没来得及复制这些日志,就被选举为新任leader了,然后这个新任leader呢,又用新的日志条目覆盖了其他节点上面上任leader committed的日志条目。那么就会导致多个不同的状态机可能执行不同的命令序列。

因此,核心问题还是在于leader选举出现了问题,对于哪些服务器有资格当选leader的限制对于Raft算法的完善十分重要,所以需要细化选举规则

  • 日志条目仅朝一个方向流动,leader永远不会覆盖其日志中的现有条目,也不会删除其日志中的条目,只能将新条目追加到其日志中(Leader Append-Only);
  • 在选举过程中,Candidate为了赢得选举,其日志中必须包含所有已提交的条目。为了当选,Candidate必须获得大多数服务器的投票才能当选新任leader,RequestVote RPC中包含有关Candidate日志的信息(term, index),如果其他服务器发现自己的日志比Candidate的日志新,那么将拒绝投票;

如何判定日志新旧?Raft通过比较日志中最后一个条目的index以及term来确定两个日志中哪一个更新。如果日志的最后一个条目有不同的term,那么更大的term对应的日志比较新。如果日志的term都相同,那么index大的日志更新。

Commit限制

Commit限制:通过计算副本数,仅提交leader当前term的日志条目。

为什么要增加这个限制?我们同样基于这个图进行场景模拟就知道了。

  • 阶段(a):S1是leader,收到请求后仅复制index2的日志给了S2,尚未复制给S3 ~ S5;
  • 阶段(b):S1崩溃,S5凭借 S3、S4 和自身的投票当选为term3的leader,收到请求后保存了与index2不同的条目(term3),此时尚未复制给其他节点;
  • 阶段(c):S5崩溃,S1重新启动,当选为新任leader(term4),并继续复制,将term2, index2复制给了 S3。这个时候term2,index2已经的日志条目已复制到大多数的服务器上,但是还没提交。
  • 阶段(d):如果S1如d阶段所示,又崩溃了,S5重新当选了leader(获得S2、S3、S4的选票)然后将 term3, index2的条目赋值给了所有的节点并commit。那这个时候,已经 committed 的 term2, index2被 term3, index2覆盖了。

因此,为了避免上述情况,commit需要增加一个额外的限制:仅commit leader当前term的日志条目。
如图,在c阶段,即使term4的时候S1已经把term2, index2复制给了大多数节点,但是它也不能直接将其commit,必须等待term4的日志并成功复制后一起commit。

所以除非说阶段c中term2, index2始终没有被 commit,这样S5在阶段d将其覆盖就是安全的,在要么就是像阶段e一样,term2, index2跟term4, index3一起被 commit,这样S5根本就无法当选leader,因为大多数节点的日志都比它新,也就不存在前边的问题了。

Follower 和 Candidate 崩溃

Follower和Candidate崩溃相对来说比Leader节点崩溃更好处理,如果Follower和Candidate出现了问题,那么也就意味着RequestVote和AppendEntries RPC将失败。Raft会无限期的重试,直到服务器重新启动,RPC将成功完成。如果很不凑巧,Follower和Candidate节点是在完成RPC之后但在响应之前崩溃,那么它将在重新启动后再次收到相同的 RPC。

超时期限和可用性

因为Raft启动选举是基于超时,使得超时期限的选择至为关键。若遵守算法的时限需求

            广播时间 << 超时期限 << 平均故障间隔

就能达到稳定性。这三个时间定义如下:

  • 广播时间: 是单一服务器发送消息给集群中每台服务器并得到回应的平均时间,需要测量得到。
  • 超时期限: 是发动选举的超时期限,由部署Raft集群的人选定。
  • 平均故障间隔: 是服务器发生故障之间的平均时间,可以测量或估计得到。

广播时间典型是 0.5ms 到 20ms,平均故障间隔通常是用周或月来计算的,所以可以将超时期限设在 10ms 到 500ms。

快照(Snapshot)

Raft接受的客户端命令都包装了日志处理, Raft维护了日志的一致性,通过apply日志我们维护了一致的状态机。但是大家有没有想过一个问题,随着客户端请求的增多,这些日志是不是会越来越长,占用越来越高的存储空间?而且,每次系统重启时都需要完整回放一遍所有日志才能得到最新的状态机,非常影响性能。并且机器存储空间有限,必须要一个日志清理机制。

Raft采用了最简单的日志压缩方法--快照(Snapshot)。就是将某一时刻系统的状态 dump 下来并落地存储,这样该时刻之前的所有日志条目就都可以丢弃了(也包括先前的快照)。每个服务器独立拍摄快照,仅覆盖committed完成的日志,因为只有committed日志才是确保最终会应用到状态机的。

上图展示了服务器用新快照替换了其日志中已提交的条目(index1-index5),新快照仅存储当前状态(变量x、y)。快照中显示的last included index以及的last included term用于定位日志位置以及支持AppendEntries一致性检查。
Follower可以保持最新状态的方法就是leader通过网络向其发送最新快照。比如,当follower落后的时候,leader需要向其同步日志,但是这个时候假设leader已经做了快照,旧的日志已经被删除,leader就可以使用InstallSnapshot RPC向落后的follower发送快照,其中将包含该follower未包含的新信息。同样,当集群中有新节点加入,或者某个节点宕机太久落后了太多日志时,leader也可以直接发送快照,节约了大量日志传输和回放时间。

参考文献

Raft协议
动画演示


杜若
70 声望3 粉丝

引用和评论

0 条评论