raft自己总结

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d0MYTFlF-1652838718164)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/1752522-07c1c597333affd7.png)]

Raft 将一致性问题分解成了三个相对独立的子问题,这些问题将会在接下来的子章节中进行讨论:

  • Leader 选举:当前的 leader 宕机时,一个新的 leader 必须被选举出来。(5.2 节)
  • 日志复制:Leader 必须从客户端接收日志条目然后复制到集群中的其他节点,并且强制要求其他节点的日志和自己的保持一致。
  • 安全性:Raft 中安全性的关键是图 3 中状态机的安全性:如果有任何的服务器节点已经应用了一个特定的日志条目到它的状态机中,那么其他服务器节点不能在同一个日志索引位置应用一条不同的指令。章节 5.4 阐述了 Raft 算法是如何保证这个特性的;该解决方案在选举机制(5.2 节)上增加了额外的限制。

在展示一致性算法之后,本章节将讨论可用性的一些问题以及时序在系统中的作用。

5.1 Raft 基础

一个 Raft 集群包含若干个服务器节点;通常是 5 个,这样的系统可以容忍 2 个节点的失效。在任何时刻,每一个服务器节点都处于这三个状态之一:leader、follower 或者 candidate 。在正常情况下,集群中只有一个 leader 并且其他的节点全部都是 follower 。Follower 都是被动的:他们不会发送任何请求,只是简单的响应来自 leader 和 candidate 的请求。Leader 处理所有的客户端请求(如果一个客户端和 follower 通信,follower 会将请求重定向给 leader)。第三种状态,candidate ,是用来选举一个新的 leader(章节 5.2)。图 4 展示了这些状态和他们之间的转换关系;这些转换关系在接下来会进行讨论。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jq6fe4Cr-1652838718166)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/1752522-b308efb041bee9dc.png)]

Raft 把时间分割成任意长度的任期(term),如图 5 所示。任期用连续的整数标记。每一段任期从一次选举开始,一个或者多个 candidate 尝试成为 leader 。如果一个 candidate 赢得选举,然后他就在该任期剩下的时间里充当 leader 。在某些情况下,一次选举无法选出 leader 。在这种情况下,这一任期会以没有 leader 结束;一个新的任期(包含一次新的选举)会很快重新开始。Raft 保证了在任意一个任期内,最多只有一个 leader 。

图5

不同的服务器节点观察到的任期转换的次数可能不同,在某些情况下,一个服务器节点可能没有看到 leader 选举过程或者甚至整个任期全程。任期在 Raft 算法中充当逻辑时钟的作用,这使得服务器节点可以发现一些过期的信息比如过时的 leader 。每一个服务器节点存储一个当前任期号,该编号随着时间单调递增。服务器之间通信的时候会交换当前任期号;如果一个服务器的当前任期号比其他的小,该服务器会将自己的任期号更新为较大的那个值。如果一个 candidate 或者 leader 发现自己的任期号过期了,它会立即回到 follower 状态。如果一个节点接收到一个包含过期的任期号的请求,它会直接拒绝这个请求。

Raft 算法中服务器节点之间使用 RPC 进行通信,并且基本的一致性算法只需要两种类型的 RPC。请求投票(RequestVote) RPC 由 candidate 在选举期间发起(章节 5.2),追加条目(AppendEntries)RPC 由 leader 发起,用来复制日志和提供一种心跳机制(章节 5.3)。第 7 节为了在服务器之间传输快照增加了第三种 RPC。当服务器没有及时的收到 RPC 的响应时,会进行重试, 并且他们能够并行的发起 RPC 来获得最佳的性能。

5.2 Leader 选举

Raft 使用一种心跳机制来触发 leader 选举。当服务器程序启动时,他们都是 follower 。一个服务器节点只要能从 leader 或 candidate 处接收到有效的 RPC 就一直保持 follower 状态。Leader 周期性地向所有 follower 发送心跳(不包含日志条目的 AppendEntries RPC)来维持自己的地位。如果一个 follower 在一段选举超时时间内没有接收到任何消息,它就假设系统中没有可用的 leader ,然后开始进行选举以选出新的 leader 。

要开始一次选举过程,follower 先增加自己的当前任期号并且转换到 candidate 状态。然后投票给自己并且并行地向集群中的其他服务器节点发送 RequestVote RPC(让其他服务器节点投票给它)。Candidate 会一直保持当前状态直到以下三件事情之一发生:(a) 它自己赢得了这次的选举(收到过半的投票),(b) 其他的服务器节点成为 leader ,© 一段时间之后没有任何获胜者。这些结果会在下面的章节里分别讨论。

当一个 candidate 获得集群中过半服务器节点针对同一个任期的投票,它就赢得了这次选举并成为 leader 。对于同一个任期,每个服务器节点只会投给一个 candidate ,按照先来先服务(first-come-first-served)的原则(注意:5.4 节在投票上增加了额外的限制)。要求获得过半投票的规则确保了最多只有一个 candidate 赢得此次选举(图 3 中的选举安全性)。一旦 candidate 赢得选举,就立即成为 leader 。然后它会向其他的服务器节点发送心跳消息来确定自己的地位并阻止新的选举。

