Raft:更加“实用主义”的分布式一致性算法

Raft的由来

系统从单机模式发展到集群、分布式模式,再到后来的微服务,不断的提高的应用的稳定性和可用性。在分布式系统中,通过副本的形式,多外提供可靠服务,集群中单独的节点故障,不会影响整体的可用性,这是相比于单机模式的一大优点。而在分布式系统中,有一个问题时无法避免的,不论是业务集群(你的业务最后还是依赖数据对外提供服务)还是数据集群,那就是分布式一致性状态问题。

什么是分布式一致性状态问题呢,简而言之,就是由多个节点组成的整体,执行相同序列的命令或存储相同序列的日志,最后每个节点都对应相同的输出序列,这样,整个系统对外就保持了状态一致性。

关于分布式一致性算法,大致有这么几种:2PC(二阶段提交)、3PC(三阶段提交)、Paxos(业界除了名的难懂)、zab(为zk专门设计的支持崩溃恢复和原子广播的协议、主备模式)、Raft(与Paxos相比,更偏重现实使用,易理解、主备模式)、NWR(Amazon云存储系统),Raft是今天的主角,想要了解分布式一致性算法的同学,尤其是想了解Paxos,但是看完之后感觉晦涩难懂、一头雾水的同学,不妨可以先看看Raft,Raft作为分布式一致性算法的入门是一个不错的选择。

Raft VS Paxos

这里把这两个拿来比较,也是为了突出这两个算法各自的优点,便于理解

性能上:Paxos经过“提出提案”、“选定提案”、“学习提案”三个rpc交互,至少2个RTT;Raft是主备模型,主要逻辑处理由Leader节点完成,Leader节点与Follower节点的交互主要是“分发日志”和“更新commit”,至少1个RTT。性能上Raft要优于Paxos。当然也有基于Paxos优化的Multi-Paxos(主从模式),将“提出提案”和“选定提案”合并为一个阶段的步骤,这里暂且不讨论。

理解和实现上:Paxos理论上很完美,但是现实实现并不容易,我们知道,一个算法的实现,在实际过程中往往需要考虑各种问题,Paxos的实现不是没有,但过于理想化,给实际实现上反而增添不少麻烦;Raft由Stanford背书,其本身出发点就是用于解决分布式一致性的实际问题,给出了明确的问题分解步骤及各阶段的不同情况的处理,理论并不复杂,实现上因为本身还是主从模式,实现不难。

是否强主:Paxos是真正的去Leader节点的分布式一致性算法,每个节点都真正成为系统中工作的一部分,而不仅仅是作为备份或者读节点用;Raft基于主从模式的出发点,尽可能的减少系统中各个状态的切换,将复杂的业务交给主节点执行,依靠Leader选主保证系统的可用性,是强主。

从以上方面可以看出,这两种算法是截然不同的,以我个人的理解,Paxos偏向于“理想主义”,而Raft则偏向于“显示主义”。

为可理解性而设计

Raft算法在讨论分布式一致性问题时,将问题分解成了:Leader选举、日志复制、安全性和成员变更。通过减少状态的数量来简化状态空间,使得系统更加连贯并且尽可能消除不确定性。特殊地,Raft要求日志复制时,所有的日志不允许有空洞,必须是连续的。

简而言之,Raft首先需要选举一个Leader,由Leader负责管理复制日志实现一致性

Leader从客户端接收日志条目,把日志条目复制到其它服务器上,并且保证安全性的时候通知其它服务器将日志条目应用到它们的状态机中

Leader可能宕机,也可能和其它服务器断开连接,这时一个新的leader会被选举出来

Leader选举

在Raft协议中,节点有三个角色

Leader:负责日志复制,成员变更管理,协调其它成员日志状态等主要工作

Follower:同步Leader的日志状态

Candidate:竞争Leader角色的使用权,Follower发现Leader心跳超时或宕机时转变角色为Candidate

图1

Raft把时间分割成任意长度的任期(term),如图2所示,任期使用连续的整数标记。每一段任期从一次选举开始,一个或多个Candidate尝试成为Leader。如果一个Candidate赢得选举,那么它将在该任期内一直充当Leader,直到它网络超时或者宕机,则集群再次进行选举操作选出Leader,新的Leader则在下一个新的任期内承担其职责。

一般来说,当某个Candidate受到大多数(假设集群数量S=2n+1,则需要n+1张选票)Follower的“选票”,就可以当选Leader,但是在某些情况下,一次选举无法选出Leader,即出现分裂选举情况(两个Candidate票数相同)在这种情况下,该任期会以没有Leader结束(如t3),一个新的任期(包含一次新的选举)会很快重新开始。Raft保证了任意一个任期内,至多有一个Leader,防止多个Leader出现造成集群状态不一致。

对于投票分裂的情况,Raft集群中的节点一般设置不同的超时时间(一般为50-200ms),当出现两个得票最多且相同的Candidate时,该任期结束,下个任期开始时,由于超时时间不同,任意两个节点同时发起投票的rpc的概率被降低,进而避免出现选举竞争的情况。

