Raft 算法

发布时间 2023-08-13 14:33:10作者: Angelia-Wang

论文 《In Search of an Understandable Consensus Algorithm》,发表于2014年

相比于 Paxos,Raft 最大的特性就是易于理解。为了达到这个目标,Raft主要做了两方面的事情:

  1. 问题分解:把共识算法分为三个子问题,分别是领导者选举 (leaderlection)、日志复制 (log replication)、安全性 (safety)
  2. 状态简化:对算法做出一些限制,减少状态数量和可能产生的变动。

1 复制状态机

复制状态机 (Replicated state machine):相同的初始状态 + 相同的输入 = 相同的结束状态。

多个节点上,从相同的初始状态开始,执行相同的一串命令,产生相同的最终状态。

在 Raft 中,leader 将客户端请求 (command) 封装到一个个日志实体 (log entry) 中,将这些 log entries 复制到所有 follower 节点,然后大家按相同顺序应用 log entries 中的 command,根据复制状态机的理论,大家的结束状态肯定是一致的。

image-20230812002539038

我们使用共识算法,就是为了实现复制状态机。一个分布式场景下的各节点间,就是通过共识算法来保证命令序列的一致,从而始终保持它们的状态一致,从而实现高可用的。(投票选主是一种特殊的命令)

拓展:复制状态机作为一种抽象的思想,其的功能可以更加强大。比如两个副本一个采用行存储的数据结构存储数据,另一个采用列存储,只要它们初始数据相同,并持续发给他们相同的命令,那么同一时刻从两个副本中读取到的结果也是一样的,这就是一种 HTAP 的实现方法 (如 TiDB)。

2 状态简化

在任何时刻,每一个服务器节点都处于 Leader, Follower, Candidate 这三种状态之一

相比于 Paxos,这一点极大简化了算法的实现,因为 Raft 只需考虑状态的切换,而不用像 Paxos 那样考虑状态之间的共存和互相影响。

  1. 任何一个节点启动时都处于 Follower 状态,若它察觉到集群中没有 Leader,就会将自己切换到 Candidate 状态。
  2. 在 Candidate 状态中进行一次或多次的选举,最终根据选举的结果决定将自己切换到 Leader 状态,或切换回 Follower 状态。
  3. 若选举成功,则切换到 Leader 状态并为客户端提供服务,若它在 Leader 状态的任期结束 or 自身发生宕机等问题,会切换回 Follower 状态,进行下一轮循环。
image-20230812003126788

Raft 把时间分割成任意长度的 任期 (term),任期用连续的整数标记,通常是 Int 型。

每一段任期从一次选举开始。在某些情况下,一次选举无法选出 Leader (没有任何一个节点的票数超过一半),在这种情况下,这一任期会以没有 Leader 结束,如图中的 t3;一个新的任期 (包含一次新的选举) 会很快重新开始。Raft 保证在任意一个任期内,最多只有一个 Leader。

image-20230812004215855

任期机制可以明确标识集群状态;且通过任期的比较可确认一台服务器历史的状态,?可通过查看一台服务器是否具有 t2 任期内的日志,判断其是否在 t2 这个时间段内是否宕机过

Raft 算法中,服务器节点之间使用 RPC 进行通信,并且 Raft 中只有两种主要的 RPC:

  • RequestVote RPC (请求投票):由 Candidate 在选举期间发起。
  • AppendEntries RPC (追加条目):由 Leader 发起,用来复制日志和提供心跳机制。

服务器之间通信时会交换当前任期号,若服务器当前的任期号比其他的小,该服务器会将自己的任期号更新为较大的那个值。

  • 如果一个 Candidate 或 Leader 发现自己的任期号过期了,它会立即回到 Follower 状态。
  • 如果一个节点接收到一个包含过期的任期号的请求,它会直接拒绝这个请求。

3 领导者选举

Raft 内部有一种心跳机制,若存在 Leader,它会周期性地向所有 Follower 发送心跳,来维持自己的地位。

image-20230812005324355

任一节点启动时都处于 Follower 状态,若 Follower 一段时间没有收到心跳,则他会认为系统中已没有可用的 Leader ,然后开始选举。