在等待投票期间,candidate 可能会收到另一个声称自己是 leader 的服务器节点发来的 AppendEntries RPC 。如果这个 leader 的任期号(包含在RPC中)不小于 candidate 当前的任期号,那么 candidate 会承认该 leader 的合法地位并回到 follower 状态。 如果 RPC 中的任期号比自己的小,那么 candidate 就会拒绝这次的 RPC 并且继续保持 candidate 状态。

第三种可能的结果是 candidate 既没有赢得选举也没有输:如果有多个 follower 同时成为 candidate ,那么选票可能会被瓜分以至于没有 candidate 赢得过半的投票。当这种情况发生时,每一个候选人都会超时,然后通过增加当前任期号来开始一轮新的选举。然而,如果没有其他机制的话,该情况可能会无限重复。

Raft 算法使用随机选举超时时间的方法来确保很少发生选票瓜分的情况,就算发生也能很快地解决。为了阻止选票一开始就被瓜分,选举超时时间是从一个固定的区间(例如 150-300 毫秒)随机选择。这样可以把服务器都分散开以至于在大多数情况下只有一个服务器会选举超时;然后该服务器赢得选举并在其他服务器超时之前发送心跳。同样的机制被用来解决选票被瓜分的情况。每个 candidate 在开始一次选举的时候会重置一个随机的选举超时时间,然后一直等待直到选举超时;这样减小了在新的选举中再次发生选票瓜分情况的可能性。9.3 节展示了该方案能够快速地选出一个 leader 。

选举的例子可以很好地展示可理解性是如何指导我们选择设计方案的。起初我们打算使用一种等级系统(ranking system):每一个 candidate 都被赋予一个唯一的等级(rank),等级用来在竞争的 candidate 之间进行选择。如果一个 candidate 发现另一个 candidate 拥有更高的等级,它就会回到 follower 状态,这样高等级的 candidate 能够更加容易地赢得下一次选举。但是我们发现这种方法在可用性方面会有一下小问题。我们对该算法进行了多次调整,但是每次调整之后都会有新的小问题。最终我们认为随机重试的方法更加显然且易于理解。

5.3 日志复制

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

日志以图 6 展示的方式组织。每个日志条目存储一条状态机指令和 leader 收到该指令时的任期号。任期号用来检测多个日志副本之间的不一致情况,同时也用来保证图 3 中的某些性质。每个日志条目都有一个整数索引值来表明它在日志中的位置。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B1aUrh7A-1652838718166)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/1752522-6ceba6710280cbaa.png)]

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

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

  • 如果不同日志中的两个条目拥有相同的索引和任期号,那么他们存储了相同的指令。
  • 如果不同日志中的两个条目拥有相同的索引和任期号,那么他们之前的所有日志条目也都相同。

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t0LMErHw-1652838718167)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/1752522-fc1352afc54b5ce7.png)]

图 7:当一个 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 的日志条目覆盖。5.4 节会证明通过增加一个限制可以保证安全性。

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

如果想要的话,该协议可以被优化来减少被拒绝的 AppendEntries RPC 的个数。例如,当拒绝一个 AppendEntries RPC 的请求的时候,follower 可以包含冲突条目的任期号和自己存储的那个任期的第一个 index 。借助这些信息,leader 可以跳过那个任期内所有冲突的日志条目来减小 nextIndex;这样就变成每个有冲突日志条目的任期需要一个 AppendEntries RPC 而不是每个条目一次。在实践中,我们认为这种优化是没有必要的,因为失败不经常发生并且也不可能有很多不一致的日志条目。

通过这种机制,leader 在当权之后就不需要任何特殊的操作来使日志恢复到一致状态。Leader 只需要进行正常的操作,然后日志就能在回复 AppendEntries 一致性检查失败的时候自动趋于一致。Leader 从来不会覆盖或者删除自己的日志条目(图 3 的 Leader Append-Only 属性)

这样的日志复制机制展示了第 2 节中描述的一致性特性:只要过半的服务器能正常运行,Raft 就能够接受,复制并应用新的日志条目;在正常情况下,新的日志条目可以在一个 RPC 来回中被复制给集群中的过半机器;并且单个运行慢的 follower 不会影响整体的性能。

5.4 安全性

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

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

5.4.1 选举限制

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

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

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nX4mBcwG-1652838718167)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/1752522-f23fc91c5094c2cd.png)]

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

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

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

5.4.3 安全性论证

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aEs4Cg7f-1652838718168)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/1752522-03fb8b6791409bb4.png)]

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

  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 也会包含被间接提交的日志条目,例如图 8 (d) 中的索引 2。

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

最后,Raft 要求服务器按照日志索引顺序应用日志条目。再加上状态机安全特性,这就意味着所有的服务器都会按照相同的顺序应用相同的日志条目到自己的状态机中。

5.5 Follower 和 candidate 崩溃

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

5.6 定时(timing)和可用性

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

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

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

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

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

6 集群成员变更

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ifBlPdTr-1652838718168)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/1752522-284d88f08ab84f85.png)]

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

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

  • 日志条目被复制给集群中新、老配置的所有服务器。
  • 新、旧配置的服务器都可以成为 leader 。
  • 达成一致(针对选举和提交)需要分别在两种配置上获得过半的支持。

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AONYe9Lw-1652838718168)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/1752522-6a440c013876545a.png)]

