Raft 算法原理详解 - 今日头条

本文由 简悦 SimpRead 转码, 原文地址 www.toutiao.com

作者: codedump 来源: https://www.codedump.

作者: codedump

来源 :https://www.codedump.info/post/20180921-raft/

关于 Raft 算法,有两篇经典的论文,一篇是《In search of an Understandable Consensus Algorithm》,这是作者最开始讲述 Raft 算法原理的论文,但是这篇论文太简单了,很多算法的细节没有涉及到。更详细的论文是《CONSENSUS: BRIDGING THEORY AND PRACTICE》,除了包括第一篇论文的内容以外,还加上了很多细节的描述。在我阅读完 etcd raft 算法库的实现之后,发现这个库的代码基本就是按照后一篇论文来写的,甚至有部分测试用例的注释里也写明了是针对这篇论文的某一个小节的情况做验证。

这篇文章做为我后续分析 etcd raft 算法的前导文章,将结合后一篇论文加上一些自己的演绎和理解来讲解 Raft 算法的原理。

Raft 算法由 leader 节点来处理一致性问题。leader 节点接收来自客户端的请求日志数据,然后同步到集群中其它节点进行复制,当日志已经同步到超过半数以上节点的时候,leader 节点再通知集群中其它节点哪些日志已经被复制成功,可以提交到 raft 状态机中执行。

通过以上方式,Raft 算法将要解决的一致性问题分为了以下几个子问题。

· leader 选举:集群中必须存在一个 leader 节点。

· 日志复制:leader 节点接收来自客户端的请求然后将这些请求序列化成日志数据再同步到集群中其它节点。

· 安全性:如果某个节点已经将一条提交过的数据输入 raft 状态机执行了,那么其它节点不可能再将相同索引 的另一条日志数据输入到 raft 状态机中执行。

Raft 算法需要一直保持的三个属性。

· 选举安全性(Election Safety):在一个任期内只能存在最多一个 leader 节点。

· Leader 节点上的日志为只添加(Leader Append-Only):leader 节点永远不会删除或者覆盖本节点上面的日志数据。

· 日志匹配性(Log Matching):如果两个节点上的日志,在日志的某个索引上的日志数据其对应的任期号相同,那么在两个节点在这条日志之前的日志数据完全匹配。

· leader 完备性(Leader Completeness):如果一条日志在某个任期被提交,那么这条日志数据在 leader 节点上更高任期号的日志数据中都存在。

· 状态机安全性(State Machine Safety):如果某个节点已经将一条提交过的数据输入 raft 状态机执行了,那么其它节点不可能再将相同索引的另一条日志数据输入到 raft 状态机中执行。

在 Raft 算法中,一个集群里面的所有节点有以下三种状态:

· Leader:领导者,一个集群里只能存在一个 Leader。

· Follower:跟随者,follower 是被动的,一个客户端的修改数据请求如果发送到 Follower 上面时,会首先由 Follower 重定向到 Leader 上,

· Candidate:参与者,一个节点切换到这个状态时,将开始进行一次新的选举。

每一次开始一次新的选举时,称为一个 “任期”。每个任期都有一个对应的整数与之关联,称为 “任期号”,任期号用单词 “Term” 表示,这个值是一个严格递增的整数值。

节点的状态切换状态机如下图所示。

https://p26.toutiaoimg.com/origin/pgc-image/4e943c98002b4848bfd134e57a469d6d?from=pc

上图中标记了状态切换的 6 种路径,下面做一个简单介绍,后续都会展开来详细讨论。

  1. start up:起始状态,节点刚启动的时候自动进入的是 follower 状态。
  2. times out, starts election:follower 在启动之后,将开启一个选举超时的定时器,当这个定时器到期时,将切换到 candidate 状态发起选举。
  3. times out, new election:进入 candidate 状态之后就开始进行选举,但是如果在下一次选举超时到来之前,都还没有选出一个新的 leade,那么还会保持在 candidate 状态重新开始一次新的选举。
  4. receives votes from majority of servers:当 candidate 状态的节点,收到了超过半数的节点选票,那么将切换状态成为新的 leader。
  5. discovers current leader or new term:candidate 状态的节点,如果收到了来自 leader 的消息,或者更高任期号的消息,都表示已经有 leader 了,将切换回到 follower 状态。
  6. discovers server with higher term:leader 状态下如果收到来自更高任期号的消息,将切换到 follower 状态。这种情况大多数发生在有网络分区的状态下。