开始一个选举过程后,Follower 会先增加自己的当前任期号,并转换到 Candidate 状态。然后投票给自己,并且并行地向集群中的其他服务器节点发送投票请求 (RequestVote RPC)。最终会有三种结果:

  1. 获得超过半数选票赢得选举 ==> 成为主并开始发送心跳;
  2. 其他节点赢得选举 ==> 收到新 Leader 的心跳后,若新 Leader 的任期号 ≥ 自己当前的任期号,则从 Candidate 回到 Follower 状态;
  3. 一段时间之后没有任何获胜者 ==> 每个 Candidate 都在一个自己的随机选举超时时间后增加任期号开始新一轮投票。

为什么会没有获胜者?比如有多个 Follower 同时成为 Candidate,得票太过分散,没有任何一个 Candidate 得票超过半数。

论文中给出的随机选举超时时间为 150~30ms。PS:这个时间对于目前的一些 OLTP 系统而言是较长的,毕竟论文比较老了,所以若要在选在的系统中应用 raft 算法,这个随机选举超时时间还需根据自身网络和服务器性能进行调整。

请求投票 (RequestVote RPC) 中的具体内容:

image-20230812011210248

RequestVote RPC 分为 request 和 response,request 由 Candidate 发起,response 由 Follower 回复 Candidate。

request 和 response 都要有当前任期号,因为 Raft 节点要通过任期号来确定自身状态以及判断接不接收该 RPC。

Raft 论文中将 request 称为 arguments,response 称为 results,这里为了理解直观,直接称 request 和 response。

没有成为 Candidate 的 Follower,在一个任期内,按照先来先得的原则投选票,每个 Follower 最多只能投出一票。投票规则

  1. 请求投票 RPC 的 term ≥ 投票者的 term。(否则说明是旧消息,拒绝该请求)
  2. Candidate 的日志至少和投票者的日志一样新:lastLogTerm > 投票者最后日志的 Term,若相同则 lastLogIndex > 投票者最后日志的 Index。(安全性保证)

4 日志复制

Leader 被选举出来后,可以为客户端请求提供服务。客户端如何知道新 Leader 是那个节点呢?客户端可随机向一个节点 or 向老 Leader 节点发送请求。这时有三种情况:(还有如设置第三方节点的做法等)

  1. 此节点正好为 Leader 节点,则可直接提供服务;
  2. 此节点为 Follower 节点,它能通过心跳得知 Leader 的 ID,就会告诉 Client 该找谁;
  3. 此节点宕机,没有响应,则 Client 要再向其他节点发送请求。

Leader 接收到客户端的指令后,会把指令作为一个新的条目追加到日志中去。日志需包含:

  • 状态机指令:通常是指具体的操作。
  • Leader 的任期号:用于检测多个日志副本间是否一致,判断节点状态。
  • 日志号 (日志索引):日志的唯一标号,是单调递增的。

Leader 宕机可能导致日志号相同,但日志内容不同的情况,因此一个日志要由 任期号 + 日志号 才能唯一确定。

Leader 生成日志后,会将日志放到 AppendEntries RPC 中,并行发送给 Follower,让他们复制该条目。当 Leader 确认该条目被超过半数的 Follower 复制后,Leader 就可以在本地执行该指令 (即应用日志到状态机) 并把结果返回客户端。

Leader 确定将日志应用到状态机是安全的,即已确定日志已被复制到超半数服务器上,就认为该日志可 提交。❗️日志被超过半数的 Follower 复制后不一定就会提交。因为从 Follower 复制完成,到 Follower 通知 Leader,是需要时间的;若此时 Leader 宕机,则该日志虽然复制到了超过半数的 Follower 节点,但没能提交。—— 安全性要考虑的问题

image-20230813031312761