图2

在Raft算法中,服务器节点之间使用RPC进行通信,并且基本的一致性算法只需要两种类型的RPC,有请求投票选举的RPC(当Leader宕机或超时后,Follower转变为Candidate时使用);有追加日志条目RPC(由Leader发起,为正常进行一致性工作的RPC),用来复制日志和提供一种心跳机制。当服务器没有及时收到RPC响应时,会进行重试,并且服务器能够并行的发起RPC来获得最佳的性能。

下面举一个例子来说明Raft的Leader选举的过程:当服务器程序启动时,它们都是Follower。一个Follower只要能够从Leader或Candidate处接收到有效的RPC就一直保持Follower状态。由于刚启动,都是Follower,所以必然需要有一个或几个Follower等待心跳超时后,转变角色为Candidate进行Leader选举,由于Raft的对投票分裂的处理措施,每个服务器节点的心跳超时时间不同,所以不会出现在同一时刻或微小的时间段内,出现大量的Candidate的情况。

当Follower转变为Candidate前,增加自己的当前任期号并且转换到Candidate状态,然后投票给自己并且并行向集群中其它服务器节点发送RequestVote RPC(让其它服务器投票给它)。Candidate会一直保持当前状态直到如下三种情况之一发生:

      1.Candidate自己赢得了这次选举(收到超过集群一半数量投票)

      2.有其它服务器成为Leader,并向这个Candidate发送了心跳

      3.一段时间后没有任何获胜者(即当前任期的选举中,没有任何一个Candidate获得了过半数投票)

对于1情况,当一个Candidate获得集群中过半数投票时(Follower给其中一个Candidate投票之后,其它Candidate投票请求会被拒绝),它赢得这次选举成为Leader,然后向集群中其它节点发送心跳来确定自己地位,并让其它Candidate转变成Follower,不再进行选举动作,然后Leader进入工作状态,处理之前任期中异常或第二阶段未完成的日志。

对于2情况,在等待投票期间,Candidate可能会收到另一个声称自己是Leader的服务器节点发送来的AppendEntries RPC。如果这个Leader的任期号(包含在RPC中)不小于Candidate当前的任期号,那么Candidate会承认该Leader的合法地位并回到Follower状态,如果RPC中任期号比自己的小,那么Candidate就会拒绝这次RPC并且继续保持Candidate状态(这样做保证每次都是集群节点中记录最新的节点来充当Leader)。

对于3情况,即第三种可能的结果是Candidate既没有赢得选举也没有输,出现选票瓜分情况:如果有多个Follower同时成为Candidate,那么选票可能被瓜分以至于没有Candidate赢得过半的选票。当这种情况发生时,每一个候选人都会超时,然后通过增加当前任期号来开始一轮新的选举。然而,如果没有其它机制的话,该情况可能会无限重复。针对这种情况,Raft使用随机选举超时时间的方法来确保降低发生选票瓜分的情况,就算发生也能很快解决。比如为了阻止选票一开始就被瓜分,选举超时时间从一个固定的区间内随机选择(例如150-300ms),这样可以把服务器超时时间分散开使得大多数情况下只有一个服务器会选举超时,然后该服务器赢得选举并在其它服务器超时之前发送心跳。该机制也可用来解决选票被瓜分情况。每个Candidate在开始一次选举时会重置一个选举超时时间,然后一直等待直到选举超时,这样就减小了在新的选举中再次发生选票瓜分的可能性。

日志复制

Leader一旦选举出来,就开始为客户端提供服务。客户端的每一个请求都包含一条将被复制状态机执行的指令。Leader把该指令作为一个新的条目追加到日志中去,然后并行的发起AppendEntries RPC给其它服务器,让它们复制该条目。当该条目被安全的复制(下面会介绍),Leader会应用该条目到它的状态机中(状态机执行该指令)然后把执行的结果返回给客户端。如果Follower崩溃或者运行缓慢,或者网络丢包,Leader会不断重置AppendEntriesRPC(即使已经回复了客户端)直到所有的Follower最终都储存了所有的日志条目。

日志以下图的方式组织。每个日志条目存储一条状态机指令和Leader收到该指令时的任期号。任期号用来检测多个日志副本不一致的情况,每个日志条目都有一个整数索引值来表明它在日志中的位置。

在Raft中,由Leader决定什么时候把日志条目应用到状态机当中是安全的;这种日志条目被称为已提交的。Raft算法保证所有已提交的日志条目都是持久化的并且最终会被所有可用的状态机执行。一旦创建该日志条目的Leader将它复制到过半的服务器上,该日志条目就会被提交。同时,Leader日志中该日志条目之前的所有日志条目也都会被提交,包括由其它Leader创建的条目。下文会讨论在Leader变更之后应用改规则的一些细节,并且证明这种提交的规则是安全的。Leader追踪将会被提交的日志条目的最大索引,未来的所有AppendEntries RPC都会包含该索引,这样其它的服务器才能最终知道哪些日志条目需要被提交。Follower一旦知道某个日志条目已经被提交就会将该日志条目应用到自己的本地状态中(按照日志的顺序)。