如果一个 candidate 在一次选举中赢得 leader,那么这个节点将在这个任期中担任 leader 的角色。但并不是每个任期号都一定对应有一个 leader 的,比如上面的情况 3 中,可能在选举超时到来之前都没有产生一个新的 leader,那么此时将递增任期号开始一次新的选举。

从以上的描述可以看出,任期号在 raft 算法中更像一个 “逻辑时钟(logic clock)” 的作用,有了这个值,集群可以发现有哪些节点的状态已经过期了。每一个节点状态中都保存一个当前任期号(current term),节点在进行通信时都会带上本节点的当前任期号。如果一个节点的当前任期号小于其他节点的当前任期号,将更新其当前任期号到最新的任期号。如果一个 candidate 或者 leader 状态的节点发现自己的当前任期号已经小于其他节点了,那么将切换到 follower 状态。反之,如果一个节点收到的消息中带上的发送者的任期号已经过期,将拒绝这个请求。

raft 节点之间通过 RPC 请求来互相通信,主要有以下两类 RPC 请求。RequestVote RPC 用于 candidate 状态的节点进行选举之用,而 AppendEntries RPC 由 leader 节点向其他节点复制日志数据以及同步心跳数据的。

现在来讲解 leader 选举的流程。

raft 算法是使用心跳机制来触发 leader 选举的。

在节点刚开始启动时,初始状态是 follower 状态。一个 follower 状态的节点,只要一直收到来自 leader 或者 candidate 的正确 RPC 消息的话,将一直保持在 follower 状态。leader 节点通过周期性的发送心跳请求(一般使用带有空数据的 AppendEntries RPC 来进行心跳)来维持着 leader 节点状态。每个 follower 同时还有一个选举超时(election timeout)定时器,如果在这个定时器超时之前都没有收到来自 leader 的心跳请求,那么 follower 将认为当前集群中没有 leader 了,将发起一次新的选举。

发起选举时,follower 将递增它的任期号然后切换到 candidate 状态。然后通过向集群中其它节点发送 RequestVote RPC 请求来发起一次新的选举。一个节点将保持在该任期内的 candidate 状态下,直到以下情况之一发生。

· 该 candidate 节点赢得选举,即收到超过半数以上集群中其它节点的投票。

· 另一个节点成为了 leader。

· 选举超时到来时没有任何一个节点成为 leader。

下面来逐个分析以上几种情况。

第一种情况,如果收到了集群中半数以上节点的投票,那么此时 candidate 节点将成为新的 leader。每个节点在一个任期中只能给一个节点投票,而且遵守 “先来后到” 的原则。这样就保证了,每个任期最多只有一个节点会赢得选举成为 leader。但并不是每个进行选举的 candidate 节点都会给它投票,在后续的 “选举安全性” 一节中将展开讨论这个问题。当一个 candidate 节点赢得选举成为 leader 后,它将发送心跳消息给其他节点来宣告它的权威性以阻止其它节点再发起新的选举。

第二种情况,当 candidate 节点等待其他节点时,如果收到了来自其它节点的 AppendEntries RPC 请求,同时做个请求中带上的任期号不比 candidate 节点的小,那么说明集群中已经存在 leader 了,此时 candidate 节点将切换到 follower 状态;但是,如果该 RPC 请求的任期号比 candidate 节点的小,那么将拒绝该 RPC 请求继续保持在 candidate 状态。

第三种情况,一个 candidate 节点在选举超时到来的时候,既没有赢得也没有输掉这次选举。这种情况发生在集群节点数量为偶数个,同时有两个 candidate 节点进行选举,而两个节点获得的选票数量都是一样时。当选举超时到来时,如果集群中还没有一个 leader 存在,那么 candidate 节点将继续递增任期号再次发起一次新的选举。这种情况理论上可以一直无限发生下去。

