raft原理介绍

1,148 阅读20分钟

CAP协议

分布式系统的奠基理论CAP:任何分布式系统最多只能满足数据一致性(Consistency)、可用性(Availability)和网络分区容忍(Partition Tolerance)三个特性中的两个。

  • 一致性(consistency)是指分布式系统各个节点上的数据在任意时刻数据是一致的,任何节点上读取数据将获得一样的结果。
  • 可用性(Avaible)是指系统能够能够正常响应数据请求,所谓正常响应只是请求要在一定时间范围内获得响应。
  • 分区容错性(Partition Tolerance)是指在遇到某节点或网络分区故障的时候,系统仍然能够对外提供满足一致性和可用性的服务。

CAP理论指出任何系统最多只能满足CA或CP或AP,没有办法做到同时满足CAP。互联网系统绝大多数最求系统的高可用和高可靠,为了满足系统的高可靠性要求,多数据副本是实现的基本方法,多数据副本对于数据的强一致性很难保证,因为副本存在数据同步的时间差和网络分区的问题,型号大多数业务对系统一致性的要求没有那么高,并不要求系统的强一致性,通常系统只需要保证最终一致性即可。

Paxos协议

Paxos共识算法由Lesile Lamport最先提出,用来解决分布式系统中的多节点写入问题。Paxos通过改进的两段提交方式来解决数据并发写入可能带来的冲突,保证了数据的强一致性。Paxos协议参与者分为3类角色:

  • Proposer: 提出提案 (Proposal)。Proposal信息包括提案编号 (Proposal ID) 和提议的值 (Value)。
  • Acceptor:参与决策,回应Proposers的提案。收到Proposal后可以接受提案,若Proposal获得多数Acceptors的接受,则称该Proposal被批准。
  • Learner:不参与决策,从Proposers/Acceptors学习最新达成一致的提案(Value) Paxos算法的主要流程入下图所示:

  • Proposal阶段
    1. Proposer先通过一个递增的提案号像所有的Acceptor发起一个新提案,该提案只有提案号
    2. Acceptor收到Proposal请求之后,检查本地是否已经有已Accept提案,如果没有返回(NULL,NULL),否则比较提案号大小,如果新提案号大则接受并返回已接受的提案(LastProposal_N,LastProposal_V),保存Proposal的提案号
  • Accept阶段
  1. Proposer收到多数Acceptor的回复后区分两种情况处理: 如果Proposal回复中都是(NULL,NULL)则已自己填的值生成(N,V)的Accept提案,如果有非(NULL,NULL)的Proposal回复,则选择LastProposal_N编号最大的对应的V生成(N,V)的Accept提案
  2. Acceptor收到Accept提案后,与上一步收到的提案号进行比较,如果相等则接受,否则拒绝
  3. Proposal接收到Accept提案回复后,统计是否已经被大多数接受,如果是则V已为Chosen状态
  4. Proposal和Accept阶段的Acceptor不一定是同一批

为什么要raft

前面简单介绍了Paxos协议的大致内容,Paxos问世以来就持续垄断了分布式一致性算法,Paxos这个名词几乎等同于分布式一致性。Google的很多大型分布式系统都采用了Paxos算法来解决分布式一致性问题。Paxos协议最大的特点就是难,不仅难以理解,更难以实现。Raft算法设计的目标就是更易于理解的分布式一致性算法。Raft算法目前已经有很多开源实现,其中最有名气的应该是k8s的存储基石etcd。

raft基本流程

Raft的基本流程的理解可以参考一个视频: raft协议演示。Raft算法由leader节点来处理一致性问题。leader节点接收来自客户端的请求日志数据,然后同步到集群中其它节点进行复制,当日志已经同步到超过半数以上节点的时候,leader节点再通知集群中其它节点哪些日志已经被复制成功,可以提交到raft状态机中执行。通过以上方式,Raft算法将要解决的一致性问题分为了以下几个子问题。

  • leader选举:集群中必须存在一个leader节点。
  • 日志复制:leader节点接收来自客户端的请求然后将这些请求序列化成日志数据再同步到集群中其它节点。
  • 安全性:如果某个节点已经将一条提交过的数据输入raft状态机执行了,那么其它节点不可能再将相同索引 的另一条日志数据输入到raft状态机中执行。

基本概念

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

  • Leader:领导者,一个集群里只能存在一个Leader,Leader通过选举产生,数据的写入只能通过Leader
  • Follower:跟随者,follower是被动的,一个客户端的修改数据请求如果发送到Follower上面时,会首先由Follower重定向到Leader上,Follower如果没有收到Leader的心跳消息可能切换到Candidate发起新一轮选举
  • Candidate:参与者,一个节点切换到这个状态时,将开始进行一次新的选举,实际etcd实现时为了避免term持续递增,还有一个PreCandidate的短暂状态