Raft的日志机制维持了不同服务器之间日志高层次的一致性。这么做不仅简化了系统的行为也使得系统行为更加可预测,同时该机制也是保证安全性的重要组成部分。Raft维护者以下特性,这些同时也构成了上图中的日志匹配特性:

1.如果不同日志中的两个条目拥有相同的索引和任期号,那么它们存储了相同的指令。

2.如果不同日志中的两个条目拥有相同的索引和任期号,那么它们之前的所有日志条目也都相同(日志序列间没有间隙)。

 

Leader在特定的任期号内的一个日志索引处最多创建一个日志条目,同时日志条目在日志中的位置也从来不会改变。该点保证了上面的第一条特性。第二个特性是由AppendEntries RPC执行一个简单的一致性检查所保证的。在发送AppendEntries RPC的时候,Leader会将前一个日志条目的索引位置和任期号包含在里面。如果Follower在它的日志中找不到包含相同索引位置和任期号的条目,那么它就会拒绝该新的日志条目。一致性检查就像一个归纳步骤:一开始空的日志状态肯定是满足日志匹配特性的,然后一致性检查保证了日志扩展时的日志匹配特性。因此,每当AppendEntries RPC返回成功时,Leader就知道Follower的日志一定和自己相同(从第一个日志条目到最新条目)。

 

关于Leader崩溃后日志状态不一致的处理

正常操作期间,Leader和Follower的日志保持一致,所以AppendEntries RPC的一致性检查从来不会失败。然而,Leader崩溃的情况会使日志处于不一致的状态(老的Leader可能还没有完全复制它日志里的所有条目)。这种不一致会在一系列的Leader和Follower崩溃的情况下加剧。下图展示了在什么情况下Follower的日志可能和新的Leader的日志不同。Follower可能缺少一些在新Leader中有的日志条目,也可能拥有一些新Leader没有的日志条目,或者这两种情况同时发生。缺失或多出日志条目的情况可能会涉及到多个任期。

比如就上图而言,当一个Leader成功当选时(最上面那条日志),Follower可能是(a-f)中的任何情况。每一个盒子表示一个日志条目;里面的数字表示任期号。Follower可能会缺少一些日志条目(a-b),可能会有一些未被提交的日志条目(c-d),或者两种情况都存在(e-f)。例如,场景f可能这样发生,f对应的服务器在任期2的时候是Leader,追加了一些日志条目到自己的日志中,一条都还没提交(commit)就崩溃了;该服务器很快重启,在任期3重新被选为Leader,又追加了一些日志条目到自己的日志中;在这些任期2和任期3中的日志都还没被提交之前,该服务器又宕机了,并且在接下来的几个任期里一直处于宕机状态。

在Raft算法中,Leader通过强制Follower复制它的日志来解决不一致的问题。这意味着Follower中跟Leader冲突的日志条目会被Leader的日志条目覆盖。后文会证明通过增加一个限制可以保证安全性。

就上述情况下,要使得Follower的日志跟自己一致,Leader必须找到两者达成一致的最大的日志条目(索引最大),删除Follower日志中从哪个点之后的所有日志条目,并且将自己从那个点之后的所有日志条目发送给Follower。所有的这些操作都发生在对AppendEntries RPC中一致性检查的回复中。Leader针对每一个Follower都维护了一个nextIndex,表示Leader要发送给Follower的下一个日志条目的索引。当选出一个新Leader时,该Leader将所有nextIndex的值都初始化为自己最后一个日志条目的index加1.如果Follower的日志和Leader的不一致,那么下一次AppendEntries RPC中的一致性检查就会失败。在被Follower拒绝之后,Leader就会减小nextIndex值并重试AppendEntries RPC。最终nextIndex会在某个位置使得Leader和Follower的日志达成一致。此时,AppendEntries RPC就会成功,将Follower中跟Leader冲突的日志条目全部删除然后追加Leader中的日志条目(如果有需要追加的日志条目的话)。一旦AppendEntries RPC成功,Follower的日志就和Leader一致,并且在该任期接下来的时间里保持一致。

当然,这一部分的逻辑其实是可以优化的,比如在Leader定位于Follower拥有相同日志条目中最大日志条目的时候,如果AppendEntries RPC失败,那么Follower可以带上自己冲突条目的任期号和自己存储的那个任期的第一个index。这样,Leader可以跳过那个任期内所有冲突的日志条目来减小nextIndex;这样就变成了每个有冲突的日志条目的任期需要一个AppendEntries RPC而不是每个条目一次。当然,根据作者的论文阐述,这种优化可以不是必要的,因为失败不经常发生并且也不可能有很多不一致的日志条目。