为了减少第三种情况发生的概率,每个节点的选举超时时间都是随机决定的,一般在 150~300 毫秒之间,这样两个节点同时超时的情况就很罕见了。

以上过程用伪代码来表示如下。

日志复制的流程大体如下:

  1. 每个客户端的请求都会被重定向发送给 leader,这些请求最后都会被输入到 raft 算法状态机中去执行。
  2. leader 在收到这些请求之后,会首先在自己的日志中添加一条新的日志条目。
  3. 在本地添加完日志之后,leader 将向集群中其他节点发送 AppendEntries RPC 请求同步这个日志条目,当这个日志条目被成功复制之后(什么是成功复制,下面会谈到),leader 节点将会将这条日志输入到 raft 状态机中,然后应答客户端。

Raft 日志的组织形式如下图所示。

https://p26.toutiaoimg.com/origin/pgc-image/0966b2f589384a81a872b07c9ebe7741?from=pc

每个日志条目包含以下成员。

  1. index:日志索引号,即图中最上方的数字,是严格递增的。
  2. term:日志任期号,就是在每个日志条目中上方的数字,表示这条日志在哪个任期生成的。
  3. command:日志条目中对数据进行修改的操作。

一条日志如果被 leader 同步到集群中超过半数的节点,那么被称为 “成功复制”,这个日志条目就是 “已被提交(committed)"。如果一条日志已被提交,那么在这条日志之前的所有日志条目也是被提交的,包括之前其他任期内的 leader 提交的日志。如上图中索引为 7 的日志条目之前的所有日志都是已被提交的日志。

以下面的图示来说明日志复制的流程。

https://p26.toutiaoimg.com/origin/pgc-image/2844192fa2624878981aa473c3b14f12?from=pc

在上图中,一个请求有以下步骤。

· 1. 客户端发送 SET a=1 的命令到 leader 节点上。

· 2. leader 节点在本地添加一条日志,其对应的命令为 SET a=1。这里涉及到两个索引值,committedIndex 存储的最后一条提交(commit)日志的索引,appliedIndex 存储的是最后一条应用到状态机中的日志索引值,一条日志只有被提交了才能应用到状态机中,因此总有 committedIndex >= appliedIndex 不等式成立。在这里只是添加一条日志还并没有提交,两个索引值还指向上一条日志。

· 3. leader 节点向集群中其他节点广播 AppendEntries 消息,带上 SET a=1 命令。

https://p26.toutiaoimg.com/origin/pgc-image/caa7537b146e4ad48bc0105442601a4a?from=pc

接下来继续看,上图中经历了以下步骤。

· 4. 收到 AppendEntries 请求的 follower 节点,同样在本地添加了一条新的日志,也还并没有提交。

· 5. follower 节点向 leader 节点应答 AppendEntries 消息。

· 6. 当 leader 节点收到集群半数以上节点的 AppendEntries 请求的应答消息时,认为 SET a=1 命令成功复制,可以进行提交,于是修改了本地 committed 日志的索引指向最新的存储 SET a=1 的日志,而 appliedIndex 还是保持着上一次的值,因为还没有应用该命令到状态机中。

https://p26.toutiaoimg.com/origin/pgc-image/c0e36ef3f0004ec49d33558712115765?from=pc

当这个命令提交完成了之后,命令就可以提交给应用层了。

· 7. 提交命令完成,给应用层说明这条命令已经提交。此时修改 appliedIndex 与 committedIndex 一样了。

· 8. leader 节点在下一次给 follower 的 AppendEntries 请求中,会带上当前最新的 committedIndex 索引值,follower 收到之后同样会修改本地日志的 committedIndex 索引。

需要说明的是,7 和 8 这两个操作并没有严格的先后顺序,谁在前在后都没关系。

leader 上保存着已被提交的最大日志索引信息,在每次向 follower 节点发送的 AppendEntries RPC 请求中都会带上这个索引信息,这样 follower 节点就知道哪个日志已经被提交了,被提交的日志将会输入 Raft 状态机中执行。

Raft 算法保持着以下两个属性,这两个属性共同作用满足前面提到的日志匹配(LogMatch)属性:

· 如果两个日志条目有相同的索引号和任期号,那么这两条日志存储的是同一个指令。

· 如果在两个不同的日志数据中,包含有相同索引和任期号的日志条目,那么在这两个不同的日志中,位于这条日志之前的日志数据是相同的。

在正常的情况下,follower 节点和 leader 节点的日志一直保持一致,此时 AppendEntries RPC 请求将不会失败。但是,当 leader 节点宕机时日志就可能出现不一致的情况,比如在这个 leader 节点宕机之前同步的数据并没有得到超过半数以上节点都复制成功了。如下图所示就是一种出现前后日志不一致的情况。

https://p26.toutiaoimg.com/origin/pgc-image/895a1b92431045e5b9cec06dc564f0cb?from=pc

在上图中,最上面的一排数字是日志的索引,盒子中的数据是该日志对应的任期号,左边的字母表示的是 a-f 这几个不同的节点。图中演示了好几种节点日志与 leader 节点日志不一致的情况,下面说明中以二元组 <任期号,索引号> 来说明各个节点的日志数据情况:

· leader 节点:<6, 10>。

· a 节点:<6,9>,缺少日志。

· b 节点:<4,4>,任期号比 leader 小,因此缺少日志。

· c 节点:<6,11>,任期号与 leader 相同,但是有比 leader 日志索引更大的日志,这部分日志是未提交的日志。

· d 节点:<7,12>,任期号比 leader 大,这部分日志是未提交的日志。

· e 节点:<4,7>,任期号与索引都比 leader 小,因此既缺少日志,也有未提交的日志。

· f 节点:<3,11>,任期号比 leader 小,所以缺少日志,而索引比 leader 大,这部分日志又是未提交的日志。

在 Raft 算法中,解决日志数据不一致的方式是 Leader 节点同步日志数据到 follower 上,覆盖 follower 上与 leader 不一致的数据。

为了解决与 follower 节点同步日志的问题,leader 节点中存储着两个与每个 follower 节点日志相关的数据。

· nextIndex 存储的是下一次给该节点同步日志时的日志索引。

· matchIndex 存储的是该节点的最大日志索引。

从以上两个索引的定义可知,在 follower 与 leader 节点之间日志复制正常的情况下,nextIndex = matchIndex + 1。但是如果出现不一致的情况,则这个等式可能不成立。每个 leader 节点被选举出来时,将初始化 nextIndex 为 leader 节点最后一条日志,而 matchIndex 为 0,这么做的原因在于:leader 节点将从后往前探索 follower 节点当前存储的日志位置,而在不知道 follower 节点日志位置的情况下只能置空 matchIndex 了。

leader 节点通过 AppendEntries 消息来与 follower 之间进行日志同步的,每次给 follower 带过去的日志就是以 nextIndex 来决定,如果 follower 节点的日志与这个值匹配,将返回成功;否则将返回失败,同时带上本节点当前的最大日志 ID,方便 leader 节点快速定位到 follower 的日志位置以下一次同步正确的日志数据,而 leader 节点在收到返回失败的情况下,将置 nextIndex = matchIndex + 1。从上面的分析可知,在 leader 当前之后第一次向 follower 同步日志失败时,nextIndex = matchIndex + 1 = 1。

以上图的几个节点为例来说明情况。

· 初始状态下,leader 节点将存储每个 folower 节点的 nextIndex 为 10,matchIndex 为 0。因此在成为 leader 节点之后首次向 follower 节点同步日志数据时,将复制索引位置在 10 以后的日志数据,同时带上日志二元组 <6,10> 告知 follower 节点当前 leader 保存的 follower 日志状态。

· a 节点:由于节点的最大日志数据二元组是 <6,9>,正好与 leader 发过来的日志 < 6,10 > 紧挨着,因此返回复制成功。

· b 节点:由于节点的最大日志数据二元组是 <4,4>,与 leader 发送过来的日志数据 < 6,10 > 不匹配,将返回失败同时带上自己最后的日志索引 4,leader 节点在收到该拒绝消息之后,将修改保存该节点的 nextIndex 为 matchIndex + 1 即 1,所以下一次 leader 节点将同步从索引 1 到 10 的数据给 b 节点。