?这是一个直观的复制状态机的实现方法。若所有服务器都不出错且网络正常,该机制能保证所有节点具备完整且正确的日志 (即可用)。但分布式系统中,故障是常见现象,在此过程中,Leader 或 Follower 随时都有崩溃 or 缓慢的可能性,Raft 必须要在有宕机的情况下继续支持日志复制,并且保证每个副本日志顺序的一致 (以保证复制状态机的实现)。具体有三种可能:

  1. Follower 缓慢:Follower 因为某些原因没有给 Leader 响应。则 Leader 会不断重发追加条目请求 (AppendEntries RPC),哪怕 Leader 已经回复了客户端
  2. Follower 宕机:Follower 宕机后恢复。此时 Raft 追加条目的一致性检查生效,保证 Follower 能按顺序恢复宕机后缺失的日志。
    • Leader 会为每个 Follower 维护一个 nextIndex,它是 Leader 将发送给该 Follower 的下一个日志条目的日志索引。Leader 第一次当选时,会将所有的 nextIndex 值初始化为其最后一个日志的后一个索引 (上图中为 9)。
    • Raft 的一致性检查:Leader 在每个发往 Follower 的 AppendEntries RPC 中,会放入当前日志条目的前一个日志的任期号和日志号。若 Follower 在它的日志中找不到前一个日志,则它会拒绝此日志。Leader 收到 Follower 的拒绝后,会递减 nextIndex 并重试 AppendEntries RPC,从而逐渐向前定位到 Follower 第一个缺失的日志,并按顺序补齐 Follower 缺失的所有日志。
    • 该协议也通过优化来减少被拒绝的 AppendEntries RPC 个数。如 Follower 拒绝一个 AppendEntries RPC 请求时,将冲突条目的任期号和自己存储的那个任期的第一个 index 也发给 Leader,则 Leader 可减小 nextIndex 来跳过那个任期内所有冲突的日志条目。这样就变成每个有冲突日志条目的任期需要一个 AppendEntries RPC 而不是每个条目一次。【在实践中,认为该优化没有必要,因为失败不经常发生并且也不可能有很多不一致的日志条目】
    • ❗️Follower 缓慢 ? Follower 宕机:Follower 宕机期间可能经历多次选举导致 Leader 换了好多个,且 Follower 恢复后的状态也是未知的,此时 Leader 并不知道它在宕机前日志复制到哪里了。
  3. Leader 宕机:则宕机的 Leader 可能已经复制了日志到部分 Follower 但还没有提交,而新选出的 Leader 又可能不具备这些日志;宕机的 Leader 恢复后变为 Follower,也可能多出新 Leader 没有的日志。这些都导致部分 Follower 中的日志和新 Leader 的日志不相同。在 Follower 收到新 Leader 的 AppendEntries RPC 进行一致性检查并逐步向前回溯的过程中,会发现最后的几个日志和 Leader 不一致的问题。Leader 会通过一致性检查找到 Follower 最后一个和自己一致的日志,然后强制 Follower 复制它的日志来解决不一致的问题,这意味着 Follower 中跟新 Leader 冲突的日志条目会被新 Leader 的日志条目覆盖 (因为没有提交,Client 会认为这些日志直接是失败的,所以不违背外部一致性)。
    • 投票选举阶段不考虑未提交的日志
image-20230813030735246

通过这种机制,Leader 在当权后无需任何特殊的操作来使日志恢复到一致状态。Leader 只需要进行正常操作,按规则一直发 AppendEntries RPC,然后日志就能在 Follower 回复 AppendEntries RPC 一致性检查失败时自动趋于一致。

Leader 从来不会覆盖或者删除自己的日志条目。(Append-Only)

这样的日志复制机制,就可以保证以下一致性特性:

  • 只要过半的服务器能正常运行,Raft 就能够接受、复制并应用新的日志条目。(因为可正常选出 Leader 并提交日志)
  • 在正常情况下,新的日志条目可以在一个 RPC 来回中被复制给集群中的过半机器。
  • 单个运行缓慢的 Follower 不会影响整体的性能。(它慢慢追就行,其他结点不用等)

追加条目 (AppendEntries RPC) 中的具体内容:

image-20230813032834470

request 中:

  • leaderId: 告知 Follower 自己是谁。
  • prevLogIndex, prevLogTerm: 进行一致性检查,只有这两个都和 Follower 中的相同,Follower 才会因为日志是一致的 (若只有日志号相同,任期号不同,如上图中 f 的情况,仍需向前回溯)。
  • entries: 日志体,也就是状态机指令内容。
  • leaderCommit: 对于 Follower 来说,接收到 Leader 的日志并不能立即提交,因为此时还未确认该日志是否已被复制到大多数节点。只有 Leader 确认了日志已被复制到大多数节点后,Leader 才会提交该日志,然后在 AppendEntries RPC 中将此提交信息通过 leaderCommit 告知 Follower (新日志 or 心跳),Follower 此时才能把自己复制单位提交的日志提交。
    • 对于 Follower 缓慢的情况,leaderCommit > commitIndex,此时它的所有日志都是可以提交的,将 commitIndex 设为 min(leaderCommit, index of last new entry)