通过这种机制,Leader在当权之后就不需要任何特殊的操作来使日志恢复到一致性状态。Leader只需要进行正常的操作,然后日志就能回复AppendEntries RPC一致性检查失败的时候自动趋于一致。Leader从来不会覆盖或者删除自己的日志条目。这样的日志复制机制展示了上文中描述的一致性特性:只要过半的服务器能正常运行,Raft就能够接受,复制并应用新的日志条目;在正常情况下,新的日志条目可以在一个RPC来回中被复制给集群中的过半机器;并且单个运行慢的Follower不会影响整体的性能。

安全性

前面的章节里描述了 Raft 算法是如何进行 leader 选举和日志复制的。然而,到目前为止描述的机制并不能充分地保证每一个状态机会按照相同的顺序执行相同的指令。例如,一个 follower 可能会进入不可用状态,在此期间,leader 可能提交了若干的日志条目,然后这个 follower 可能会被选举为 leader 并且用新的日志条目覆盖这些日志条目;结果,不同的状态机可能会执行不同的指令序列。

这节通过对 leader 选举增加一个限制来完善 Raft 算法。这一限制保证了对于给定的任意任期号, leader 都包含了之前各个任期所有被提交的日志条目。有了这一 leader 选举的限制,我们也使得提交规则更加清晰。最后,我们展示了对于 Leader Completeness 性质的简要证明并且说明该性质是如何领导复制状态机执行正确的行为的。

选举限制

在任何基于 leader 的一致性算法中,leader 最终都必须存储所有已经提交的日志条目。在某些一致性算法中,例如 Viewstamped Replication[22],一开始并没有包含所有已经提交的日志条目的服务器也可能被选为 leader 。这种算法包含一些额外的机制来识别丢失的日志条目并将它们传送给新的 leader ,要么是在选举阶段要么在之后很快进行。不幸的是,这种方法会导致相当大的额外的机制和复杂性。Raft 使用了一种更加简单的方法,它可以保证新 leader 在当选时就包含了之前所有任期号中已经提交的日志条目,不需要再传送这些日志条目给新 leader 。这意味着日志条目的传送是单向的,只从 leader 到 follower,并且 leader 从不会覆盖本地日志中已经存在的条目。

Raft 使用投票的方式来阻止 candidate 赢得选举除非该 candidate 包含了所有已经提交的日志条目。候选人为了赢得选举必须与集群中的过半节点通信,这意味着至少其中一个服务器节点包含了所有已提交的日志条目。如果 candidate 的日志至少和过半的服务器节点一样新(接下来会精确地定义“新”),那么他一定包含了所有已经提交的日志条目。RequestVote RPC 执行了这样的限制: RPC 中包含了 candidate 的日志信息,如果投票者自己的日志比 candidate 的还新,它会拒绝掉该投票请求。

Raft 通过比较两份日志中最后一条日志条目的索引值和任期号来定义谁的日志比较新。如果两份日志最后条目的任期号不同,那么任期号大的日志更新。如果两份日志最后条目的任期号相同,那么日志较长的那个更新。

提交之前任期内的日志条目

如同日志复制章节中描述的那样,一旦当前任期内的某个日志条目已经存储到过半的服务器节点上,leader 就知道该日志条目已经被提交了。如果某个 leader 在提交某个日志条目之前崩溃了,以后的 leader 会试图完成该日志条目的复制。然而,如果是之前任期内的某个日志条目已经存储到过半的服务器节点上,leader 也无法立即断定该日志条目已经被提交了。图 8 展示了一种情况,一个已经被存储到过半节点上的老日志条目,仍然有可能会被未来的 leader 覆盖掉。

如图的时间序列展示了为什么 leader 无法判断老的任期号内的日志是否已经被提交。在 (a) 中,S1 是 leader ,部分地复制了索引位置 2 的日志条目。在 (b) 中,S1 崩溃了,然后 S5 在任期 3 中通过 S3、S4 和自己的选票赢得选举,然后从客户端接收了一条不一样的日志条目放在了索引 2 处。然后到 (c),S5 又崩溃了;S1 重新启动,选举成功,继续复制日志。此时,来自任期 2 的那条日志已经被复制到了集群中的大多数机器上,但是还没有被提交。如果 S1 在 (d) 中又崩溃了,S5 可以重新被选举成功(通过来自 S2,S3 和 S4 的选票),然后覆盖了他们在索引 2 处的日志。但是,在崩溃之前,如果 S1 在自己的任期里复制了日志条目到大多数机器上,如 (e) 中,然后这个条目就会被提交(S5 就不可能选举成功)。 在这种情况下,之前的所有日志也被提交了。

为了消除上图中描述的问题,Raft 永远不会通过计算副本数目的方式来提交之前任期内的日志条目。只有 leader 当前任期内的日志条目才通过计算副本数目的方式来提交;一旦当前任期的某个日志条目以这种方式被提交,那么由于日志匹配特性,之前的所有日志条目也都会被间接地提交。在某些情况下,领导人可以安全地断定一个老的日志条目已经被提交(例如,如果该条目已经存储到所有服务器上),但是 Raft 为了简化问题使用了一种更加保守的方法。