7 日志压缩

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

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

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

图12

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

图 12 展示了 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 丢弃它所有的日志;这些会被该快照所取代,并且可能一些没有提交的条目会和该快照产生冲突。如果接收到的快照是自己日志的前面部分(由于网络重传或者错误),那么被快照包含的条目将会被全部删除,但是快照之后的条目仍然有用并保留。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MVQqD7io-1652838718169)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/1752522-97e86b90137791b9.png)]

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

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

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

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

8 客户端交互

本节介绍客户端如何和 Raft 进行交互,包括客户端如何找到 leader 和 Raft 是如何支持线性化语义的。这些问题对于所有基于一致性的系统都存在,并且 Raft 的解决方案和其他的也差不多。

Raft 的客户端发送所有的请求给 leader 。当客户端第一次启动的时候,它会随机挑选一个服务器进行通信。如果客户端第一次挑选的服务器不是 leader ,那么该服务器会拒绝客户端的请求并且提供关于它最近接收到的领导人的信息(AppendEntries 请求包含了 leader 的网络地址)。如果 leader 已经崩溃了,客户端请求就会超时;客户端之后会再次随机挑选服务器进行重试。

我们 Raft 的目标是要实现线性化语义(每一次操作立即执行,只执行一次,在它的调用和回复之间)。但是,如上述,Raft 可能执行同一条命令多次:例如,如果 leader 在提交了该日志条目之后,响应客户端之前崩溃了,那么客户端会和新的 leader 重试这条指令,导致这条命令被再次执行。解决方案就是客户端对于每一条指令都赋予一个唯一的序列号。然后,状态机跟踪每个客户端已经处理的最新的序列号以及相关联的回复。如果接收到一条指令,该指令的序列号已经被执行过了,就立即返回结果,而不重新执行该请求。

整个集群在运行时会持有如下状态信息:

所有节点都会持有的持久化状态信息(在响应RPC前会先将更新写入到持久存储):

  • currentTerm: 当前Term ID 初值为0
  • voteFor :该 Term 中已接收到来自该节点的选票的 Candidate ID
  • log[]: 日志记录。第一个日志记录的 index 值为 1

所有节点都会持有的易失性状态信息:

  • commitIndex: 最后一个已提交日志记录的index 初值为0
  • lastApplied: 最后一个已应用状态机的日志记录的index 初值为0

Leader才会持有的易失性状态信息 (会在每次选举完成后初始化):

