Raft学习笔记(一)——核心算法

1,515 阅读11分钟

我正在参加「掘金·启航计划」

这是笔者的Raft学习笔记,只为温故时能快速回忆,故算法细节没有太详细介绍。

这是第一部分核心算法,理解了这部分则理解了Raft。

写在前面

Raft作为共识算法,也是基于大多数原则实现的。领导者选举和日志复制两个核心流程都是大多数节点成功则操作成功。

可在复杂的分布式场景下,只是大多数原则无法保证算法正确性,所以还需要给算法加些限制,我理解这就是安全性子问题的由来。

Raft厉害的地方在于,算法中只有3个不重叠的状态,2个核心的RPC。论文也是面向工程实现提出的,相较于Paxos,Raft在实现上容易得多。但Raft毕竟是共识算法,本身也有理解难度。

复制状态机

共识算法是从复制状态机的背景下提出的,复制状态机通常都是基于复制日志实现的,共识算法的任务就是保证复制日志的一致性。 指令存储在日志中,每个节点都按相同顺序执行指令,每一次操作都产生相同的状态。

raft-图1.png

共识算法通常含有以下特性:

  • 安全性保证:在非拜占庭情况下。
  • 可用性:可以允许少数节点失败。
  • 不依赖时序保证一致性
  • 个别慢节点不影响整体性能

Raft易理解性

算法分解:Raft将实现拆解为以下3个子问题

  • 领导人选举
  • 日志复制
  • 安全性

减少节点状态:只有以下3个状态,一个节点在一个时刻只能是一种状态。

  • leader
  • follower
  • candidate

raft-图4.png

只有两种RPC:核心算法中只有两种RPC请求。

  • RequestVote:由candidate在选举期间发起
  • AppendEntries:由leader发起,用来复制日志和提供一种心跳机制。

这个图就是Raft核心算法的全部内容了:

raft-图2.png

领导者选举

选举过程

leader

Raft内部有一种心跳机制,leader会通过向follower发送心跳,来维持leader地位。

follower

所有节点启动之后,首先进入follower状态。 如果follower一段时间没有收到心跳,那么它认为系统中没有可用leader,然后开始选举流程。 开始一个选举过程后,follower先增加自己的当前任期号,并切换到candidate状态。

candidate

变为candidate之后,先增加自己当前任期号。然后投票给自己,并且并行向集群中其他节点发送投票请求(RequestVote)。 三种投票结果:

  1. 获得半数以上选票,赢得选举,成为leader并开始发送心跳,结束选举
  2. 其他节点赢得了选举,从canaidate退回follower。确认方式:收到了新leader的心跳,新leader的任期号不小于自己的当前任期号。
  3. 一段时间后没有任何获胜者,candidate在自己的随机选举超时时间之后开始新一轮选举。

随机超时机制

如果所有节点同时发起选举,则可能会不断陷入平分选票的循环,一直没有candidate获得大多数选票。引入随机超时机制,率先超时的节点则有更大概率成为leader

RequestVote RPC

Request

class RequestVoteRequest {
	int term;          // candidate当前任期号
	int candidateId;   // candidate自己的ID,follower要知道投票给谁
	int lastLogIndex;  // 自己最后一个日志号
	int lastLogTerm;   // 自己最后一个日志的任期号
}

Response

class RequestVoteResponse {
	int term;          // 自己当前的任期号
	bool voteGranted;  // 自己会不会投票给这个candidate
}

follower收到request之后,校验请求,判断这个candidate是否满足条件:

  • candidate的term比自己大
  • lastLogIndex、lastLogTerm判断。原因和逻辑在安全性中介绍

日志复制

raft-图6.png

图中最上面,是日志号logIndex,全局单调递增。 日志号logIndex和任期号term唯一确定一条日志。 leader生成日志后,会将日志通过AppendEntries RPC并行的发送给所有follower,当超过半数的follower复制后,leader就可以在本地提交该指令。

提交:节点将指令应用到状态机。上图中,leader可以提交最新的日志是logIndex=7的那条。

复制过程

日志复制过程中,要考虑崩溃或慢节点的情况,并保证每个副本日志顺序一致,以保证复制状态机的实现:

follower慢节点

follower没有及时响应leader,那么leader会不断重发日志,哪怕leader已经完成了大多数请求并返回了client。

follower崩溃后恢复

如果follower崩溃后恢复,Raft通过一致性检查,保证follower能按顺序恢复缺失的日志。一致性检查:

  • leader发送的AppendEntries RPC中,会放入前一条日志的日志号prevLogIndex和任期号lastLogTerm。
  • 如果follower在它的日志里找不到前一个日志,他就会拒绝此日志。即response的success为false。
  • leader收到拒绝后,会发送前一条日志,从而逐渐向前定位到follower第一条缺失的日志。
  • leader再顺序发送日志,恢复到正常发送的情况。

leader崩溃后恢复

如果leader崩溃,那么崩溃的leader可能已经复制了日志到部分follower,而被选出来的新leader又不具备这些日志,这样就有部分follower的日志和新leader日志不相同。

  • Raft在这种情况下,leader会强制follower复制它的日志。follower中跟leader冲突的日志会被覆盖(因为没有提交,所以不违背外部一致性)。
  • 这种强制覆盖会引出已提交的日志被覆盖,这种情况会在安全性里讲

raft-图7.png

在上图这种情况下,a、b会从leader那里补全自己缺失的日志。c、d、e、f会覆盖掉与leader不一致的日志,并按顺序补全日志。

AppendEntries RPC

Request

