Raft协议理解

308 阅读15分钟

CAP与共识算法

相信大家都了解CAP定理,CAP指出对于一个分布式系统来说,不可能同时满足一致性(Consistency),可用性(Availability),分区容错性(Partition tolerance)。

共识算法产生是用于解决复制状态机的问题。复制状态机指的是一组服务器上的状态机经过相同的输入后,最终达到一致的状态。复制状态机用于协调分布式系统的工作,例如kafka使用zookeeper进行选主和存储元信息,bigtable使用chubby的分布式锁功能实现文件操作的原子性。复制状态机的一种实现方式就是复制日志,每个状态机通过相同的顺序执行一组日志中的命令,最终就会达到相同的状态。

image.png

共识算法用来保证复制状态的一致性,即使部分(小于一半)节点失效的情况下,仍能正确的复制日志和执行日志的命令,并将输出返回客户端,从而形成强一致性和高可用的状态机。共识算法的是代表Paxos,也有人说世界上只有一种共识协议,就是Paxos,Raft也是Paxos的一个退化版本。

Raft概述

由于Paxos理解起来比较困难,而且Paxos作者只是描述了Basic Paxos,对 multi-Paxos 并没有一个广泛同意的算法,所以在实现起来会有非常大的差异,甚至最终实现的是一个看着像Paxos,实际上是一个新的未被证明的协议。

所以Raft作者在研究完Paxos后,提出了更容易理解和更容易实现的Raft,甚至通过阅读Raft论文后,就能对着论文实现一个Raft。而Raft在提出后,也经过了工业界的验证,例如etcd,consul和tikv都通过Raft来实现。

Raft会选举出一个leader,leader会负责日志复制和管理,接收到客户端的日志后,leader将日志复制到其它服务器上,其它服务器会在保证安全性的前提下返回结果给leader,在达成共识后这些服务器上的状态机执行日志上的命令。通过这种方式,Raft被拆解成三个子问题:

  • Leader选举
  • 日志复制
  • 安全性

Raft会通过两种RPC请求来进行leader选举,日志复制和心跳维持

  • RequestVote: 用于 candidate 进行选举投票
  • AppendEntries: 用于 leader 向其它节点日志复制以及心跳维护(心跳包的日志数据为空)。

Leader选举

Raft为什么需要选leader。熟悉Paxos的同学应该知道Paxos选出一个值至少需要两个阶段,Prepare和Accept,那么就至少需要两轮RPC才能完成。而Paxos为什么需要Prepare阶段呢,因为Paxos允许多个节点发起提议,需要通过Prepare阶段来学习到已经被选主的提议,否则就有可能覆盖掉已经选中的提议。

那如果只有一个节点发起提议,其实就不需要Prepare阶段,直接可以进入Accept阶段,从而加快达成共识的速度。所以Raft采用选选举出leader,通过leader来发起提议。

节点角色

Raft节点有以下三种状态,任意时刻每个节点都处于三种状态之一

  • Leader:正常的Raft集群中大部分时间内只会有一个leader,其它都是follower,leader负责日志管理和复制,并维护和其它节点间的心跳。
  • Follower:follower负责响应candidate的投票请求和leader的日志复制请求。
  • Candidate:用来选举一个新的leader。 image.png

任期

Raft把时间分割成任意长度的任期,任期用连续的整数标记。每个任期从一个candidate发起选举投票开始,当candidate赢得大多数的选票后,就成为这个任期剩余时间的leader,直到下一个任期开始。Raft保证每个任期内最多只有一个leader,但也有可能任期内选举失败导致没有leader,例如t3。 image.png 任期在Raft充当着逻辑时钟的作用,Raft通过任期来判断一些信息是否已经过期,并通过节点间的任期交换来推进整个集群的逻辑时钟。

选举过程

  1. 一个节点启动时候,会进入follower的角色,并从一个固定的区间设置一个随机的超时时间,每次收到leader发送过来的心跳包会重置超时时间。
  2. 当一个follower的在超时时间内没有收到leader的心跳包,就会将当前的任期号加1,进入candidate的状态,并向集群中的其它节点发送RequestVote来进行leader选举,在选举期间,cadidate可能会有以下三种状态变化:
    • 一个新的leader被选出来,并收到该leader的心跳包,cadidate会转换成follower角色。
    • 选票被多个candidate瓜分,该轮选举没有节点胜出,此时会将任期号加1再次进行选举。
    • 获得过半的选票,赢得选举,candidate会转换成leader角色。
  3. 当一个节点成为leader后,会马上开始向其他节点发送心跳来阻止发起新的选举,以保证自己的leader地位。leader会一直保持自己的地位,除非发现了更大的任期号,复制日志或者发送心跳都有可能从其他节点学习到更大的任期号。