response 中 success 只有在 request 的 term ≥ 自身 term,且通过一致性检查后,才返回 true。

5安全性

领导者选举和日志复制两个子问题实际上已经涵盖了共识算法的全程,但这两点还不能完全保证每一个状态机会按照相同的顺序执行相同的命令。所以 Raft 通过几个补充规则完善整个算法,使算法可以在各类宕机问题下都不出错。

5.1 Leader 宕机处理

选举限制

如果一个 Follower 落后了 Leader 若干条日志 (但没有漏一整个任期),那么下次选举中,若只比较 Follower 的任期号,它依旧有可能当选 Leader。它在当选新 Leader 后就永远无法补上之前缺失的那部分日志,从而造成状态机之间的不一致。
所以需要领导者选举要增加一个限制,保证被选出来的 Leader 一定包含了之前各任期的所有被提交的日志条目

RequestVote RPC 执行了这样的限制:RPC 中包含了 Candidate 的日志信息,若投票者自己的日志比 Candidate 的日志还新,它会拒绝掉该投票请求。Raft 比较两份日志中最后一条日志条目的日志索引值和任期号判断谁的日志更新。

  • 若最后日志条目的任期号不同,任期号大的更新。
  • 若最后日志条目的任期号相同,日志索引值大的更新。

此时若 Candidate 能获得超半数的选票变为 Leader,那它的日志至少和超半数的投票者的日志一样新。而之前任期的所有被提交的日志条目,肯定是已被超半数结点复制的。所以成为 Leader 的结点,至少有一个包含了所有被提交的日志给他投了票,Leader 的日志比它更新,这样就能保证选出的 Leader 包含之前任期的所有被提交的日志条目。

提交规则

  1. 如果某个 Leader 在提交某个日志条目之前崩溃了,以后的 Leader 会试图完成该日志条目的复制。而不是提交,不能通过心跳提交老日志。
  2. Raft 永远不会通过计算副本数目的方式来提交之前任期内的日志条目。只有 Leader 当前任期内的日志条目才通过计算副本数目的方式来提交。
  3. 一旦当前任期的某个日志条目被提交,那么由于日志匹配特性,之前的所有日志条目也都会被间接地提交。
image-20230813100942258

若当前任期前的日志已被复制到大多数节点,此时顺势提交该日志肯定是最高效的。但为了防止出现 (d) 的问题,可采用 no-op 补丁。

5.2 Follwer & Candidate 宕机处理

Follower 和 Candidate 宕机的处理方式相同。

  • 若它们宕机,则后续发送给他们的 RequestVote 和 AppendEntries RPCs 都会失败。Raft 通过无限的重试来处理这种失败。如果宕机的机器重启了,那么这些 RPC 就会成功地完成。
  • 如果一个服务器在完成了一个 RPC,但是还没有响应时宕机了,那么它重启后会再次收到同样的请求。(Raft 的RPC都是幂等的)

幂等性是指对于一个操作而言,无论这个操作执行多少次,都不会改变系统的状态。

5.3 时间与可用性限制

Raft算法整体不依赖客观时间,也就是说,哪怕因为网络或其他因素,造成后发的 RPC 先到,也不会影响 Raft 的正确性。(这点和Spaner不同)

只要整个系统满足下面的时间要求,Raft 就可以选举出并维持一个稳定的 leader:广播时间(broadcastTime) << 选举超时间(elctionTimeout) << 平均故障时间(MTBF)

  • 广播时间为一次网络来回时间,若广播时间大于选举超时时间,则 Candidate 等到选票前就因超时开始下一轮选举了,永远也等不到选票。
  • 广播时间和平均故障时间是由系统决定的,但是选举超时间是我们自己选择的。Raft 的 RPC 需要接受并将信息落盘,所以广播时间大约是 0.5ms 到 20ms,取决于存储的技术。因此,选举超时间可能需要在 10ms 到 50ms 之间。大多数服务器的平均故障间隔时间都在几个月甚至更长。