class AppendEntriesRequest {
	int term;           // leader自己当前的任期号
	int leaderId;       // leader自己的ID
	int prevLogIndex;   // 前一个日志的日志号
	int preLogTerm;     // 前一个日志的任期号
	byte[] entries;     // 当前日志体
	int leaderCommit;   // leader的已提交日志号
}

Response

class AppendEntriesResponse {
	int term;      // follower或candidate自己当前的任期号
	bool success;  // 如果follower持有前一个日志,则返回true
}

前面只讲了leader的日志提交时机,但没讲follower的提交时机。request的leaderCommit就是与follower日志提交有关的。follower在收到request之后,根据leaderCommit,比leaderCommit小的日志都是可以提交的。

所以对于一条日志来说,leader在确认大多数节点appendEntries成功后就会提交,follower至少收到第二次appendEntries请求,才会提交日志中的指令到状态机。后面会介绍这个提交时机不会影响一致性

多提一下,follower要在下一次appendEntries时才能提交的话,leader和follower之间的状态机不一致的时间间隔貌似有点大。 如果写频繁还好,不间断的写请求会让日志快速提交。但如果写请求不频繁,follower的日志提交基本就靠心跳了,延迟可能会影响部分场景。

小结

  • leader在当权后,不需要任何特殊操作,依靠AppendEntries RPC的一致性检查逻辑就能自动恢复一致。
  • leader不会覆盖或者删除自己的日志。(Append-Only)

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

  • 只要超过半数的节点正常,Raft就能接受、复制、应用新的日志
  • 少数慢follower不会影响整体性能。

安全性

前面讲的领导者选举和日志复制两个子问题的过程,实际上还不能保证复制状态机的正确实现。即,不能保证每一个节点都按照相同的顺序执行相同的命令。

所以Raft通过几个补充规则完善算法,保证在非拜占庭情况下算法的正确性。

选举限制

之前介绍的leader选举过程中,只有一个限制条件,就是比较term大小,follower只会给term比自己大的candidate投票。但这个条件还不够。

raft-图6.png

如果一个follower落后了leader部分日志,但没有遗漏整个任期,比如上图里的第2节点。在下次选举中,按领导者选举的规则,它仍然有可能当选leader。但按日志复制的规则,leader不会补充自己缺失的日志,还会覆盖其他follower的日志。这就违背了一致性原则。

所以对领导者选举增加一个限制:被选出来的leader一定包含了之前各任期所有被提交的日志

在RequestVote request中,增加了candidate的最后一条日志信息,即lastLogIndex和lastLogTerm,follower收到request之后比较自己的最新日志,如果follower的日志比candidate还新,就会拒绝投票

在上图的例子中,只有第1、3、5节点可以成为leader,第2节点会被拒绝投票,无法成为leader。

多说一句吧。增加这个规则不会影响选主正确性,不会造成此类情况下的丢数据。 几个基本的前提或原则:

  1. 如果不可用的节点数超过半数,则集群不可用。所以讨论都是在大多数节点可用的情况下。
  2. 如果一条日志被提交(leader提交了,follower不一定),那一定复制到了大多数节点。未提交的日志不用管,丢了不影响一致性。
  3. 两个大多数之间必然有交集,则按新规则一定可以选出leader,且新leader是拥有最新已提交日志的。虽然可能新leader并没有把最新日志应用到状态机。但它只要有这个日志,就可以之后再提交并发送给其他follower,而不会丢。

新leader是否提交之前任期内的日志

raft-图8.png

上图中的情况,c时刻S1当选leader,把日志2复制到了大多数节点,按之前的规则S1可以提交日志2了,但d时刻S1宕机S5当选(S5可以拿到S2、S3、S4和S5的选票),S5则会覆盖日志2和日志4。 日志4未达成大多数未提交,被覆盖没关系,但日志2如果提交了,则会造成状态机不一致。

增加日志提交规则:

  1. 如果某个leader在提交某个日志之前崩溃了,以后的leader会试图完成该日志的复制,而非提交,不能通过心跳来提交老leader的日志。实现上,可能是新leader发送之前任期日志时,AppendEntries RPC的leaderCommit都是0,follower收到后只会复制日志,而不会提交到状态机。
  2. Raft永远不会通过计算副本数来提交非自己任期内的日志。只有自己任期内的日志,才会通过计算副本数来提交。
  3. 在follower中,当前任期的日志提交时,才会提交之前任期的日志。follower视角:
    1. 新leader选出后,复制之前日志
    2. 收到第一条非心跳appendEntries。不会提交之前任期的日志,也不会提交该条日志。
    3. 收到第二条appendEntries,心跳或数据。提交之前任期日志,提交上一条日志。

上图e中,在提交第4任期的日志时,才提交第2任期的日志。

多提一下。 新leader在保证之前任期的日志已经全部复制到大多数follower之后,实际可以通过心跳来提交老任期日志。但这个逻辑本身也足够复杂。Raft通过这种简单规则避免掉了实现这种逻辑的复杂性。

再多提一下,这种情况下,如果新leader一直不写新数据,那leader和follower的状态机之间就一直存在不一致。 我自己感觉:如果是想要缩短这种情况下,follower和leader状态机不一致时间的话,leader是可以马上写一个无效日志,非心跳,占一个有效的logIndex,但不会产生实际作用的日志。

Follower和Candidate宕机重启

follower和candidate崩溃的处理方式就简单了,且处理方式相同

  • 如果follower或candidate崩溃,后续发给他们的RPC都会失败
  • Raft通过无限重试来处理这种失败,崩溃的机器重启后,RPC就会成功
  • Raft的RPC是幂等的

时间与可用性限制

  • Raft整体不依赖客观时间。则不会受时钟偏移这类问题影响。
  • 广播时间(broadcastTime) << 选举超时时间(electionTimeout) << 平均故障时间(MTBF)。

参考