· c 节点:由于节点的最大日志数据二元组是 <6,11>,与 leader 发送过来的日志数据 < 6,10 > 不匹配,将返回失败同时带上自己最后的日志索 引 11,leader 节点在收到该拒绝消息之后,将修改保存该节点的 nextIndex 为 matchIndex + 1 即 1,所以下一次 leader 节点将同步从索引 1 到 10 的数据给 c 节点,由于 c 节点上有未提交的数据,所以在第二次与 leader 同步完成之后,这些未提交的数据被清除。

· d 节点:由于节点的最大日志数据二元组是 <7,12>,与 leader 发送过来的日志数据 < 6,10 > 不匹配,将返回失败同时带上自己最后的日志索 引 11,leader 节点在收到该拒绝消息之后,将修改保存该节点的 nextIndex 为 matchIndex + 1 即 1,所以下一次 leader 节点将同步从索引 1 到 10 的数据给 d 节点,由于 d 节点上有未提交的数据,所以在第二次与 leader 同步完成之后,这些未提交的数据被清除。

· e 节点:由于节点的最大日志数据二元组是 <4,7>,与 leader 发送过来的日志数据 < 6,10 > 不匹配,将返回失败同时带上自己最后的日志索 引 11,leader 节点在收到该拒绝消息之后,将修改保存该节点的 nextIndex 为 matchIndex + 1 即 1,所以下一次 leader 节点将同步从索引 1 到 10 的数据给 e 节点,由于 e 节点上缺少的日志数据将被补齐,而多出来的未提交数据将被清除。

· f 节点:由于节点的最大日志数据二元组是 <4,7>,与 leader 发送过来的日志数据 < 6,10 > 不匹配,将返回失败同时带上自己最后的日志索 引 11,leader 节点在收到该拒绝消息之后,将修改保存该节点的 nextIndex 为 matchIndex + 1 即 1,所以下一次 leader 节点将同步从索引 1 到 10 的数据给 f 节点,由于 f 节点上缺少的日志数据将被补齐,而多出来的未提交数据将被清除。

前面章节已经将 leader 选举以及日志同步的机制介绍了,这一小节讲解安全性相关的内容。

raft 算法中,并不是所有节点都能成为 leader。一个节点要成为 leader,需要得到集群中半数以上节点的投票,而一个节点会投票给一个节点,其中一个充分条件是:这个进行选举的节点的日志,比本节点的日志更新。之所以要求这个条件,是为了保证每个当选的节点都有当前最新的数据。为了达到这个检查日志的目的,RequestVote RPC 请求中需要带上参加选举节点的日志信息,如果节点发现选举节点的日志信息并不比自己更新,将拒绝给这个节点投票。

如果判断日志的新旧?这通过对比日志的最后一个日志条目数据来决定,首先将对比条目的任期号,任期号更大的日志数据更新;如果任期号相同,那么索引号更大的数据更新。

以上处理 RequestVote 请求的流程伪代码表示如下。

如果 leader 在写入但是还没有提交一条日志之前崩溃,那么这条没有提交的日志是否能提交?有几种情况需要考虑,如下图所示。

https://p26.toutiaoimg.com/origin/pgc-image/0e22c1a526ea4996abb2cd0d5aa39612?from=pc

在上图中,有以下的场景变更。

· 情况 a:s1 是 leader,index 2 位置写入了数据 2,该值只写在了 s1,s2 上,但是还没有被提交。

· 情况 b: s1 崩溃,s5 成为新的 leader,该节点在 index 2 上面提交了另外一个值 3,但是这个值只写在了 s5 上面,并没有被提交。

· 情况 c: s5 崩溃,s1 重新成为 leader,这一次,index 2 的值 2 写到了集群的大多数节点上。

此时可能存在以下两种情况:

· 情况 d1: s1 崩溃,s5 重新成为 leader(投票给 s5 的是 s4,s2 和 s5 自身),那么 index 2 上的值 3 这一次成功的写入到集群的半数以上节点之上,并成功提交。

· 情况 d2: s1 不崩溃,而是将 index 2 为 2 的值成功提交。