- nextIndex[]: 每个节点即将为其发送的下一个日志记录的 index(初值均为 Leader 最新日志记录 index 值 + 1)
- matchIndex[]: 每个节点上已备份的最后一条日志记录的 index(初值均为 0

在Raft集群中,节点间交互主要有两种RPC调用构成。

首先用于日志备份的AppendEntries:

AppendEntries RPC:由 Leader 进行调用,用于将日志记录备份至 Follower,同时还会被用来作为心跳信息

参数:

  • trem: Leader的Trem ID
  • leaderId : Leader的ID
  • prevLogIndex: 正在备份的日志记录之前的日志记录的index值
  • prevLogTerm: 正在备份的日志记录之前的日志记录的Term ID
  • entries[]: 正在备份的日志记录
  • leaderCommit: Leader已经提交的最后一次日志记录的index值

返回值:

term: 接收方的当前Term ID

success: 当Follower能够在自己的日志中找到Index值和Term ID与PrevLogIndex 和prevLogTerm 相同记录时为true

接收方收到该RPC后会进行以下操作:

1.若term<currentterm,返回false

2.若votedFor == null and 给定的日志记录信息可得出对方的日志和自己的相同甚至更新,返回true

Raft集群的节点还需要遵循以下规则:

对于所有节点:

  • 若 commitIndex > lastApplied ,则对lastApplied +1 ,并将log[lastApplied]应用至上层状态机
  • 若RPC请求或者相应内容中携带的term > currentTerm , 则令currentTerm = term, Leader降级为Follower

对于Follower:

  • 负责响应Candidate和Leader的PRC
  • 如果在Election Timeout之前没能收到来自当前Leader的AppendEntries RPC或将选票投给其他Candidate,则进入Candidate角色

对于Candidate:

​ 在进入Candidate角色时,发起Leader选举:

  • currentTrem +1
  • 将选票投给自己
  • 重置Election Timeout 计时器
  • 发送 RequestVote RPC 至其他所有节点
    - 如果接收到来自其他大多数节点的选票,则进入 Leader 角色
    - 若接收到来自其他 Leader 的 AppendEntries RPC,则进入 Follower 角色
    - 若再次 Election Timeout,那么重新发起选举

对于 Leader:

  • 在空闲周期地向Follower发起空白AppendEntries PRC(作为心跳信息),以避免Follower 发起选举
  • 若从客户端处收到新的命名,则将该命名追加到所存储的日志中,并在顺利将该命令应用至上层状态机后返回响应
  • 如果最新一条日志记录的index值大于等于某个Follower的nextIndex值,则通过AppendEntries RPC发送在该nextIndex值之后的所有日志记录: === 》 日志匹配
    • 如果备份成功,那么就更新该Follower对应的 nextIndex和matchIndex值
    • 否则,对nextIndex 减1并重试
    • 如果存在一个值N,使得N > commitIndex,且大多数的matchIndex[i] >= N,且log[N].term == currentTrem, 令commitIndex = N

接下来我们将分章节介绍 Raft 的主要实现以及各种约束的主要考虑。

Leader 选举

在初次启动时,节点首先会进入 Follower 角色。只要它能够一直接收到来自其他 Leader 节点发来的 RPC 请求,它就会一直处于 Follower 状态。如果接收不到来自 Leader 的通信,Follower 会等待一个称为 Election Timeout(选举超时)的超时时间,然后便会开始发起新一轮选举。

Follower 发起选举时会对自己存储的 Term ID 进行自增,并进入 Candidate 状态。随后,它会将自己的一票投给自己,并向其他节点并行地发出 RequestVote RPC 请求。其他节点在接收到该类 RPC 请求时,会以先到先得的原则投出自己在该 Term 中的一票。

当 Candidate 在某个 Term 接收到来自集群中大多数节点发来的投票时,它便会成为 Leader,然后它便会向其他节点进行通信,确保其他节点知悉它是 Leader 而不会发起又一轮投票。每个节点在指定 Term 内只会投出一票,而只有接收到大多数节点发来的投票才能成为 Leader 的性质确保了在任意 Term 内都至多会有一个 Leader。由此我们实现了前面提及的 Eleaction Safety 性质。

Candidate 在投票过程中也有可能收到来自其他 Leader 的 AppendEntries RPC 调用,这意味着有其他节点成为了该 Term 的 Leader。如果该 RPC 中携带的 Term ID 大于等于 Candidate 当前保存的 Term ID,那么 Candidate 便会认可其为 Leader,并进入 Follower 状态,否则它会拒绝该 RPC 并继续保持其 Candidate 身份。

除了上述两种情况以外,选举也有可能发生平局的情况:若干节点在短时间内同时发起选举,导致集群中没有任何一个节点能够收到来自集群大多数节点的投票。此时,节点同样会在等待 Election Timeout 后发起新一轮的选举,但如果不加入额外的应对机制,这样的情况有可能持续发生。为此,Raft 为 Election Timeout 的取值引入了随机机制:节点在进入新的 Term 时,会在一个固定的区间内(如 150~300ms)随机选取自己在该 Term 所使用的 Election Timeout。通过随机化来错开各个节点进入 Candidate 状态的时机便能有效避免这种情况的重复发生。

日志备份

在选举出一个 Leader 后,Leader 便能够开始响应来自客户端的请求了。客户端请求由需要状态机执行的命令所组成:Leader 会将接收到的命令以日志记录的形式追加到自己的记录里,并通过 AppendEntries RPC 备份到其他节点上;当日志记录被安全备份后,Leader 就会将该命令应用于位于自己上层的状态机,并向客户端返回响应;无论 Leader 已响应客户端与否,Leader 都会不断重试 AppendEntries RPC 调用,直到所有节点都持有该日志记录的备份。

日志由若干日志记录组成:每条记录中都包含一个需要在状态机上执行的命令,以及其对应的 index 值;除外,日志记录还会记录自己所属的 Term ID。

img

当某个日志记录顺利备份到集群大多数节点上后,Leader 便会认为该日志记录“已提交”(Committed),即该日志记录已可被安全的应用到上层状态机上。Raft 保证一个日志记录一旦被提交,那么它最终就会被所有仍可用的状态机所应用。除外,一条日志记录的提交也意味着位于其之前的所有日志记录也进入“已提交”状态。Leader 会保存其已知的最新的已提交日志的 index 值,并在每次进行 AppendEntries RPC 调用时附带该信息;Follower 在接收到该信息后即可将对应的日志记录应用在位于其上层的状态机上。

在运行时,Raft 能为系统提供如下两点性质,这两点性质共同构成了论文图 3 中提到的 Log Matching 性质:

  • 对于两份日志中给定的 index 处,如果该处两个日志记录的 Term ID 相同,那么它们存储的状态机命令相同
  • 如果两份日志中给定 index 处的日志记录有相同的 Term ID 值,那么位于它们之前的日志记录完全相同

第一条性质很容易得出,考虑到 Leader 在一个 Term 中只会在一个 index 处创建一条日志记录,而且日志的位置不会发生改变。为了提供上述第二个性质,Leader 在进行 AppendEntries RPC 调用时会同时携带在其自身的日志存储中位于该新日志记录之前的日志记录的 index 值及 Term ID;如果 Follower 在自己的日志存储中没有找到这条日志记录,那么 Follower 就会拒绝这条新记录。由此,每一次 AppendEntries RPC 调用的成功返回都意味着 Leader 可以确定该 Follower 存储的日志直到该 index 处均与自己所存储的日志相同。

AppendEntries RPC 的日志一致性检查是必要的,因为 Leader 的崩溃会导致新 Leader 存储的日志可能和 Follower 不一致。

img

考虑上图(即文中的图 7),对于给定的 Leader 日志,Follower 有可能缺失部分日志(a、b 情形)、有可能包含某些未提交的日志(c、d 情形)、或是两种情况同时发生(e、f 情形)。

对于不一致的 Follower 日志,Raft 会强制要求 Follower 与 Leader 的日志保持一致。为此,Leader 会尝试确定它与各个 Follower 所能相互统一的最后一条日志记录的 index 值,然后就会将 Follower 在该 index 之后的所有日志删除,再将自身存储的日志记录备份到 Follower 上。具体而言:

  1. Leader 会为每个 Follower 维持一个 nextIndex 变量,代表 Leader 即将通过 AppendEntries RPC 调用发往该 Follower 的日志的 index 值
  2. 在刚刚被选举为一个 Leader 时,Leader 会将每个 Follower 的 nextIndex 置为其所保存的最新日志记录的 index 之后
  3. 当有 Follower 的日志与 Leader 不一致时,Leader 的 AppendEntries RPC 调用会失败,Leader 便对该 Follower 的 nextIndex 值减 1 并重试,直到 AppendEntries 成功
  4. Follower 接收到合法的 AppendEntries 后,便会移除其在该位置上及以后存储的日志记录,并追加上新的日志记录
  5. 如此,在 AppendEntries 调用成功后,Follower 便会在该 Term 接下来的时间里与 Leader 保持一致

由此,我们实现了前面提及的 Leader Append-Only 和 Log Matching 性质。

Leader 选举约束

就上述所提及的 Leader 选举及日志备份规则,实际上是不足以确保所有状态机都能按照相同的顺序执行相同的命令的。例如,在集群运行的过程中,某个 Follower 可能会失效,而 Leader 继续在集群中提交日志记录;当这个 Follower 恢复后,有可能会被选举为 Leader,而它实际上缺少了一些已经提交的日志记录。

其他的基于 Leader 架构的共识算法都会保证 Leader 最终会持有所有已提交的日志记录。一些算法(如 Viewstamped Replication)允许节点在不持有所有已提交日志记录的情况下被选举为 Leader,并通过其他机制将缺失的日志记录发送至新 Leader。而这种机制实际上会为算法引入额外的复杂度。为了简化算法,Raft 限制了日志记录只会从 Leader 流向 Follower,同时 Leader 绝不会覆写它所保存的日志。

在这样的前提下,要提供相同的保证,Raft 就需要限制哪些 Candidate 可以成为 Leader。前面提到,Candidate 为了成为 Leader 需要获得集群内大多数节点的选票,而一个日志记录被提交同样要求它已经被备份到集群内的大多数节点上,那么如果一个 Candidate 能够成为 Leader,投票给它的节点中必然存在节点保存有所有已提交的日志记录。Candidate 在发送 RequestVote RPC 调用进行拉票时,它还会附带上自己的日志中最后一条记录的 index 值和 Term ID 值:其他节点在接收到后会与自己的日志进行比较,如果发现对方的日志落后于自己的日志(首先由 Term ID 决定大小,在 Term ID 相同时由 index 决定大小),就会拒绝这次 RPC 调用。如此一来,Raft 就能确保被选举为 Leader 的节点必然包含所有已经提交的日志。

来自旧 Term 的日志记录

如上文所述,Leader 在备份当前 Term 的日志记录时,在成功备份至集群大多数节点上后 Leader 即可认为该日志记录已提交。但如果 Leader 在日志记录备份至大多数节点之前就崩溃了,后续的 Leader 会尝试继续备份该日志。然而,此时的 Leader 即使在将该日志备份至大多数节点上后都无法立刻得出该日志已提交的结论。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QI7dmJji-1652838718170)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/v2-9b541db227881eba77ca49e62c491b46_720w.jpg)]