集群成员变更

在需要改变集群配置的时候 (如增减节点、替换宕机的机器、改变复制的程度),Raft 可以进行配置变更自动化。
自动化配置变更机制最大的难点是保证转换过程中不会出现同一任期的两个 Leader (脑裂问题),因为转换期间整个集群可能划分为两个独立的大多数。

image-20230813113205594

Raft提供了下面两种动态更改集群成员的方式:

  • 单节点成员变更:One Server ConfChange【扩展部分介绍】
  • 多节点联合共识:Joint Consensus

多节点的联合共识算法,采用两阶段提交的思想,集群先切换到一个过渡的配置 (联合共识),再切换到新配置。配置信息作为一个日志体,包装为一个普通的 AppendEntries RPC。主要步骤:

  1. Leader 收到 \(C_{new}\) 的成员变更请求,创建一个 \(C_{old,new}\) 的 ConfChange 日志,马上应用该配置作为节点当前配置。然后将该日志的 AppendEntries RPC 发送给所有 Follower,收到该日志的节点也马上应用该配置。
  2. \(C_{old,new}\) 日志复制到大多数节点并提交后,创建一个 \(C_{new}\) 的 ConfChange 日志,马上应用该配置。然后将该日志的 AppendEntries RPC 发送给所有 Follower,收到该日志的节点也马上应用该配置。
  3. \(C_{new}\) 日志复制到大多数节点并提交后,就完成了配置的变更。

\(C_{old,new}\) 配置指 \(C_{old}\)\(C_{new}\) 的联合配置,所有请求投票 RPC 要在新旧两个配置中都达到大多数才算成功。

结合下图,我们可以走一遍整个集群的变更过程,在多点联合共识的规则之下,每一个任期之中不会出现两个 Leader。

image-20230813115302869
  1. \(C_{old,new}\) 日志提交前:此阶段, \(C_{old,new}\) 中的所有节点有可能处于 \(C_{old}\) 的配置下,也有可能处于 \(C_{old,new}\) 的配置下,如果这时原 Leader 宕机了,无论发起新一轮投票的节点当前的配置是 \(C_{old}\) 还是 \(C_{old,new}\),都需要 \(C_{old}\) 的节点同意投票,所以不会出现两个 Leader。也就是 old 节点不可能同时 follow 两个 Leader。
  2. \(C_{old,new}\) 提交后,\(C_{new}\) 发起前:此时所有 \(C_{old,new}\) 的配置已经在 \(C_{old}\)\(C_{new}\) 的大多数节点上,如果集群中的节点超时,那么肯定只有有 \(C_{old,new}\) 配置的节点才能成为 Leader,所以不会出现两个 Leader。
  3. \(C_{new}\) 发起后提交前:此时集群中的节点可能有三种,\(C_{old}\) 的节点 (可能一直没有收到请求)、\(C_{old,new}\) 的节点、\(C_{new}\) 的节点。其中 \(C_{old}\) 的节点因为没有最新的日志,集群中的大多数节点是不会给他投票的;剩下的持有 \(C_{new}\)\(C_{old,new}\) 的节点,无论是谁发起选举,都需要 \(C_{new}\) 同意,那么也是不会出现两个 Leader。
  4. \(C_{new}\) 提交后:此时集群处于 \(C_{new}\) 配置下运行,只有 \(C_{new}\) 的节点才可以成为 Leader,已完成配置的变更。