从情况 d 的两种场景可以看出,在 index 2 值为 2,且已经被写入到半数以上节点的情况下,同样存在被新的 leader 覆盖的可能性。

由于以上的原因,对于当前任期之前任期提交的日志,并不通过判断是否已经在半数以上集群节点写入成功来作为能否提交的依据。只有当前 leader 任期内的日志是通过比较写入数量是否超过半数来决定是否可以提交的。

对于任期之前的日志,Raft 采用的方式,是只要提交成功了当前任期的日志,那么在日志之前的日志就认为提交成功了。这也是为什么 etcd-Raft 代码中,在成为 leader 之后,需要再提交一条 dummy 的日志的原因–只要该日志提交成功,leader 上该日志之前的日志就可以提交成功。

在上面描述 Raft 基本算法流程中,都假设集群中的节点是稳定不变的。但是在某些情况下,需要手动改变集群的配置。

安全性是变更集群成员时首先需要考虑到的问题,任何时候都不能出现集群中存在一个以上 leader 的情况。为了避免出现这种情况,每次变更成员时不能一次添加或者修改超过一个节点,集群不能直接切换到新的状态,如下图所示。

https://p26.toutiaoimg.com/origin/pgc-image/31d5b94187e84ccd94015b5994c17c36?from=pc

在上图中,server 1、2、3 组成的是旧集群,server 4、5 是准备新加入集群的节点。注意到如果直接尝试切换到新的状态,在某些时间点里,如图中所示,由于 server 1、2 上的配置还是旧的集群配置,那么可能这两个节点已经选定了一个 leader;而 server 3、4、5 又是新的配置,它们也可能选定了一个 leader,而这两个 leader 不是同一个,这就出现了集群中存在一个以上 leader 的情况了。

反之,下图所示是分别往奇数个以及偶数个集群节点中添加删除单个节点的场景。

https://p26.toutiaoimg.com/origin/pgc-image/34d3022e6c65463ab75fafba2bc2fa9b?from=pc

可以看到,不论旧集群节点数量是奇数还是偶数个,都不会出现同时有两个超过半数以上子集群的存在,也就不可能选出超过一个 leader。

raft 采用将修改集群配置的命令放在日志条目中来处理,这样做的好处是:

· 可以继续沿用原来的 AppendEntries 命令来同步日志数据,只要把修改集群的命令做为一种特殊的命令就可以了。

· 在这个过程中,可以继续处理客户端请求。

添加一个新的节点到集群时,需要考虑一种情况,即新节点可能落后当前集群日志很多的情况,在这种情况下集群出现故障的概率会大大提高,如下图所示。

https://p26.toutiaoimg.com/origin/pgc-image/5a934508485d43c9b0952ed38386ac0c?from=pc

上图中的情况 a 中,s1、s2、s3 是原有的集群节点,这时把节点 s4 添加进来,而 s4 上又什么数据都没有。如果此时 s3 发生故障,在集群中原来有三个节点的情况下,本来可以容忍一个节点的失败的;但是当变成四个节点的集群时,s3 和 s4 同时不可用整个集群就不可用了。

因此 Raft 算法针对这种新添加进来的节点,是如下处理的。

· 添加进来的新节点首先将不加入到集群中,而是等待数据追上集群的进度。

· leader 同步数据给新节点的流程是,划分为多个轮次,每一轮同步一部分数据,而在同步的时候,leader 仍然可以写入新的数据,只要等新的轮次到来继续同步就好。

以下图来说明同步数据的流程。

https://p26.toutiaoimg.com/origin/pgc-image/ff81a09434a645d993c78d65b37c5252?from=pc

如上图中,划分为多个轮次来同步数据。比如,在第一轮同步数据时,leader 的最大数据索引为 10,那么第一轮就同步 10 之前的数据。而在同步第一轮数据的同时,leader 还能继续接收新的数据,假设当同步第一轮完毕时,最大数据索引变成了 20,那么第二轮将继续同步从 10 到 20 的数据。以此类推。

这个同步的轮次并不能一直持续下去,一般会有一个限制的轮次数量,比如最多同步 10 轮。