考虑上图这种情形。在时间点 (a) 时,S1 是 Leader,并把 (TermID=2, index=2) 的日志记录备份到了 S2 上。到了时间点 (b) 时,S1 崩溃,S5 收到 S3、S4、S5 的选票,被选为 Leader,并从客户端处接收到日志记录 (TermID=3, index=2)。在时间点 © 时,S5 崩溃,S1 重启,被选举为 Leader,并继续将先前没有备份的日志记录 (TermID=2, index=2) 备份到其他节点上。即便此时 S1 顺利把该日志记录备份到集群大多数节点上,它仍然不能认为该日志记录已被安全提交。考虑此时 S1 崩溃,S5 将可以收到来自 S2、S3、S4、S5 的选票,成为 Leader(其最后一个日志记录的 Term ID 是 3,大于 2),进入情形 (d):此时 S5 会继续把日志记录 (TermID=3, index=2) 备份到其他节点上,覆盖掉原本已经备份至大多数节点的日志记录 (TermID=2, index=2)。然而,如果在时间点 © S1 成为 Leader 后,同样将当前 Term 的最新日志记录 (TermID=4, index=3) 备份出去并提交,就会进入情形 (e),此时 S5 便无法再被选举为 Leader。因此,解决该问题的关键在于在备份旧 Term 的日志时也要把当前 Term 最新的日志一并分发出去。

由此,Raft 只会在备份当前 Term 的日志记录时才会通过计数的方式来判断该日志记录是否已被提交;一旦该日志记录完成提交,根据前面提及的 Log Matching 性质,Leader 就能得出之前的日志记录也已被提交。由此,我们便实现了前面提及的 Leader Completeness 性质。文中 5.4.3 节有完整的证明过程,感兴趣的读者可自行查阅。