Raft 会在提交规则上增加额外的复杂性是因为当 leader 复制之前任期内的日志条目时,这些日志条目都保留原来的任期号。在其他的一致性算法中,如果一个新的 leader 要重新复制之前的任期里的日志时,它必须使用当前新的任期号。Raft 的做法使得更加容易推导出(reason about)日志条目,因为他们自始至终都使用同一个任期号。另外,和其他的算法相比,Raft 中的新 leader 只需要发送更少的日志条目(其他算法中必须在它们被提交之前发送更多的冗余日志条目来给它们重新编号)。

安全性论证

在给出了完整的 Raft 算法之后,我们现在可以更加精确的讨论 leader 完整性特性(Leader Completeness Prop-erty)(这一讨论基于 9.2 节的安全性证明)。我们假设 leader 完整性特性是不满足的,然后我们推出矛盾来。假设任期 T 的 leader(leader T)在任期内提交了一个日志条目,但是该日志条目没有被存储到未来某些任期的 leader 中。假设 U 是大于 T 的没有存储该日志条目的最小任期号。

如果 S1 (任期 T 的 leader)在它的任期里提交了一个新的日志条目,然后 S5 在之后的任期 U 里被选举为 leader ,那么肯定至少会有一个节点,如 S3,既接收了来自 S1 的日志条目,也给 S5 投票了。

在给出论文中的证明之前,我们先试着自行试着用假设论证法来证明一下:

图中关键的一点就在于S3,S3既追加了任期T时期来自S1的日志条目AE,又确认了任期U时期S5的Leader身份,并追加了S5的日志条目RV,这里我们可以让T=1,U=2,即任期1和任期2,之所以这样假设是因为,即使任期T和任期U不是连续的两个任期(即中间有几个间隔任期),S3先后追加了日志条目AE和RV这个情况是确认的,只是说T和U不连续的情况下,S3的AE和RV之间可能会追加几条日志条目(注意,这里只是说追加,但是没有说提交),所以为了论证方便,直接认为是连续的任期1和任期2。

首先S1在任期1中当选Leader,并且S3追加S1的日志条目AE,这个过程是没有问题的,关键在于也追加了日志RV,既然追加日志RV说明S3承认S5在任期2的Leader身份,而要承认这个身份,S5就必须要比S3新,即任期新,或者任期相等的情况下,已提交的日志条目足够新,显然将S3和S5比较来看,S5任期够新,但是已提交的日志没有S3新(因为S5没有AE这个日志条目),这违反了Raft的选举限制,这是矛盾,即这种情况不会存在,也就说明,新任期的Leader一定会从有最新的已提交的日志条目的节点中产生。

  1. U 一定在刚成为 leader 的时候就没有那条被提交的日志条目了(leader 从不会删除或者覆盖任何条目)。

  2. Leader T 复制该日志条目给集群中的过半节点,同时,leader U 从集群中的过半节点赢得了选票。因此,至少有一个节点(投票者)同时接受了来自 leader T 的日志条目和给 leader U 投票了,如图 9。该投票者是产生矛盾的关键。

  3. 该投票者必须在给 leader U 投票之前先接受了从 leader T 发来的已经被提交的日志条目;否则它就会拒绝来自 leader T 的 AppendEntries 请求(因为此时它的任期号会比 T 大)。

  4. 该投票者在给 leader U 投票时依然保有这该日志条目,因为任何 U 、T 之间的 leader 都包含该日志条目(根据上述的假设),leader 从不会删除条目,并且 follower 只有跟 leader 冲突的时候才会删除条目。

  5. 该投票者把自己选票投给 leader U 时,leader U 的日志必须至少和投票者的一样新。这就导致了以下两个矛盾之一。

  6. 首先,如果该投票者和 leader U 的最后一个日志条目的任期号相同,那么 leader U 的日志至少和该投票者的一样长,所以 leader U 的日志一定包含该投票者日志中的所有日志条目。这是一个矛盾,因为该投票者包含了该已被提交的日志条目,但是在上述的假设里,leader U 是不包含的。

  7. 否则,leader U 的最后一个日志条目的任期号就必须比该投票者的大了。此外,该任期号也比 T 大,因为该投票者的最后一个日志条目的任期号至少和 T 一样大(它包含了来自任期 T 的已提交的日志)。创建了 leader U 最后一个日志条目的之前的 leader 一定已经包含了该已被提交的日志条目(根据上述假设,leader U 是第一个不包含该日志条目的 leader)。所以,根据日志匹配特性,leader U 一定也包含该已被提交的日志条目,这里产生了矛盾。

  8. 因此,所有比 T 大的任期的 leader 一定都包含了任期 T 中提交的所有日志条目。

  9. 日志匹配特性保证了未来的 leader 也会包含被间接提交的日志条目。