集群成员变更的三个补充规则:

  1. 新增节点时,需要等新增的节点完成日志同步后再开始集群成员变更。
    • 这点是防止集群在新增节点还未同步日志时就进入联合一致状态或新配置状态,影响正常命令日志提交。
    • 也即让新增节点在完成日志同步前是个只读的状态 (不具有投票权,也不参与日志计数)。
  2. 缩减节点时,Leader 本身可能就是要缩减的节点,这时它会在完成 \(C_{new}\) 的提交后自动退位。
    • 在发起 \(C_{new}\) 后,要退出集群的 Leader 就会处在操纵一个不包含它本身的 Raft 集群的状态下。这时它可以发送 \(C_{new}\) 日志,但是日志计数时不计自身。
  3. 为避免下线的节点超时选举而影响集群运行,服务器会在它确信集群中有 Leader 存在时拒绝 RequestVote RPC。
    • 因为 \(C_{new}\) 的新 Leader 不会再发送心跳给要退出的节点,如果这些节点没有及时下线,它们会超时增加任期号后发送RequestVote RPC。虽然它们不可能当选 Leader,但会导致 Raft 集群进入投票选举阶段,影响集群的正常运行。
    • 所以在 RequestVote RPC上补充规则:若一个节点在最小超时实间内收到 RequestVote RPC,就拒绝此 RPC。这样只要 Follower 能连续收到 Leader 的心跳,退出集群节点的 RequestVote RPC 就不会影响到raft集群的正常运行。

7 总结与扩展

7.1 深入理解复制状态机

image-20230813121911779

把复制状态机需要同步的数据量按大小进行分类,它们分别适合不同类型的共识算法。

  1. 数据量非常小,如集群成员信息、配置文件、分布式锁、小容量分布式任务队列。外部命令较稀疏,集群内部的信息科通过比日志粒度更小的一个个命令传递,无需设置一个 Leader 来支持快速的写入与同步。
    ==> 无 Leader 的共识算法 (如 Basic Paxos)。
  2. 数据量比较大但可以拆分为不相干的各部分,如大规模存储系统
    ==> 有 Leader 的共识算法 (如 Multi Paxos,Raft),实现有 GFS,HDFS 等。
  3. 不仅数据量大,数据之间还存在关联。一个共识算法集群容纳不了所有的数据。这种情况下,就要把数据分片 (partition) 到多个状态机中,状态机之间通过两阶段提交来保证一致性。如 Spanner、OceanBase、TiDB等支持分布式事务的分布式数据库
    ==> 通常会对 Paxos 或 Raft 等共识算法进行一定的改造,来满足事务级的要求。

7.2 Raft 算法主要过程

Raft 算法中,服务器节点之间使用 RPC 进行通信,并且 Raft 中只有两种主要的 RPC:

  • RequestVote RPC (请求投票):由 Candidate 在选举期间发起。
  • AppendEntries RPC (追加条目):由 Leader 发起,用来复制日志和提供心跳机制。

服务器之间通信时会交换当前任期号,若服务器当前的任期号比其他的小,该服务器会将自己的任期号更新为较大的那个值。

  • 如果一个 Candidate 或 Leader 发现自己的任期号过期了,它会立即回到 Follower 状态。
  • 如果一个节点接收到一个包含过期的任期号的请求,它会直接拒绝这个请求。

领导者选举

任一节点启动时都处于 Follower 状态,若 Follower 一段时间没有收到心跳,则他会认为系统中已没有可用的 Leader ,然后开始选举。

开始一个选举过程后,Follower 会先增加自己的当前任期号,并转换到 Candidate 状态。然后投票给自己,并且并行地向集群中的其他服务器节点发送投票请求 (RequestVote RPC)。最终会有三种结果:

  1. 获得超过半数选票赢得选举 ==> 成为主并开始发送心跳;
  2. 其他节点赢得选举 ==> 收到新 Leader 的心跳后,若新 Leader 的任期号 ≥ 自己当前的任期号,则从 Candidate 回到 Follower 状态;
  3. 一段时间之后没有任何获胜者 ==> 每个 Candidate 都在一个自己的随机选举超时时间后增加任期号开始新一轮投票。

请求投票 (RequestVote RPC) 中的具体内容:

image-20230812011210248

RequestVote RPC 分为 request 和 response,request 由 Candidate 发起,response 由 Follower 回复 Candidate。

没有成为 Candidate 的 Follower,在一个任期内,按照先来先得的原则投选票,每个 Follower 最多只能投出一票。投票规则

  1. 请求投票 RPC 的 term ≥ 投票者的 term。(否则说明是旧消息,拒绝该请求)
  2. Candidate 的日志至少和投票者的日志一样新:lastLogTerm > 投票者最后日志的 Term,若相同则 lastLogIndex > 投票者最后日志的 Index。(安全性保证)