证得前面 4 条性质后,最后一条 State Machine Safety 性质也可证得:当节点将日志记录应用于其上层状态机时,该日志记录及其之前的所有日志记录必然已经提交。某些节点执行命令的进度可能落后,我们考虑所有节点目前已执行日志记录的 index 值的最小值:Log Completeness 性质保证了未来的所有 Leader 都会持有该日志记录,因此在之后的 Term 中其他节点应用位于该 index 处的日志记录时,该日志记录保存的必然是相同的命令。由此,上层状态机只要按照 Raft 日志记录的 index 值顺序执行命令即可安全完成状态备份。

时序要求

为了提供合理的可用性,集群仍需满足一定的时序要求,具体如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QrlsmLnz-1652838718171)(https://www.zhihu.com/equation?tex=broadcastTime+\ll+electionTimeout+\ll+MTBF)]

其中 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b9PwCJPx-1652838718171)(https://www.zhihu.com/equation?tex=broadcastTime)] 即一个节点并发地发送 RPC 请求至集群中其他节点并接收请求的平均耗时;[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JRSGSuyN-1652838718172)(https://www.zhihu.com/equation?tex=electionTimeout)] 即节点的选举超时时间; [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JO3KGEy7-1652838718172)(https://www.zhihu.com/equation?tex=MTBF)] 即单个节点每次失效的平均间隔时间(Mean Time Between Failures)。

上述的不等式要求,[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yhKJW8IS-1652838718173)(https://www.zhihu.com/equation?tex=broadcastTime)] 要小于 一个数量级,以确保正常 Leader 心跳间隔不会导致 Follower 超时并发起选举;同时考虑到 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wK6jzvmK-1652838718173)(https://www.zhihu.com/equation?tex=electionTimeout)] 会随机选出,该不等式还能确保 Leader 选举时平局局面不会频繁出现。除外,[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1osonbHn-1652838718173)(https://www.zhihu.com/equation?tex=electionTimeout)] 也应比 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-culEh5Ay-1652838718174)(https://www.zhihu.com/equation?tex=MTBF)] 小几个数量级,考虑到系统会在 Leader 失效时停止服务,而这样的情况不应当频繁出现。

在这个不等式中,[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Nd0YdDnj-1652838718174)(https://www.zhihu.com/equation?tex=broadcastTime)] 及 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ien7TETU-1652838718174)(https://www.zhihu.com/equation?tex=MTBF)] 由集群架构所决定,[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-swkMJ0bK-1652838718175)(https://www.zhihu.com/equation?tex=electionTimeout)] 则可由运维人员自行配置。

Candidate 与 Follower 失效

目前来讲我们讨论都是 Leader 失效的问题。对于 Candidate 和 Follower 而言,它们分别是 RequestVote 和 AppendEntries RPC 调用的接收方:当 Candidate 或 Follower 崩溃后,RPC 调用会失败;Raft RPC 失败时会不断重试 RPC,直至 RPC 成功;除外,RPC 调用也有可能已经生效,但接收方在响应前就已失效,为此 Raft 保证 RPC 的幂等性,在节点重启后收到重复的 RPC 调用也不会有所影响。

集群成员变更

直到目前为止,我们的讨论都假设集群的成员配置是一成不变的,然而这在系统的正常运维中是不常见的:系统总是可能需要做出变更,例如移除一些节点或增加一些节点。

当然,集群可以被全部关闭后,调整配置文件,再全部重启,这样也能完成集群配置变更,但这样会导致系统出现一段时间的不可用。而 Raft 则引入了额外的机制来允许集群在运行中变更自己的成员配置。

在进行配置变更时,直接从旧配置切换至新配置是不可行的,源于不同的节点不可能原子地完成配置切换,而这之间可能会有一些时间间隙使得集群存在两个不同的“大多数”。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XBoJ1ZZw-1652838718175)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/v2-de2f49416661795d84165c4efd63a524_720w.jpg)]

如上图所示,集群逐渐地从旧配置切换至新配置,那么在箭头标记的位置就出现了两个不同的“大多数”:S1、S2 构成 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zsnrqu78-1652838718175)(https://www.zhihu.com/equation?tex=C_{old})] 的大多数,S3、S4、S5 构成 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Uox2gfTt-1652838718176)(https://www.zhihu.com/equation?tex=C_{new})] 的大多数。在这一时间间隔内,处于两个配置的节点可能会选出各自的 Leader,引入 Split-Brain 问题。问题的关键在于,在这段时间间隔中,[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TnWCdLyg-1652838718176)(https://www.zhihu.com/equation?tex=C_%7Bold%7D)] 和 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nyo3cDOQ-1652838718176)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 都能够独立地做出决定。

为了解决这个问题,Raft 采用二阶段的方式来完成配置切换:在 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JxRKCRPa-1652838718177)(https://www.zhihu.com/equation?tex=C_%7Bold%7D)] 与 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zQqkncjA-1652838718177)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 之间,引入一个被称为 Joint Consensus 的特殊配置 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KDTzsruj-1652838718178)(https://www.zhihu.com/equation?tex=C_{old%2Cnew})] 作为迁移状态。该配置有如下性质:

  • 日志记录会被备份到 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b029qaMX-1652838718178)(https://www.zhihu.com/equation?tex=C_%7Bold%7D)] 及 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yio26ayj-1652838718178)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 的节点上
  • 两份配置中的任意机器都能成为 Leader
  • 选举或提交日志记录要求得到来自 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9ZzVK6tP-1652838718179)(https://www.zhihu.com/equation?tex=C_%7Bold%7D)] 和 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kfWaBoDJ-1652838718179)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 的两个不同的“大多数”的同意

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6AwTC6KB-1652838718180)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/v2-295c0116b60ff2fcbe5a947379dce7d8_720w.jpg)]