如果某个服务器已经将某个给定的索引处的日志条目应用到自己的状态机里了,那么其他的服务器就不会在相同的索引处应用一个不同的日志条目。在一个服务器应用一个日志条目到自己的状态机中时,它的日志和 leader 的日志从开始到该日志条目都相同,并且该日志条目必须被提交。现在考虑如下最小任期号:某服务器在该任期号中某个特定的索引处应用了一个日志条目;日志完整性特性保证拥有更高任期号的 leader 会存储相同的日志条目,所以之后任期里服务器应用该索引处的日志条目也会是相同的值。因此,状态机安全特性是成立的。最后,Raft 要求服务器按照日志索引顺序应用日志条目。再加上状态机安全特性,这就意味着所有的服务器都会按照相同的顺序应用相同的日志条目到自己的状态机中。

Follower 和 candidate 崩溃

到目前为止,我们只关注了 leader 崩溃的情况。Follower 和 candidate 崩溃后的处理方式比 leader 崩溃要简单的多,并且两者的处理方式是相同的。如果 follower 或者 candidate 崩溃了,那么后续发送给他们的 RequestVote 和 AppendEntries RPCs 都会失败。Raft 通过无限的重试来处理这种失败;如果崩溃的机器重启了,那么这些 RPC 就会成功地完成。如果一个服务器在完成了一个 RPC,但是还没有响应的时候崩溃了,那么在它重启之后就会再次收到同样的请求。Raft 的 RPCs 都是幂等的,所以这样的重试不会造成任何伤害。例如,一个 follower 如果收到 AppendEntries 请求但是它的日志中已经包含了这些日志条目,它就会直接忽略这个新的请求中的这些日志条目。

定时(timing)和可用性

Raft 的要求之一就是安全性不能依赖定时:整个系统不能因为某些事件运行得比预期快一点或者慢一点就产生错误的结果。但是,可用性(系统能够及时响应客户端)不可避免的要依赖于定时。例如,当有服务器崩溃时,消息交换的时间就会比正常情况下长,candidate 将不会等待太长的时间来赢得选举;没有一个稳定的 leader ,Raft 将无法工作。

Leader 选举是 Raft 中定时最为关键的方面。 只要整个系统满足下面的时间要求,Raft 就可以选举出并维持一个稳定的 leader:

广播时间(broadcastTime) << 选举超时时间(electionTimeout) << 平均故障间隔时间(MTBF)

在这个不等式中,广播时间指的是一个服务器并行地发送 RPCs 给集群中所有的其他服务器并接收到响应的平均时间;选举超时时间就是在集群选主章节中介绍的选举超时时间;平均故障间隔时间就是对于一台服务器而言,两次故障间隔时间的平均值。广播时间必须比选举超时时间小一个量级,这样 leader 才能够可靠地发送心跳消息来阻止 follower 开始进入选举状态;再加上随机化选举超时时间的方法,这个不等式也使得选票瓜分的情况变得不可能。选举超时时间需要比平均故障间隔时间小上几个数量级,这样整个系统才能稳定地运行。当 leader 崩溃后,整个系统会有大约选举超时时间不可用;我们希望该情况在整个时间里只占一小部分。

广播时间和平均故障间隔时间是由系统决定的,但是选举超时时间是我们自己选择的。Raft 的 RPCs 需要接收方将信息持久化地保存到稳定存储中去,所以广播时间大约是 0.5 毫秒到 20 毫秒之间,取决于存储的技术。因此,选举超时时间可能需要在 10 毫秒到 500 毫秒之间。大多数的服务器的平均故障间隔时间都在几个月甚至更长,很容易满足时间的要求。

集群成员变更

到目前为止,我们都假设集群的配置(参与一致性算法的服务器集合)是固定不变的。但是在实践中,偶尔会改变集群的配置的,例如替换那些宕机的机器或者改变复制程度。尽管可以通过使整个集群下线,更新所有配置,然后重启整个集群的方式来实现,但是在更改期间集群会不可用。另外,如果存在手工操作步骤,那么就会有操作失误的风险。为了避免这样的问题,我们决定将配置变更自动化并将其纳入到 Raft 一致性算法中来。

为了使配置变更机制能够安全,在转换的过程中不能够存在任何时间点使得同一个任期里可能选出两个 leader 。不幸的是,任何服务器直接从旧的配置转换到新的配置的方案都是不安全的。一次性自动地转换所有服务器是不可能的,所以在转换期间整个集群可能划分成两个独立的大多数

直接从一种配置转到另一种配置是不安全的,因为各个机器会在不同的时候进行转换。在这个例子中,集群从 3 台机器变成了 5 台。不幸的是,存在这样的一个时间点,同一个任期里两个不同的 leader 会被选出。一个获得旧配置里过半机器的投票,一个获得新配置里过半机器的投票。

为了保证安全性,配置变更必须采用一种两阶段方法。目前有很多种两阶段的实现。例如,有些系统在第一阶段停掉旧的配置所以不能处理客户端请求;然后在第二阶段在启用新的配置。在 Raft 中,集群先切换到一个过渡的配置,我们称之为联合一致(joint consensus);一旦联合一致已经被提交了,那么系统就切换到新的配置上。联合一致结合了老配置和新配置:

  • 日志条目被复制给集群中新、老配置的所有服务器。

  • 新、旧配置的服务器都可以成为 leader 。

  • 达成一致(针对选举和提交)需要分别在两种配置上获得过半的支持。