选举成功的 Leader 会周期性地向所有 Follower 发送心跳,来维持自己的地位。心跳是一种特殊的 AppendEntries RPC,日志体 entries 为空。

日志复制

Leader 对于自己生成的日志的处理:

Leader 收到 Client 请求,会生成对应日志并将日志放到 AppendEntries RPC 中,并行发送给 Follower,让他们复制该条目。当 Leader 确认该条目被超过半数的 Follower 复制后,Leader 就可在本地执行该指令 (即应用日志到状态机) 并把结果返回 Client。

若 Follower 因为某些原因没有给 Leader 响应。则 Leader 会不断重发追加条目请求 (AppendEntries RPC),哪怕 Leader 已经回复了客户端

追加条目 (AppendEntries RPC) 中的具体内容:

image-20230813032834470

Follower 接收到请求后,进行如下判断:

  1. 追加条目 RPC 的 term ≥ 自身的 term。
  2. 进行一致性检查:比较 prevLogIndex, prevLogTerm 和 Follower 当前的最后日志,若一致,则就可以从此处更新日志,把所有的日志写入自己的日志列表中。否则,说明 Follower 上相同位置的数据和 Leader 不一致 (冲突),此时 Leader 会减小nextIndex 的值重试,一直找到 Follower 上两者一致的位置,然后从这个位置开始复制 Leader 的数据给 Follower,同时 Follower 后续已有的数据会被清空。

response 中 success 只有在 request 的 term ≥ 自身 term,且通过一致性检查后,才返回 true。


Leader 对于之前任期内的日志的处理:

新选举得到的 Leader 会试图完成之前任期内日志条目的复制,但不能通过计算副本数目的方式来提交之前任期内的日志条目。只有 Leader 当前任期内的日志条目才通过计算副本数目的方式来提交。

一旦当前任期的某个日志条目被提交,那么由于日志匹配特性,之前的所有日志条目也都会被间接地提交。

7.3 Raft 扩展

no-op 补丁

一个节点当选 Leader 后,立刻发送一个自己当前任期的空日志体的 AppendEntries RPC。这样,就能把之前任期内满足提交条件的日志都提交了。因为没有日志体,这个过程应该是很快的。

目前大部分应用于生产系统的 Raft 算法,都是启用 no-op 的。

集群变更扩展

多节点联合共识的集群成员变更扩展较复杂,在Diego Ongaro的博士论文,和后续的大部分对raft实现中,都使用的是另一种更简单的单节点并更方法,即一次只增减一个节点,称为单节点集群成员变更

每次只增减一个节点,相比于多节点变更,最大的差异是新旧配置集群的大多数,是一定会有重合的。也就说是在同一个任期中,\(C_{old}\)\(C_{new}\) 中交集的那一个节点只会进行一次投票,要么投票给 \(C_{old}\),要么投票给 \(C_{new}\),这样就避免了同一任期下出现两个 Leader。

image-20230813124526384

变更的流程如下:

  1. 向 Leader 提交一个成员变更请求,请求的内容为服务节点的是添加还是移除,以及服务节点的地址信息。
  2. Leader 在收到请求以后,创建一个 \(C_{new}\) 的 ConfChange 日志,马上应用该配置。然后将该日志的 AppendEntries RPC 发送给所有 Follower,收到该日志的节点也马上应用该配置。
  3. \(C_{new}\) 日志复制到大多数节点并提交后,就完成了配置的变更。

以上就是整个单节点的变更流程,在日志被提交以后,那么就可以:

  1. 马上响应客户端,变更已经完成。
  2. 如果变更过程中移除了服务器,那么服务器可以关机了。
  3. 可以开始下一轮的成员变更了,注意在上一次变更没有结束之前,是不允许开始下一次变更的。

单节点集群成员变更十分简单,但却存在以下缺陷

  1. 联合共识支持一步完成机器的替换,比如我们可以通过联合共识的方法把原来集群的 (a, b, c) 三台机器替换为 (d, b, c) 三台机器。但使用单节点变更就只能由 (a, b, c) 替换为 (a, b, c, d) 再替换为 (d, b, c),需要两步。
  2. 单节点变更过程必然经历偶数节点的状态,这会降低集群的高可用性。