上图显示了配置切换的时序,其中可以看到不存在 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-97uZ3WnE-1652838718181)(https://www.zhihu.com/equation?tex=C_%7Bold%7D)] 和 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sKkrXDUu-1652838718181)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 都能独立作出决定的时间段。

切换时,Leader 会创建特殊的配置切换日志,并利用先前提到的日志备份机制通知其他节点进行配置切换。对于这种特殊的配置切换日志,节点在接收到时就会立刻切换配置,不会等待日志提交,因此 Leader 会首先进入 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-60TPei63-1652838718181)(https://www.zhihu.com/equation?tex=C_{old%2Cnew})] 配置,同时运用这份配置来判断配置切换的日志是否成功提交。如此,如果 Leader 在完成提交这份日志之前崩溃,新的 Leader 只会处于 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F25JcSLe-1652838718182)(https://www.zhihu.com/equation?tex=C_%7Bold%7D)] 或 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3xh7nVG9-1652838718182)(https://www.zhihu.com/equation?tex=C_%7Bold%2Cnew%7D)] 配置,如此一来在该日志记录完成提交前,[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-roicfaog-1652838718182)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 便无法独立做出决定。

在 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-npS3vnnW-1652838718183)(https://www.zhihu.com/equation?tex=C_%7Bold%2Cnew%7D)] 的配置变更日志完成提交后,[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4hfWklGg-1652838718183)(https://www.zhihu.com/equation?tex=C_%7Bold%7D)] 便也不能独立做出决定了,且 Leader Completeness 性质保证了此时选举出的 Leader 必然处于 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OWTjKH9L-1652838718183)(https://www.zhihu.com/equation?tex=C_%7Bold%2Cnew%7D)] 配置。此时,Leader 就能开始重复上述过程,切换到 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t1d0gVYP-1652838718184)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 配置。在 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fl76DYXr-1652838718184)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 配置日志完成提交后,这个过程中被移出集群的节点就能顺利关闭了。

除此以外,我们仍有三个问题需要去解决。

首先,配置变更可能会引入新的节点,这些节点不包含之前的日志记录,完成日志备份可能会需要较长的时间,而这段时间可能导致集群无法提交日志,引入一段时间的服务不可用。为此,Raft 在节点变更配置之前还引入了一个额外的阶段:此时节点会以不投票成员的形式加入集群,开始备份日志,Leader 在计算“大多数”时也不会考虑它们;等到它们完成备份后,它们就能回到正常状态,完成配置切换。

此外,集群的 Leader 有可能不属于 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-de7NrHiT-1652838718184)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)]。在这种情况下,Leader 在完成 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RjyOWsTW-1652838718184)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 的配置变更日志提交后才能变更自己的配置并关闭。也就是说,在 Leader 提交 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TCVSTXZ4-1652838718185)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 的日志时,那段时间里它会需要管理一个不包含自己的集群:它会把日志记录备份出去,但不会把自己算入“大多数”。直到 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Hq2Z94RC-1652838718185)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 日志完成提交,[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HnFnNjj7-1652838718185)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 才能够独立做出决定,才能够在原 Leader 降级后在 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-21PhR9sM-1652838718186)(https://www.zhihu.com/equation?tex=C_%7Bnew%7D)] 集群中选出新的 Leader;在那之前,来自 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Vu4b7mze-1652838718186)(https://www.zhihu.com/equation?tex=C_%7Bold%7D)] 的节点有可能被选为 Leader。

最后,那些从集群中被移除出去的节点可能会在配置切换完成后干扰新集群的运行。这些节点不会再接收到 Leader 的心跳,于是它们就会超时并发起选举。这时它们会发起 RequestVote RPC 调用,其中包含新的 Term ID,而这可能导致新的 Leader 自动降级为 Follower,导致服务不可用。最终新集群会选出一个新的 Leader,但被移除的节点依旧不会接收到心跳信息,它们会再次超时,再次发起选举,如此循环往复。

为解决此问题,节点在其“确信” Leader 仍存活时会拒绝 RequestVote RPC 调用:如果距离节点上一次接收到 Leader 心跳信息过去的时间小于 Election Timeout 的最小值,那么节点便会“确信” Leader 仍然存活。考虑前面提到的时序要求,这确实能够在大多数情况下避免该问题。

日志压缩

随着 Raft 集群的不断运行,节点上的日志体积会不断增大,这会逐渐占用节点的磁盘资源,此外过长的日志也会延长节点重放日志的耗时,引入服务可用性问题。为此,集群需要对过往的日志进行压缩。

快照是进行日志压缩最简便的方案。在进行快照时,状态机当前的完整状态会被写入到持久存储中,而后就能够安全地把直到该时间点以前的日志记录移除了。完成快照后,Raft 也会在快照中记录其所覆盖到的最新日志记录的 index 和 Term ID,以便后面的日志记录能够被继续追加。为了兼容前面提到的集群成员配置变更,快照同样需要记录下当前的集群成员配置。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9S0TqKfl-1652838718186)(https://gitee.com/yang_xizheng/csdn-images/tree/master/img/v2-ff38d6b6e9886c4bf066504aec343830_720w.jpg)]

对于 Raft 来说,每个节点会独立地生成快照。相比起只由 Leader 生成快照,这样的设计避免了 Leader 频繁向其他 Follower 传输快照而占用网络带宽,况且 Follower 也持有着足以独立生成快照的数据。

尽管如此,当某个 Follower 落后太多或是集群加入了新节点时,Leader 仍然会需要将自己持有的快照传输给 Follower。为此,Raft 提供了专门的 InstallSnapshot RPC 接口。

InstallSnapshot RPC: 由 Leader 进行调用,用于将组成快照的文件块发给指定 Follower。Leader 总会按照顺序发送文件块。

参数:
- term: Leader 当前的 Term ID
- leaderId: Leader 的 ID
- lastIncludedIndex: 该快照所覆盖的最后一个日志记录的 index 值
- lastIncludedTerm: 该快照所覆盖的最后一个日志记录的 Term ID
- offset: 当前发送的文件块在整个快照文件中的偏移值
- data[]: 文件块的内容
- done: 该文件块是否为最后一个文件块

返回值:
- term: Follower 的当前 Term ID。Leader 可根据该值判断是否要降级为 Follower。

Follower 在接收到该 RPC 调用后会进行以下操作:
\1. 若 term < currentTerm,return
\2. 若 offset == 0,创建快照文件
\3. 在快照文件的指定 offset 处写入 data[]
\4. 若 done == false,返回响应,并继续等待其他文件块
\5. 移除已有的或正在生成的在该快照之前的快照
\6. 如果有一个日志记录的 Term ID 及 index 值与该快照所包含的最后一个日志记录相同,那么便保留该日志记录之后的日志记录,并返回响应
\7. 移除被该快照覆盖的所有日志记录
\8. 使用该快照的内容重置上层状态机,并载入快照所携带的集群成员配置

客户端交互

最后,我们再来聊聊客户端如何与 Raft 集群进行交互。

首先,客户端需要能够得知目前 Raft 集群的 Leader 是谁。在一开始,客户端会与集群中任意的一个节点进行通信:如果该节点不是 Leader,那么它会把上一次接收到的 AppendEntries RPC 调用中携带的 Leader ID 返回给客户端;如果客户端无法连接至该节点,那么客户端就会再次随机选取一个节点进行重试。

Raft 的目标之一是为上层状态机提供日志记录的 exactly-once 语义,但如果 Leader 在完成日志提交后、向客户端返回响应之前崩溃,客户端就会重试发送该日志记录。为此,客户端需要为自己的每一次通信赋予独有的序列号,而上层状态机则需要为每个客户端记录其上一次通信所携带的序列号以及对应的响应内容,如此一来当收到重复的调用时状态机便可在不执行该命令的情况下返回响应。

对于客户端的只读请求,Raft 集群可以在不对日志进行任何写入的情况下返回响应。然而,这有可能让客户端读取到过时的数据,源于当前与客户端通信的 “Leader” 可能已经不是集群的实际 Leader,而它自己并不知情。

为了解决此问题,Raft 必须提供两个保证。首先,Leader 持有关于哪个日志记录已经成功提交的最新信息。基于前面提到的 Leader Completeness 性质,节点在成为 Leader 后会立刻添加一个空白的 no-op 日志记录;此外,Leader 还需要知道自己是否已经需要降级,为此 Leader 在处理只读请求前需要先与集群大多数节点完成心跳通信,以确保自己仍是集群的实际 Leader。

结语

至此,本文已对 Raft 论文的内容进行了完整的总结。总体而言,Raft 的论文为 Raft 提供了很详实的介绍,论文各处的 API Specification 也为他人实现 Raft 算法提供了很好的基础。Raft 算法也存在着一些在论文中也没有提及的细节及优化方式,有机会的话我会在后续的文章中介绍这部分的内容,敬请期待。

到过时的数据,源于当前与客户端通信的 “Leader” 可能已经不是集群的实际 Leader,而它自己并不知情。

为了解决此问题,Raft 必须提供两个保证。首先,Leader 持有关于哪个日志记录已经成功提交的最新信息。基于前面提到的 Leader Completeness 性质,节点在成为 Leader 后会立刻添加一个空白的 no-op 日志记录;此外,Leader 还需要知道自己是否已经需要降级,为此 Leader 在处理只读请求前需要先与集群大多数节点完成心跳通信,以确保自己仍是集群的实际 Leader。

结语

至此,本文已对 Raft 论文的内容进行了完整的总结。总体而言,Raft 的论文为 Raft 提供了很详实的介绍,论文各处的 API Specification 也为他人实现 Raft 算法提供了很好的基础。Raft 算法也存在着一些在论文中也没有提及的细节及优化方式,有机会的话我会在后续的文章中介绍这部分的内容,敬请期待。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值