联合一致允许独立的服务器在不妥协安全性的前提下,在不同的时刻进行配置转换过程。此外,联合一致允许集群在配置变更期间依然响应客户端请求

配置更改的时间线。虚线显示已创建但未提交的配置项,实线显示最新提交的配置项。leader首先制造一条日志,在其日志中添加新旧共存的配置项,并将其提交到新旧共存状态下(大部分旧有配置机器和大部分新配置机器)的机器。然后它创建一个新配置项的日志条目,并将其提交给大多数Cnew。在任何时间点上,Cold和Chew都不能同时独立做出决定。

集群配置在复制日志中以特殊的日志条目来存储和通信;图 11 展示了配置变更过程。当一个 leader 接收到一个改变配置从 C-old 到 C-new 的请求,它就为联合一致将该配置(图中的 C-old,new)存储为一个日志条目,并以前面描述的方式复制该条目。一旦某个服务器将该新配置日志条目增加到自己的日志中,它就会用该配置来做出未来所有的决策(服务器总是使用它日志中最新的配置,无论该配置日志是否已经被提交)。这就意味着 leader 会使用 C-old,new 的规则来决定 C-old,new 的日志条目是什么时候被提交的。如果 leader 崩溃了,新 leader 可能是在 C-old 配置也可能是在 C-old,new 配置下选出来的,这取决于赢得选举的 candidate 是否已经接收到了 C-old,new 配置。在任何情况下, C-new 在这一时期都不能做出单方面决定。

此时若C-old中某节点成为leader,原有C-old正常工作,相当于C-new不存在

一旦 C-old,new 被提交,那么 C-old 和 C-new 都不能在没有得到对方认可的情况下做出决定,并且 leader 完整性特性保证了只有拥有 C-old,new 日志条目的服务器才能被选举为 leader 。现在 leader 创建一个描述 C-new 配置的日志条目并复制到集群其他节点就是安全的了。此外,新的配置被服务器收到后就会立即生效。当新的配置在 C-new 的规则下被提交,旧的配置就变得无关紧要,同时不使用新配置的服务器就可以被关闭了。如图 11 所示,任何时刻 C-old 和 C-new 都不能单方面做出决定;这保证了安全性。

在关于配置变更还有三个问题需要解决。第一个问题是,新的服务器开始时可能没有存储任何的日志条目。当这些服务器以这种状态加入到集群中,它们需要一段时间来更新来赶上其他服务器,这段它们无法提交新的日志条目。为了避免因此而造成的系统短时间的不可用,Raft 在配置变更前引入了一个额外的阶段,在该阶段,新的服务器以没有投票权身份加入到集群中来(leader 也复制日志给它们,但是考虑过半的时候不用考虑它们)。一旦该新的服务器追赶上了集群中的其他机器,配置变更就可以按上面描述的方式进行。

第二个问题是,集群的 leader 可能不是新配置中的一员。在这种情况下,leader 一旦提交了 C-new 日志条目就会退位(回到 follower 状态)。这意味着有这样的一段时间(leader 提交 C-new 期间),leader 管理着一个不包括自己的集群;它复制着日志但不把自己算在过半里面。Leader 转换发生在 C-new 被提交的时候,因为这是新配置可以独立运转的最早时刻(将总是能够在 C-new 配置下选出新的领导人)。在此之前,可能只能从 C-old 中选出领导人。

第三个问题是,那些被移除的服务器(不在 C-new 中)可能会扰乱集群。这些服务器将不会再接收到心跳,所以当选举超时,它们就会进行新的选举过程。它们会发送带有新任期号的 RequestVote RPCs ,这样会导致当前的 leader 回到 follower 状态。新的 leader 最终会被选出来,但是被移除的服务器将会再次超时,然后这个过程会再次重复,导致系统可用性很差。

为了防止这种问题,当服务器认为当前 leader 存在时,服务器会忽略RequestVote RPCs 。特别的,当服务器在最小选举超时时间内收到一个 RequestVote RPC,它不会更新任期号或者投票。这不会影响正常的选举,每个服务器在开始一次选举之前,至少等待最小选举超时时间。相反,这有利于避免被移除的服务器的扰乱:如果 leader 能够发送心跳给集群,那它就不会被更大的任期号废黜。

日志压缩

Raft 的日志在正常操作中随着包含更多的客户端请求不断地增长,但是在实际的系统中,日志不能无限制地增长。随着日志越来越长,它会占用越来越多的空间,并且需要花更多的时间来回放。如果没有一定的机制来清除日志中积累的过期的信息,最终就会带来可用性问题。

快照技术是日志压缩最简单的方法。在快照技术中,整个当前系统的状态都以快照的形式持久化到稳定的存储中,该时间点之前的日志全部丢弃。快照技术被使用在 Chubby 和 ZooKeeper 中,接下来的章节会介绍 Raft 中的快照技术。