日志压缩

在实际的系统中,不能让日志无限增长,否则系统重启时需要花很长的时间进行回放,从而影响可用性。Raft采用对整个系统定期进行 snapshot 来解决,snapshot 之前的日志都可以丢弃。每个副本独立的对自己的系统状态进行 snapshot,并且只能对已经提交的日志记录进行snapshot。

image-20230813130802400

Snapshot中包含以下内容:

  • 日志元数据。最后一条已提交的日志条目的 log index 和 term。这两个值在 snapshot 之后的第一条日志条目的AppendEntries RPC 的一致性检查时会被用上。
  • 系统当前状态。上面的例子中,x为 0,y为 9。

若否个 Follower 的日志落后太多,Leader 要发给它的日志条目已被丢弃,Leader 会将 snapshot 发给 Follower。或者当新加进一台机器时,也会发送 snapshot 给它。

snapshot 既不要做的太频繁,否则消耗磁盘带宽;也不要做的太不频繁,否则一旦节点重启需要回放大量日志,影响可用性。推荐当日志达到某个固定的大小做一次 snapshot。

只读操作处理

线性一致性读的定义:读到的结果要是读请求发起时已经完成提交的结果 (当时的快照)。

直观讲,Raft 的读只要直接读取 Leader 上的结果就行。但直接从 Leader 的状态机取值,实际上并不是线性一致性读 (也称作强一致性读)。? 在 Leader 和其他节点发生了网络分区情况下,其他节点可能已经重新选出了一个 Leader,而如果老 Leader 在没有访问其他节点的情况下直接拿自身的值返回客户端,这个读取的结果就有可能不是最新的。

image-20230813132446135

要追求线性一致性读的话,就需要让这个读的过程或结果,也在大多数节点上达到共识

最简单的方法:把读也当做一个 log,由 Leader 发到所有的所有节点上寻求共识,这个读的 log 提交后,再返回 Client 得到的结果是一定符合线性一致性的。

但这种代价太大了,优化后的方法

  1. 线性一致性读一定要发往 Leader;若发往 Follower,也要转发给 Leader。
  2. 若该 Leader 在它的任期内还没有提交一个日志,那么它要在提交了一个日志后才能反馈 Client 读请求。(可通过 no-op 补丁来优化这一点)
    • 因为只有在自己任期内提交了一个日志,Leader 才能确认之前任期的哪些日志已被提交,才不会出现已提交的数据读取不到的情况。
      image-20230813133819130
  3. Leader 进行读操作前,要向所有节点发送心跳,并得到大多数节点的反馈。(为确保自己仍是 Leader,避免网络分区问题)
  4. Leader 把自己已提交的日志号设为 readIndex,只要 Leader 应用到了 readInex 位置的日志,就可以查询状态机结果并返回 Client。

优化过后的线性一致性读,也至少需要一轮 RPC (Leader 确认的心跳),并不比写操作快多少 (写操作最少也就一轮 RPC)。

所以,还可以更进一步,因为读的这轮 RPC 仅是为了确认集群中没有新 Leader 产生。那么如果 Leader上一次心跳发送的时间还不到选举超时间的下界,集群就不会选出一个新 Leader,那么这段时间就可以不经过这轮心跳确认,直接返回读的结果。
(这种方法不安全,不建议使用。分布式系统因为时钟偏移,full GC 之类的情况,通常认为时钟是不可靠的。在追求线性一致性读的情况下,尽量不要节省这个 RPC 的时间)


大部分系统对读要求没那么高,一般只要求避免脏读就行,那怎么为弱一致性读实现更高的读性能呢?A:读 Follower。因为 Follower 可能落后 Leader 若干日志,所以可能读不到最新的数据,所以是弱一致性读。

也可通过 Follower 进行线性一致性读,需满足以下条件,且 Leader 正常:(实际上,这么做的代价通常大于收益)

  1. Follower 接收到读请求后,向 Leader 请求 readIndex。
  2. Follower 等待自身状态机应用日志到 readIndex。
  3. Follower 查询状态机结果,并返回客户端。

参考链接

宝藏 UP 戌米的论文笔记

一文彻底搞懂 Raft 算法

Raft 论文翻译

Raft 动画在线演示