对于同一个任期,每个节点只会投给一个candidate ,并按照先来先服务的原则,要求获得过半投票的规则确保了每个任期最多只有一个candidate会赢得选举成为leader。

由于每个任期都可能出现多个candidate来瓜分选票,理论上可能会出现一直选不到leader的情况。Raft使用随机选举超时时间来确保很少出现选票瓜分的情况,只要保证RPC平均响应时间 << 选举超时间。例如RPC平均响应时间是1-5ms,把超时时间设置为150-300ms,这样每个节点的超时时间差值都远大于RPC平均响应时间,正常情况下只需要一轮RPC就可以选出leader,在第二个follower超时前,已经完成了leader的选举。

日志复制

日志特性

Raft每个日志条目存储一条客户端提交的指令和收到客户端请求的leader的任期号,任期号用来检测多个日志副本之间的不一致情况,并用来判断冲突日志间的新旧。每个日志条目都有一个整数索引值来表明它在日志中的位置。

Raft通过维护以下的日志匹配特性来减少协议整体的复杂度:

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

由于每个任期只有一个leader,而leader在一个索引上只会创建一个日志,从而保证了第一条特性。第二条特性则是在进行日志复制时候,通过检查前一个位置的日志是否一致来保证,这个在复制过程中再详细介绍。

复制过程

当leader被选举出来后,开始处理客户端的请求,客户端的每个请求都会被作为一个条目追加到日志中,然后并行向其它节点发起AppendEntries RPC,让他们把这个条目也加到自己的日志中。leader维护了每个节点的日志复制进度,其它节点的日志赶上leader前会不停的发送。在收到过半节点的返回成功后,leader会提交该日志并在状态机上执行该日志包含的命令,而leader还会在后续的AppendEntries RPC中带上已经提交的日志索引(commitIndex),通知其它节点进行提交。

现在我们再回来看下日志复制怎么保证日志匹配特性的第二点。leader会在每次AppendEntries RPC中带上当前日志日志条目的前一个日志条目任期和索引信息,follower接收到AppendEntries RPC后,会比对前一个索引位置的任期是否一致,如果一致则复制日志条目并返回成功,否则会返回失败给leader,leader收到失败消息后,会将该节点的日志复制进度回退,然后再次尝试发送AppendEntries RPC,直到follower匹配成功,follower上的冲突日志会被删除,然后复制leader发过来的日志,从而保证了日志的一致。

正常情况下,所有日志都是从leader流向follower,不会出现上面的情况,但是如果出现leader切换的场景,就会出现日志不一致的情况。例如一个leader处理了一些客户端的请求,但是在完成提交前崩溃了,一个新的leader被选出来,新leader上面就可能会没有这些没有完成提交的日志,下图简单描述了这种情况。

image.png

  1. 这里Raft集群有3个节点,一开始S1作为任期1的leader处理客户端请求进行日志复制,在索引1和2都正常完成了日志复制,接着又收到两个请求,分别写入3和4的位置。
  2. 在这个时候,S1如果崩溃或者网络分区导致心跳超时,集群进入leader选举,假设S3通过自己和S2获取了过半的选票成为任期2的leader,那么S3开始处理客户端的请求,在索引3和4进行日志复制,此时虽然S1未能正常响应,但是两个节点已经可以完成正常的工作。
  3. 接下来,S1恢复正常,S3崩溃或者网络分区,那么此时S2会通过选举成为leader,在索引位置5进行日志复制时候,发现索引位置4跟S1产生冲突,于是对S1的日志进度进行回退,一直退回到位置3后,发现前面的日志没有冲突了,此时follower删除掉不一致的日志,并复制leader发过来的日志。

通过这种方式,leader在当选后,只要不停的给其它节点发送日志,就可以保证所有节点的日志达到一致的状态,而不需要进行额外的操作。

安全性

选举限制

Raft通过leader管理日志,所有日志都从leader流向follower,那么保证leader日志的完整性呢。正常情况下,所有请求都是通过leader处理,leader日志是完整的,但如果leader宕机后,一个还没有追上leader进度的follower成为leader,就会导致部分已经提交的日志丢失,那日志的完整性就无法保证。而这种情况在Raft中是不可能发生的。

Raft对日志的新旧做了定义,通过比较日志最后一个条目的索引和任期来判断新旧。如果两份日志最后而条目任期不同,那么任期号大的日志更新,否则日志较长的日志更新。分别对应下面两种情况: image.png

Raft通过日志的新旧来限制leader的选举,要求follower在给candidate投票时候,日志至少要跟自己的一样新,否则会拒绝投票,而成为leader需要获取过半的选票,所以leader的日志至少和过半的节点的一样新,从而保证了leader必须包含已经提交的日志。