增量压缩方法,例如日志清理或者日志结构合并树(log-structured merge trees,LSM 树),都是可行的。这些方法每次只对一小部分数据进行操作,这样就分散了压缩的负载压力。首先,它们先选择一个积累了大量已经被删除或者被覆盖的对象的数据区域,然后重写该区域还活着的对象,之后释放该区域。和快照技术相比,它们需要大量额外的机制和复杂性,快照技术通过操作整个数据集来简化该问题。状态机可以用和快照技术相同的接口来实现 LSM 树,但是日志清除方法就需要修改 Raft 了。

一台服务器用一个新快照替代了它日志中已经提交了的条目(索引 1 到 5),该快照只存储了当前的状态(变量 x 和 y 的值)。快照的 last included index 和 last included term 被保存来定位日志中条目 6 之前的快照

上图展示了 Raft 中快照的基本思想。每个服务器独立地创建快照,快照只包括自己日志中已经被提交的条目。主要的工作是状态机将自己的状态写入快照中。Raft 快照中也包含了少量的元数据:the last included index 指的是最后一个被快照取代的日志条目的索引值(状态机最后应用的日志条目),the last included term 是该条目的任期号。保留这些元数据是为了支持快照后第一个条目的 AppendEntries 一致性检查,因为该条目需要之前的索引值和任期号。为了支持集群成员变更(第 6 节),快照中也包括日志中最新的配置作为 last included index 。一旦服务器完成写快照,他就可以删除 last included index 之前的所有日志条目,包括之前的快照。

尽管通常服务器都是独立地创建快照,但是 leader 必须偶尔发送快照给一些落后的跟随者。这通常发生在 leader 已经丢弃了需要发送给 follower 的下一条日志条目的时候。幸运的是这种情况在常规操作中是不可能的:一个与 leader 保持同步的 follower 通常都会有该日志条目。然而一个例外的运行缓慢的 follower 或者新加入集群的服务器(第 6 节)将不会有这个条目。这时让该 follower 更新到最新的状态的方式就是通过网络把快照发送给它。

Leader 使用 InstallSnapshot RPC 来发送快照给太落后的 follower ;见图 13。当 follower 收到带有这种 RPC 的快照时,它必须决定如何处理已经存在的日志条目。通常该快照会包含接收者日志中没有的信息。在这种情况下,follower 丢弃它所有的日志;这些会被该快照所取代,并且可能一些没有提交的条目会和该快照产生冲突。如果接收到的快照是自己日志的前面部分(由于网络重传或者错误),那么被快照包含的条目将会被全部删除,但是快照之后的条目仍然有用并保留。

这种快照的方式违反了 Raft 的 strong leader 原则,因为 follower 可以在不知道 leader 状态的情况下创建快照。但是我们认为这种违背是合乎情理的。Leader 的存在,是为了防止在达成一致性的时候的冲突,但是在创建快照的时候,一致性已经达成,因此没有决策会冲突。数据依然只能从 leader 流到 follower ,只是 follower 可以重新组织它们的数据了。

我们考虑过一种可替代的基于 leader 的快照方案,在该方案中,只有leader 会创建快照,然后 leader 会发送它的快照给所有的 follower 。但是这样做有两个缺点。第一,发送快照会浪费网络带宽并且延缓了快照过程。每个 follower 都已经拥有了创建自己的快照所需要的信息,而且很显然,follower 从本地的状态中创建快照远比通过网络接收别人发来的要来得经济。第二,leader 的实现会更加复杂。例如,leader 发送快照给 follower 的同时也要并行地将新的日志条目发送给它们,这样才不会阻塞新的客户端请求。

还有两个问题会影响快照的性能。首先,服务器必须决定什么时候创建快照。如果快照创建过于频繁,那么就会浪费大量的磁盘带宽和其他资源;如果创建快照频率太低,就要承担耗尽存储容量的风险,同时也增加了重启时日志回放的时间。一个简单的策略就是当日志大小达到一个固定大小的时候就创建一次快照。如果这个阈值设置得显著大于期望的快照的大小,那么快照的磁盘带宽负载就会很小。

第二个性能问题就是写入快照需要花费一段时间,并且我们不希望它影响到正常的操作。解决方案是通过写时复制的技术,这样新的更新就可以在不影响正在写的快照的情况下被接收。例如,具有泛函数据结构的状态机天然支持这样的功能。另外,操作系统对写时复制技术的支持(如 Linux 上的 fork)可以被用来创建整个状态机的内存快照(我们的实现用的就是这种方法)。

参考

https://www.cnblogs.com/linbingdong/p/6442673.html(论文译文)

http://thesecretlivesofdata.com/raft/(动画+simple阐述)

http://www.kailing.pub/raft/index.html(中文版动画演示)

https://blog.csdn.net/las723/article/details/93767240(LSM树)

https://www.cnblogs.com/cbkj-xd/p/12161903.html(日志压缩的详细说明)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值