每一次开始一次新的选举时,称为一个“任期”。每个任期都有一个对应的整数与之关联,称为“任期号”,任期号用单词Term表示,这个值是一个严格递增的整数值。Raft把Term作为逻辑时钟来保证时序状态的正确性,每次通信时都会带上当前的term,每一个节点状态中都保存一个当前任期号(currentterm),节点在进行通信时都会带上本节点的当前任期号。如果一个节点的当前任期号小于其他节点的当前任期号,将更新其当前任期号到最新的任期号。如果一个candidate或者leader状态的节点发现自己的当前任期号已经小于其他节点了,那么将切换到follower状态。反之,如果一个节点收到的消息中带上的发送者的任期号已经过期,将拒绝这个请求。

Leader选举

节点刚开始启动时,初始状态是follower状态。一个follower状态的节点,会有一个electiontimeout计时器,如果计时器超时以后就会切换状态到candidate,发起超时选举。由于初始启动时term都为0, 所以发起选举的node很容易就能单选为leader节点。follower节点收到收到来自leader或者candidate的正确RPC消息的话,将一直保持在follower状态。leader节点通过周期性的发送心跳请求(一般使用带有空数据的AppendEntriesRPC来进行心跳)来维持着leader节点状态。每个follower同时还有一个选举超时(electiontimeout)定时器,如果在这个定时器超时之前都没有收到来自leader的心跳请求,那么follower将认为当前集群中没有leader了,将发起一次新的选举。状态转换图如下:

Raft状态转换
前面详细介绍了选举的发起过程,但是要最终成为leader节点不仅仅是最先发起选举就能成功,follower节点投票选举leader也是有很多限制条件的, 选举的详细过程如下:

  • follower递增自己的term
  • follower将自己的状态变为candidate并投票给自己
  • 向集群其它机器发起投票请求(RequestVote请求)
  • 当以下情况发生,结束自己的candidate状态。
    1. 超过集群一半服务器都同意,状态变为leader,并且立即向所有服务器发送心跳消息,之后按照心跳间隔时间发送心跳消息。任意一个term中的任意一个服务器只能投一次票,所有的candidate在此term已经投给了自己,那么需要另外的follower投票才能赢得选举
    2. 发现了其它leader并且这个leader的term不小于自己的term,状态转为follower。否则丢弃消息,继续选举流程
    3. 没有服务器赢得选举,可能是由于网络超时或者服务器原因没有leader被选举,这种情况比较简单,超时之后重试。有一种情况被称为split votes,比如一个有三个服务器的集群中所有服务器同时发起选举,那么就不可能有leader被选举出来,此时如果超时之后重试很可能所有服务器又同时发起选举,这样永远不可能有leader被选举出来。raft处理这种情况是采用上文提到过的random election timeout,通过随机化的选举超时时间来规避该问题
    4. 选举中竞争当选leader的过程中,如果发生网络分区,candidate由于没有获得足够的选票,会不停的重新发起选举递增term值,当网络分区问题修复之后加入原有集群后,会中断原有集群的稳定状态,为了规避该问题,Raft优化了选举过程提出了PreVote,follower节点在选举之前要先发起prevote请求,确认能获得超过半数同意回复后再切换到candidate状态发起选举

Follower节点收到candidate的选举请求之后根据以下逻辑判断是否投票:

  • Term比自己的Term大
  • 该Term还没有投票过
  • Index大于或者等于Follower的LastIndex,这样才能保证选举出来的leader已经拥有最近的同步数据

日志复制

如果说Leader选举通过单写入点降低了Raft保障数据一致性的复杂度,那么日志复制则是数据一致性保证的根本。Raft集群中只有Leader节点能够接受客户端的请求,由Leader向其他Follower转发所有请求日志,并且有那么两条规则:Leader不删除任何日志、Follower只接收Leader所发送的日志信息。Raft的日志格式如下:

Raft日志格式

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

  • index:日志索引号,即图中最上方的数字,是严格递增的。
  • term:日志任期号,就是在每个日志条目中上方的数字,表示这条日志在哪个任期生成的。
  • command:日志条目中对数据进行修改的操作。 一条日志如果被leader同步到集群中超过半数的节点,那么被称为“成功复制”,这个日志条目就是“已被提交(committed)”。如果一条日志已被提交,那么在这条日志之前的所有日志条目也是被提交的,包括之前其他任期内的leader提交的日志。如上图中索引为7的日志条目之前的所有日志都是已被提交的日志。