提交之前任期的日志

在日志复制中我们知道当前任期内一个日志条目被复制到了过半的节点上,leader就知道该日志已经被提交了,但是如果在完成日志提交前leader崩溃了,一个新的leader被选举出来后,会继续对该日志条目进行复制,然而即使之前任期的日志被复制到了过半的节点上,leader也不能认为该日志已经被提交了,因为它可能被未来的leader覆盖。

image.png 这是Raft作者在大论文给出的了例子:

  • 在(b)中,S1在完成索引2的日志条目的提交前崩溃了,S5成为leader,S5成为leader在索引2接受了一个不一样的日志。
  • 然后到(c)后,S5在进行复制前崩溃了,此时S1又成为leader,并在索引3接受了一个日志,在日志复制中讲索引2的日志复制到了S3,那么此时S1,S2和S3在索引2上已经满足了过半节点的要求,但是此时S1不能提交这个日志条目。
  • 如果进入(d1),此时S1再次崩溃,由于S5的日志更新,所以S5可以获取过半的选票成为leader,那么S5就会将其它节点在索引2位置上的日志覆盖,所以在(c)中不能提交索引2的日志。
  • 如果进入(d2),那么S1一直正常运行,最终成功将索引3上的日志复制到过半节点,此时可以提交索引3的日志条目,从而把索引2的日志条目也提交了。

由于上面这种情况的存在,导致Raft并不能简单的计算复制成功的副本数量来提交之前任期的日志条目,只有当前任期的日志才能通过计算副本的方式提交,由于日志匹配特性吗,当前任期日志的提交会间接的把之前的日志也提交。如果担心当前任期一直没有日志导致没法提交之前任期的日志,可以通过提交一个空日志(no-op)的方式进行优化。

读请求处理

Raft的读请求也是通过leader来处理,但是如果仅从leader中读取结果,可能会导致读到一些过期的数据,从而破坏了线性一致性。例如一个leader跟其它节点处于网络分区的状态,一个新的leader已经选举出来并提交了一些日志。

Raft log read

Raft本身就实现了线性一致性,所以可以直接利用Raft的日志特性,每次读操作都走一次Raft的日志复制流程,在完成日志提交后,从状态机读出来的结果必定是满足一致性要求的。

虽然通过这种方式可以很简单实现线性一致性读,但是每次读都走一次日志复制流程是非常低效的,所以通常不会使用这种方式。

ReadIndex Read

上面描述的读到过期的数据是由于leader过期,那么我们只要确认从leader在处理当前读请求时候,一定还没有过期,其实就可以保证读到最新的数据,Raft论文给出了两种方法确认leader。

第一种就是ReadIndex,处理过程如下:

  • 接收到写请求后将自己的commitIndex保存到一个本地变量readIndex中。
  • 为了确认自己的leader状态,发起一轮心跳广播,如果大多数节点响应了,那么就确认了自己的地位。
  • 等到状态机执行超过readIndex后,此时读取到的数据就可以满足线性一致性读。
  • 返回从状态机读取到的结果给客户端。

这里需要注意的是,如果leader在他的任期内还没有提交过日志条目,那么他的commitIndex可能不是最新的,例如一个leader在完成日志复制后更新了commitIndex,但是在同步给follower前崩溃了,那么follower在成为leader后,commitIndex并不是最新的,所以要求当leader选举成功之后,马上提交一个空日志(no-op),保证leader的commitIndex是最新的。

通过ReadIndex还可以提供follower read的功能,follower可以发个请求给leader获取readIndex,leader走一遍流程确认自己的地位后返回readIndex,follower等待自己的状态机执行超过readIndex后返回结果给客户端。

Lease Read

尽管ReadIndex比Raft log的方式高效,但是仍然需要走一次心跳广播流程,Raft论文里面还提到一种只需要leader单独完成读请求的方法。

这是一种通过租约的来确定leader状态的方式。在每次发送心跳广播时,记录一个开始时间start,当系统过半节点回复后,就可以认为leader的有效时间到start + min election timeout / clock drift bound,这里min election timeout指的是超时时间范围的最小值,clock drift bound指的是时钟漂移边界。

因为follower在收到心跳后,至少需要等待超过min election timeout时间才会发起选举,所以下一个leader一定在start + min election timeout / clock drift bound之后。这里还需要调整选举投票的方式,因为可能出现部分节点没有收到心跳包发起选举,所以如果follower在min election timeout时间内,就拒绝给其它节点投票。

这种方式还需要假定集群内所有服务器的时钟漂移是有界的,如果出现服务器时钟频率太快或者太慢,就有可能导致leader状态判断错误,读取到过期的数据。所以这种方式是无法保证线性一致性读的。