当需要下线当前集群的 leader 节点时,leader 节点将发出一个变更节点配置的命令,只有在该命令被提交之后,原先的 leader 节点才下线,然后集群会自然有一个节点选举超时而进行新的一轮选举。

如果某个节点在一次配置更新之后,被移出了新的集群,但是这个节点又不知道这个情况,那么按照前面描述的 Raft 算法流程来说,它应该在选举超时之后,将任期号递增 1,发起一次新的选举。虽然最终这个节点不会赢得选举,但是毕竟对集群运行的状态造成了干扰。而且如果这个节点一直不下线,那么上面这个发起新选举的流程就会一直持续下去。

为了解决这个问题,Raft 引入了一个成为 “PreVote” 的流程,在这个流程中,如果一个节点要发起一次新的选举,那么首先会广播给集群中的其它所有节点,询问下当前该节点上的日志是否足以赢下选举。只有在这个 PreVote 阶段赢得超过半数节点肯定的情况下,才真正发起一次新的选举。

然而,PreVote 并不能解决所有的问题,因为很有可能该被移除节点上的日志也是最新的。

由于以上的原因,所以不能完全依靠判断日志的方式来决定是否允许一个节点发起新一轮的选举。

Raft 采用了另一种机制。如果 leader 节点一直保持着与其它节点的心跳消息,那么就认为 leader 节点是存活的,此时不允许发起一轮新的选举。这样 follower 节点处理 RequestVote 请求时,就需要加上判断,除了判断请求进行选举的节点日志是否最新以外,如果当前在一段时间内还收到过来自 leader 节点的心跳消息,那么也不允许发起新的选举。然而这种情况与前面描述的 leader 迁移的情况相悖,在 leader 迁移时是强制要求发起新的选举的,因此 RequestVote 请求的处理还要加上这种情况的判断。

总结来说,RequestVote 请求的处理逻辑大致是这样的。

日志数据如果不进行压缩处理掉的话,会一直增长下去。为此 Raft 使用快照数据来进行日志压缩,比如针对键值 a 的几次操作日志 a=1、删除 a、a=3 最后可以被压缩成为最后的结果数据即 a=3。

快照数据和日志数据的组织形式如下图。

https://p26.toutiaoimg.com/origin/pgc-image/dcb301bb7fd344c481a993863b16fc00?from=pc

在上图中:

· 未压缩日志前,日志数据保存到了 <3,5> 的位置,而在 < 2,3 > 的位置之前的数据都已经进行提交了,所以可以针对这部分数据进行压缩。

· 压缩日志之后,快照文件中存放了几个值:压缩时最后一条日志的二元数据是 <2,3>,而针对 a 的几次操作最后的值为 a=3,b 的值为 2。

前面已经提到过,处理一个命令时,需要经历以下流程:leader 向集群中其它节点广播日志,在日志被超过半数节点应答之后,leader 提交该日志,最后才应答客户端。这样的流程对于一个只读请求而言太久了,而且还涉及到日志落盘的操作,对于只读请求而言这些操作是不必要的。

但是如果不经过上面的流程,leader 节点在收到一个只读请求时就直接将本节点上保存的数据应答客户端,也是不安全的,因为这可能返回已经过期的数据。一方面 leader 节点可能已经发生了变化,只是这个节点并不知道;另一方面可能数据也发生了改变。返回过期的数据不符合一致性要求,因此这样的做法也是不允许的。

Raft 中针对只读请求是这样做处理的。

  1. leader 节点需要有当前已提交日志的信息。在前面提到过不能提交前面任期的日志条目,因此一个新 leader 产生之后,需要提交一条空日志,这样来确保上一个任期内的日志全部提交。
  2. leader 节点保存该只读请求到来时的 commit 日志索引为 readIndex,
  3. leader 需要确认自己当前还是集群的 leader,因为可能会由于有网络分区的原因导致 leader 已经被隔离出集群而不自知。为了达到这个目的,leader 节点将广播一个 heartbeat 心跳消息给集群中其它节点,当收到半数以上节点的应答时,leader 节点知道自己当前还是 leader,同时 readIndex 索引也是当前集群日志提交的最大索引。