Raft的日志有两个重要属性,保证了日志的可靠性:

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

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

  • 每个客户端的请求都会被重定向发送给leader,这些请求最后都会被输入到raft算法状态机中去执行
  • leader在收到这些请求之后,会首先在自己的日志中添加一条新的日志条目
  • 在本地添加完日志之后,leader将向集群中其他节点发送AppendEntries RPC请求同步这个日志条目,当这个日志条目被成功复制之后,leader节点将会将这条日志输入到raft状态机中,更新commitedIndex,通知应用层进行数据的持久化,持久化成功之后就可以更新appliedIndex,然后应答客户端
  • leader节点会在每次heartbeat或者AppendEntries的请求中带上committedIndex,这样follower节点就能够提交日志并应用持久化数据

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

在上图中,最上面的一排数字是日志的索引,盒子中的数据是该日志对应的任期号,左边的字母表示的是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节点日志相关的数据(Progress)。其中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节点上缺少的日志数据将被补齐,而多出来的未提交数据将被清除。

安全性

在Raft中有一个很重要的安全性保证就是只有一个Leader,如果我们在不加任何限制的情况下,动态的向集群中添加成员,那么就可能导致同一个任期下存在多个Leader的情况,这是非常危险的。

如下图所示,从Cold迁移到Cnew的过程中,因为各个节点收到最新配置的实际不一样,那么肯能导致在同一任期下多个Leader同时存在。

比如图中此时Server3宕机了,然后Server1和Server5同时超时发起选举:

  • Server1:此时Server1中的配置还是Cold,只需要Server1和Server2就能够组成集群的Majority,因此可以被选举为Leader。
  • Server5:已经收到Cnew的配置,使用Cnew的配置,此时只需要Server3,Server4,Server5就可以组成集群的Majority,因为可以被选举为Leader

换句话说,以Cold和Cnew作为配置的节点在同一任期下可以分别选出Leader,为了解决上面的问题,在集群成员变更的时候需要作出一些限定。

每次只想集群中添加或移除一个节点。比如说以前集群中存在三个节点,现在需要将集群拓展为五个节点,那么就需要一个一个节点的添加,而不是一次添加两个节点。

这个为什么安全呢?很容易枚举出所有情况,原有集群奇偶数节点情况下,分别添加和删除一个节点。在下图中可以看出,如果每次只增加和删除一个节点,那么Cold的Majority和Cnew的Majority之间一定存在交集,也就说是在同一个Term中,Cold和Cnew中交集的那一个节点只会进行一次投票,要么投票给Cold,要么投票给Cnew,这样就避免了同一Term下出现两个Leader。

变更的流程如下:

  • 向Leader提交一个成员变更请求,请求的内容为服务节点的是添加还是移除,以及服务节点的地址信息
  • Leader在收到请求以后,回向日志中追加一条ConfChange的日志,其中包含了Cnew,后续这些日志会随着AppendEntries的RPC同步所有的Follower节点中
  • 当ConfChange的日志被添加到日志中是立即生效(注意:不是等到提交以后才生效)
  • 当ConfChange的日志被复制到Cnew的Majority服务器上时,那么就可以对日志进行提交了

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

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

Paxos与Raft比较

Raft协议比paxos的优点是容易理解,容易实现。它强化了leader的地位,把整个协议可以清楚的分割成两个部分,并利用日志的连续性做了一些简化:

  • Leader在时,数据写入只能通过leader,由Leader向Follower同步日志
  • Leader挂掉时通过选举算法选举新的leader

但是本质上来说,它容易的地方在于流程清晰,描述更清晰,关键之处都给出了伪代码级别的描述,可以直接用于实现,而paxos最初的描述是针对非常理论的一致性问题,真正能应用于工程实现的mulit-paxos,Lamport老爷爷就提了个大概,之后也有人没尝试对multi-paxos做出更为完整详细的描述,但是每个人描述的都不大一样。

multi-paxos和raft,在选定了leader状态下的行为模式一模一样 raft仅允许日志最多的节点当选为leader,而multi-paxos则相反,任意节点都可以当选leader raft不允许日志的空洞,这也是为了比较方便和拉平两个节点的日志方便,而multi-paxos则允许日志出现空洞。 在Multi-paxos中,任何一个 leader election 算法都可以给 multi-paxos 使用,这里不详细描述了。多个leader并不会破坏算法正确性,而Raft则只允许有一个leader节点。

参考文档

  1. Raft算法原理
  2. 分布式一致性算法对比 - Raft VS Paxos
  3. Raft 集群成员变更
  4. raft协议演示