前一章,我们了解了当Nacos客户端注册的节点为临时节点时,服务端的一致性协议使用的Distro协议,那么当我们的客户端注册节点为非临时节点的时候,他用的一致性协议是raft协议,下面,我们就来介绍一下raft协议。
1.raft介绍
1.1共识算法
在分布式系统中,为了消除单点提高系统可用性,通常会使用副本来进行容错,但这会带来另一个问题,即如何保证多个副本之间的一致性?
所谓的强一致性(线性一致性)并不是指集群中所有节点在任一时刻的状态必须完全一致,而是指一个目标,即让一个分布式系统看起来只有一个数据副本,并且读写操作都是原子的,这样应用层就可以忽略系统底层多个数据副本间的同步问题。也就是说,我们可以将一个强一致性分布式系统当成一个整体,一旦某个客户端成功的执行了写操作,那么所有客户端都一定能读出刚刚写入的值。即使发生网络分区故障,或者少部分节点发生异常,整个集群依然能够像单机一样提供服务。
共识算法(Consensus Algorithm)就是用来做这个事情的,它保证即使在小部分(≤ (N-1)/2)节点故障的情况下,系统仍然能正常对外提供服务。共识算法通常基于状态复制机(Replicated State Machine)模型,也就是所有节点从同一个 state 出发,经过同样的操作 log,最终达到一致的 state。
raft就是一个共识算法,他相比Paxos是比较容易理解的一个算法,也是Paxos算法的一个衍生.
注意他是一个共识算法,他会去往整个集群同步数据,但是一致性得你更上层去保证,因为集群同步数据是有延时还有网络问题的,如果你想连接集群的任意一个节点都能获取到最新数据,你的更上层得去做一些操作,例如只从主节点去读,读从节点之前先让从节点去主节点请求一下,问一下我是否是最新数据等等。但是我们可以保证 整个集群对外是一致的。
共识算法和一致性的关系
一致性往往指分布式系统中多个副本对外呈现的数据的状态。如顺序一致性、线性一致性,描述了多个节点对数据状态的维护能力。 共识性则描述了分布式系统中多个节点之间,彼此对某个状态达成一致结果的过程。 因此,一致性描述的是结果状态,共识则是一种手段。达成某种共识并不意味着就保障了一致性(这里的一致性指强一致性)。只能说共识机制,能够实现某种程度上的一致性。 实践中,要保障系统满足不同程度的一致性,核心过程往往需要通过共识算法来达成.
1.2 集群中数据一致性的问题
如下图,我们先思考一个问题,假设我想往一个集群写入‘你好’,那么我们要怎么保证我写入了集群的每一个节点呢,即整个集群的数据一致性,这样无论我访问哪个节点都能获取到‘你好’。
- 假设我往集群的每一个节点都写入,假设有一个因为网络原因写失败了,或者宕机,后面恢复之后重新加入集群,那么我客户端访问这个节点肯定获取不到‘你好’,而且每一个节点都进行写入,开销太大。
- 那么我们选一个leader节点,让他负责所有写入数据,再同步到从节点,我们的开销是不是就没那么大了;但是我们又会遇到别的问题,假设我们成功写入了leader,在同步到follow节点的时候,leader宕机了,我follow也没有你写入的数据。
- 为了解决这种问题,我们需要一种数据同步机制,这种就是共识算法,raft是其中一种算法,来让整个集群的数据达到一致,即使发生网络分区故障,或者少部分节点发生异常,整个集群依然能够像单机一样提供服务。
1.3 raft基本概念
整个raft算法的阶段划分为3个阶段:
- 阶段1:选举阶段。选举出Leader,其他机器为Follower。
- 阶段2:正常阶段。Leader接收写请求,然后复制给其他Follower。
- 阶段3:恢复阶段。旧Leader宕机,新Leader商人,其他Follower切换到新Leader,开始同步数据
核心点:
- 首先,Raft 集群必须存在一个主节点(leader),我们作为客户端向集群发起的所有操作都必须经由主节点处理。所以 Raft 核心算法中的第一部分就是选主(Leader election)——没有主节点集群就无法工作,先票选出一个主节点,再考虑其它事情。
- 其次,主节点需要承载什么工作呢?它会负责接收客户端发过来的操作请求,将操作包装为日志同步给其它节点,在保证大部分节点都同步了本次操作后,就可以安全地给客户端回应响应了。这一部分工作在 Raft 核心算法中叫日志复制(Log replication)。
- 然后,因为主节点的责任是如此之大,所以节点们在选主的时候一定要谨慎,只有符合条件的节点才可以当选主节点。此外主节点在处理操作日志的时候也一定要谨慎,为了保证集群对外展现的一致性,不可以覆盖或删除前任主节点已经处理成功的操作日志。所谓的“谨慎处理”,其实就是在选主和提交日志的时候进行一些限制,这一部分在 Raft 核心算法中叫安全性(Safety)。
- Raft 核心算法其实就是由这三个子问题组成的:选主(Leader election)、日志复制(Log replication)、安全性(Safety)。这三部分共同实现了 Raft 核心的共识和容错机制。
- 他是一个二阶段提交协议
- 整个raft会围绕两种rpc请求来展开:
RequestVote RPCs: 用于 candidate 拉票选举
AppendEntries RPCs: 用于 leader 向其它节点复制日志以及同步心跳。
2.Leader选举
2.1 为什么要选leader
原生的 Paxos 算法使用了一种点对点(peer-to-peer)的方式,所有节点地位是平等的。在理想情况下,算法的目的是制定一个决策,这对于简化的模型比较有意义。但在工业界很少会有系统会使用这种方式,当有一系列的决策需要被制定的时候,先选出一个 leader 节点然后让它去协调所有的决策,这样算法会更加简单快速。
此外,和其它一致性算法相比,Raft 赋予了 leader 节点更强的领导力,称之为 Strong Leader。比如说日志条目只能从 leader 节点发送给其它节点而不能反着来,这种方式简化了日志复制的逻辑,使 Raft 变得更加简单易懂。
2.2 选举(RequestVote RPCs)
raft中,任何一个节点仅有三种状态:
- 跟随者(Follower):普通群众,默默接收和来自领导者的消息,当领导者心跳信息超时的时候,就主动站出来,推荐自己当候选人Candidate。
- 候选人(Candidate):候选人将向其他节点请求投票 RPC 消息,通知其他节点来投票,如果赢得了大多数投票选票,就晋升当领导者Leader。
- 领导者(Leader):霸道总裁,一切以我为准。处理写请求、管理日志复制和不断地发送心跳信息,通知其他节点“我是领导者,我还活着,你们不要”发起新的选举,不用找新领导来替代我。
节点状态转换
我们知道集群每个节点的状态都只能是 leader、follower 或 candidate,那么节点什么时候会处于哪种状态呢?下图展示了一个节点可能发生的状态转换:
1、初始所有节点都是Follower
2、每个节点都有一个随机睡眠时间,谁先苏醒,谁就从Follower变为Candidate
3、Candidata苏醒后向其他Follower发送投票请求,想其他Follower投票给我,让我成为领导者;一旦收到了集群中大多数节点的投票,则从Candidata变为Leader。
4、回到第二步,当我同时有A,B两个节点苏醒为Candidate,但是A获取了大多数选票变为Leader,Candidate B收到Leader心跳之后,自己乖乖变为Follwer
5、假设我有A,B,C三个节点,在Term=1,可以理解为第一年的时候,A是Leader,这个时候由于网络原因分区了,A自己就孤零零了。而这个时候B,C发现Leader不见了,他们从Follwer变为Candidata,重新选举,这个时候Term=2,就是第二年的Leader为B或C其中一个;这个时候A的网络突然又好了,这个集群现在会有两个Leader,这样会有问题,所以当A节点收到更高Term的Leader的心跳之后,A节点从Leader变为Follower,并且更新自己的Term=2。
任期,term
每一个任期有且仅有一个Leader节点,这是由选举规则决定,类似2022年选出Leader1,2022年就是任期,后面2022年的Leader1宕机了,我们其他节点感知不到Leader了,自动term+1=2023,再由剩余的follower节点选出2023年的Leader
-
自动增加:Follower在等待Leader心跳信息超时后,推荐自己为Candidate,会增加自己的任期号,如节点 A 任期为 0,推举自己为候选人时,任期编号增加为 1,但并不是每个 term 都一定对应一个 leader,有时候某个 term 内会由于选举超时导致选不出 leader,这时 candicate 会递增 term 号并开始新一轮选举。
-
更新为较大值:当节点发现自己的任期编号比其他节点小时,会更新到较大的编号值。比如节点 A 的任期为 1,请求投票,投票消息中包含了节点 A 的任期编号,且编号为 1,节点 B 收到消息后,会将自己的任期编号更新为 1。
-
恢复为跟随者:如果一个候选人或者领导者,发现自己的任期编号比其他节点小,那么它会立即恢复成跟随者状态。这种场景出现在分区错误恢复后,任期为 3 的领导者受到任期编号为 4 的心跳消息,那么前者将立即恢复成跟随者状态。
-
拒绝消息:如果一个节点接收到较小的任期编号值的请求,那么它会直接拒绝这个请求,比如任期编号为 6 的节点 A,收到任期编号为 5 的节点 B 的请求投票 RPC 消息,那么节点 A 会拒绝这个消息。
-
一个任期内,领导者一直都会领导者,直到自身出现问题(如宕机),或者网络问题(延迟),其他节点发起一轮新的选举。
-
在一次选举中,每一个服务器节点最多会对一个任期编号投出一张选票(先到先得),投完了就没了。
2.2.1选举成功过程
当candicate从整个集群的大多数(N/2+1)节点获得了针对同一 term 的选票时,它就赢得了这次选举,立刻将自己的身份转变为 leader 并开始向其它节点发送心跳来维持自己的权威。
1、初始状态下,集群中所有节点都是Follower的状态。
如下图所示,有三个节点(Node) a、b、c,任期(Term)都为 0。
2、Raft 算法实现了随机超时时间的特性,每个节点等待领导者节点心跳信息的超时时间间隔是随机的,可以大大减少多个节点同时成为Candidate的概率。比如 A 节点等待超时的时间间隔 150 ms,B 节点 200 ms,C 节点 300 ms。那么 a 先超时,最先因为没有等到领导者的心跳信息,发生超时。如下图所示,三个节点的超时计时器开始运行。
3、当 A 节点的超时时间到了后,A 节点成为候选者,并增加自己的任期编号,Term 值从 0 更新为 1,并给自己投了一票。
- Node A:Term = 1, Vote Count = 1。
- Node B:Term = 0。
- Node C:Term = 0。
4、节点 A 成为候选者后,向其他节点发送请求投票 RPC 信息,请它们选举自己为领导者。
5、节点 B 和 节点 C 接收到节点 A 发送的请求投票信息后,在编号为 1 的这届任期内(同一个任期只能投一票),还没有进行过投票,并且因为自己的term比节点A带过来的term小,把自己的term变为1,就把选票投给节点 A,并增加自己的任期编号。
6、节点 A 收到 3 次投票,得到了大多数节点的投票,从candicate成为本届任期内的新的Leader。(当candicate从整个集群的大多数(N/2+1)节点获得了针对同一 term 的选票时,它就赢得了这次选举,立刻将自己的身份转变为 leader 并开始向其它节点发送心跳来维持自己的权威)
7、节点 A 作为Leader,固定的时间间隔给 节点 B 和节点 C 发送心跳信息,告诉节点 B 和 C,我是Leader,你们不需要重新选举了。
8、节点 B 和节点 C 发送响应信息给节点 A,告诉节点 A 我是正常的。
9、如果B,C节点有一段时间没有收到Leader的心跳,他们又会重复上面步骤,变为Candidate发起投票。
2.2.2选举超时过程
如果有多个 follower 同时成为 candidate,选票是可能被瓜分的,如果没有任何一个 candidate 能得到大多数节点的支持,那么每一个 candidate 都会超时。此时 candidate 需要增加自己的 term,然后发起新一轮选举。如果这里不做一些特殊处理,选票可能会一直被瓜分,导致选不出 leader 来。这里的“特殊处理”指的就是前文所述的随机化选举超时时间。
1、A,B,C,D 4个节点都是Follower节点并且term=3,但是这个时候没有Leader,证明term=3的Leader挂掉了或者前几轮选举都没有选出Leader。
2、B和C同时苏醒变为Candidata,这个时候term+1=4,先投自己一票,同时发起投票,节点A投给C,同时更新当前自己的term=4,节点D投给C,这样B,C都是2票,平票,所以一段时间没选举出Leade,选举超时,重新进入休眠状态。
3、超时之后,节点C优先苏醒,这个时候term=5,然后大多数节点投票给节点C,C成为Term5的Leader
4、假设A和D优先收到了B节点的请求,B会成为Leader,先到先得原则,并且已经得到大多数人的投票,并且A和D已经在这个term投过票,则不会再给别人后面继续C又来拉票,也不会投给C。
2.2.3 选举失败
Candidate 在等待投票回复的时候,可能会突然收到其它自称是 leader 的节点发送的心跳包,如果这个心跳包里携带的 term 不小于 candidate 当前的 term,那么 candidate 会承认这个 leader,并将身份切回 follower。这说明其它节点已经成功赢得了选举,我们只需立刻跟随即可。但如果心跳包中的 term 比自己小,candidate 会拒绝这次请求并保持选举状态。
2.2.4 网络分区term无限增大
有的小朋友这个时候会有疑问?当我们的一个Follower节点网络不通,那么这个时候他没有收到来自Leader的心跳,那么他的term不就一直在增大,当网络好了,这个Follower节点term最大,不就变成Leader了吗?
其实Follower在变为候选者发起投票之前还有一个PerVote阶段,他需要请求其他节点,看正常节点是否多于半数节点,如果多于则自增term发起投票,否则则不会投票以及自增term。
3.日志复制(AppendEntries RPCs)
3.1什么是日志复制
共识算法通常基于状态复制机(Replicated State Machine)模型,所有节点从同一个 state 出发,经过一系列同样操作 log 的步骤,最终也必将达到一致的 state。也就是说,只要我们保证集群中所有节点的 log 一致,那么经过一系列应用(apply)后最终得到的状态机也就是一致的。
Raft 负责保证集群中所有节点 log 的一致性。
此外我们还提到过:Raft 赋予了 leader 节点更强的领导力(Strong Leader)。那么 Raft 保证 log 一致的方式就很容易理解了,即所有 log 都必须交给 leader 节点处理,并由 leader 节点复制给其它节点。
这个过程,就叫做日志复制(Log replication)。
Leader会并发地向所有Follower发送AppendEntries RPCs请求,只要超过半数的Follower复制成功,则返回给客户端已写入成功。
3.2 Raft 日志复制机制解析
3.2.1整体流程解析
一旦 leader 被票选出来,它就承担起领导整个集群的责任了,开始接收客户端请求,并将操作包装成日志,并复制到其它节点上去。
整体流程如下:
- Leader 为客户端提供服务,客户端的每个请求都包含一条即将被状态复制机执行的指令,例如client向我们的Leader节点写入数字5。
- Leader 把该指令作为一条新的日志附加到自身的日志集合,然后向其它节点发起附加条目请求(AppendEntries RPC),来要求它们将这条日志附加到各自本地的日志集合。
- 当这条日志已经确保被安全的复制,即大多数(N/2+1)节点都已经复制后,leader 会将该日志 apply 到它本地的状态机中,然后把操作成功的结果返回给客户端。
整个集群的日志模型可以宏观表示为下图(x ← 3 代表 x 赋值为 3):
下面Followers和Leader数据不一致是因为网络延迟或者Follwer宕机,导致日志不一致。
每条日志除了存储状态机的操作指令外,还会拥有一个唯一的整数索引值(log index)来表明它在日志集合中的位置。此外,每条日志还会存储一个 term 号(日志条目方块最上方的数字,相同颜色 term 号相同),该 term 表示 leader 收到这条指令时的当前任期,term 相同的 log 是由同一个 leader 在其任期内发送的。
当一条日志被 leader 节点认为可以安全的 apply 到状态机时,称这条日志是 committed(上图中的 committed entries)。那么什么样的日志可以被 commit 呢?答案是:当 leader 得知这条日志被集群过半的节点复制成功时。因此在上图中我们可以看到 (term3, index7) 这条日志以及之前的日志都是 committed,尽管有两个节点拥有的日志并不完整。
Raft 保证所有 committed 日志都已经被持久化,且“最终”一定会被状态机apply。
3.3.2 AppendEntries RPC请求参数
当往Leader写入数据的时候,Leader下一次发送心跳,会带上这一次写入内存的数据以及附加餐数,如下
- term:Leader的term
- leaderId:Leader的机械编号
- prevLogTerm:上一次复制成功的最后一条日志的term
- prevLogIndex:上一次复制成功最后一条日志的index,例如当前要复制的日志index为5-7,则prevLogIndex=4,prevLogTerm就是index=4位置对应的term
- entry:当前需要复制日志的内容和index,空为心跳
- leaderCommit:Leader的CommitIndex的值
- nextIndex:leader在向follower节点发送心跳的时候,会带上 (term,next index) 信息,当存在落后的follower节点收到心跳之后,会那自己的(term,next index)与收到的(term,log index + 1)比较,当follower发现自己不匹配,会响应给leader拒绝的消息,然后leader收到了拒绝的消息,会重新发一条消息,这次携带的是 (term,next index - 1) 的消息,然后重复上面的步骤,总的来说就是,当follower拒绝消息,leader则发送上一次的log位置给follower,依次递减,直到follower返回正确的响应,表示接受数据的同步, 则向后补满所有的log日志 ,达到数据一致性的结果。
@Data
public class AppendEntriesRPCParam {
private int term;
private int leaderId;
private int prevLogTerm;
private int prevLogIndex;
private entry[] entries;
private int leaderCommit;
private int nextIndex;
class entry{
private String content;
private int index;
}
}
接受者逻辑:
当任何一个接受者接收到RPC调用,则会执行如下逻辑
- 如果term<currentTerm返回false
- 如果当前节点在prevLogIndex,prevLogTerm没有日志,则证明数据落后,拒绝当前复制,并且下一次leader 会减少prevLogIndex的值并进行重试,直到相等,则可以重新复制Leader的数据。
- 如果接受者的日志在prevLogIndex位置的term不等于prevLogTerm,则返回false
- 如果接受者的某一条日志和Leader发过来的不匹配(index相同的位置,term不同),接受者删除此条日志,同时删除此日志之后的所有日志。如我刚刚往Leader set a=5&index=5,突然他挂了,5并没有同步给任何节点,这个时候LeaderB被选出来set b=6&index=5,并且同步给了大多数节点,这个时候前Leader苏醒,发现index=5和LeaderB不匹配,则重新同步Leader数据。
- 把entries[]中的日志最佳到自己的日志末尾
- 如果leaderCommit>commitIndex,则把commitIndex位置为Min(leaderCommit,Index of last new entry)
3.2.2图示raft日志复制
- 我们客户端往LeaderB写入数据5,这时候数据5是在日志序列中
- LeaderB在下一次心跳把数据5发送给FollowerA和C
- A和C收到之后写入自己的日志序列然后响应给LeaderB
- 收到AC的答复(N/2+1)LeaderB把5提交到状态机(红色变黑色)
- LeaderB响应给客户端成功
- LeaderB在下一次心跳告诉AC,5已经提交到状态机
- AC把数据5提交到状态机
分区情况:
- LeaderB原本是5个节点的Leader
- 产生分区A,B节点为一个分区,C,D,E节点为另一个分区
- 由于C,D,E发现一定时间收不到Leader心跳,所以自己term+1,然后发起投票
- 最后节点D收到3票成为Term2的Leader
- 假如这个时候有一个客户端往A,B分区的集群写入数据3,他是不会成功的,因为不能复制到大多数节点,得不到大多数节点的响应
- 但是客户端可以往C,D,E分区的集群写入数据8
- 网络好了,A,B收到更高Term的心跳,变为Follower
- A,B收到LeaderD的心跳还发现自己的日志出问题了,丢弃之前未提交的日志,从Leader拉取数据
- 最终整个集群数据一致
3.3对日志一致性的保证
我们使用了 (term2, index1) 这种方式来表示一条日志条目,这里为什么要带上 term,而不仅仅是使用 index?原因是 term 可以用来检查不同节点间日志是否存在不一致的情况。
Raft 保证:如果不同的节点日志集合中的两个日志条目拥有相同的 term 和 index,那么它们一定存储了相同的指令。
为什么可以作出这种保证?因为 Raft 要求 leader 在一个 term 内针对同一个 index 只能创建一条日志,并且永远不会修改它。
同时 Raft 也保证:如果不同的节点日志集合中的两个日志条目拥有相同的 term 和 index,那么它们之前的所有日志条目也全部相同。
这是因为 leader 发出的 AppendEntries RPC 中会额外携带上一条日志的 (term, index),如果 follower 在本地找不到相同的 (term, index) 日志,则拒绝接收这次新的日志。
所以,只要 follower 持续正常地接收来自 leader 的日志,那么就可以通过归纳法验证上述结论。
3.3.1可能出现的日志不一致场景
在所有节点正常工作的时候,leader 和 follower的日志总是保持一致,AppendEntries RPC 也永远不会失败。然而我们总要面对任意节点随时可能宕机的风险,如何在这种情况下继续保持集群日志的一致性才是我们真正要解决的问题。
上图展示了一个 term8 的 leader 刚上任时,集群中日志可能存在的混乱情况。例如 follower 可能缺少一些日志(a ~ b),可能多了一些未提交的日志(c ~ d),也可能既缺少日志又多了一些未提交日志(e ~ f)。
注:Follower 不可能比 leader 多出一些已提交(committed)日志,这一点是通过选举上的限制来达成的。
我们先来尝试复现上述 a ~ f 场景,最后再讲 Raft 如何解决这种不一致问题。
场景a~b. Follower 日志落后于 leader
这种场景其实很简单,即 follower 宕机了一段时间,follower-a 从收到 (term6, index9) 后开始宕机,follower-b 从收到 (term4, index4) 后开始宕机。这里不再赘述。
场景c. Follower 日志比 leader 多 term6
当 term6 的 leader 正在将 (term6, index11) 向 follower 同步时,该 leader 发生了宕机,且此时只有 follower-c 收到了这条日志的 Appen
dEntries RPC。然后经过一系列的选举,term7 可能是选举超时,也可能是 leader 刚上任就宕机了,最终 term8 的 leader 上任了,成就了我们看到的场景 c。
场景d. Follower 日志比 leader 多 term7
当 term6 的 leader 将 (term6, index10) 成功 commit 后,发生了宕机。此时 term7 的 leader 走马上任,连续同步了两条日志给 follower,然而还没来得及 commit 就宕机了,随后集群选出了 term8 的 leader。
场景e. Follower 日志比 leader 少 term5 ~ 6,多 term4
当 term4 的 leader 将 (term4, index7) 同步给 follower,且将 (term4, index5) 及之前的日志成功 commit 后,发生了宕机,紧接着 follower-e 也发生了宕机。这样在 term5~7 内发生的日志同步全都被 follower-e 错过了。当 follower-e 恢复后,term8 的 leader 也刚好上任了。
场景f. Follower 日志比 leader 少 term4 ~ 6,多 term2 ~ 3
当 term2 的 leader 同步了一些日志(index4 ~ 6)给 follower 后,尚未来得及 commit 时发生了宕机,但它很快恢复过来了,又被选为了 term3 的 leader,它继续同步了一些日志(index7~11)给 follower,但同样未来得及 commit 就又发生了宕机,紧接着 follower-f 也发生了宕机,当 follower-f 醒来时,集群已经前进到 term8 了。
3.3.2 如何处理日志不一致
通过上述场景我们可以看到,真实世界的集群情况很复杂,那么 Raft 是如何应对这么多不一致场景的呢?其实方式很简单暴力,想想 Strong Leader 这个词。
Raft 强制要求 follower 必须复制 leader 的日志集合来解决不一致问题。
也就是说,follower 节点上任何与 leader 不一致的日志,都会被 leader 节点上的日志所覆盖。这并不会产生什么问题,因为某些选举上的限制,如果 follower 上的日志与 leader 不一致,那么该日志在 follower 上一定是未提交的。未提交的日志并不会应用到状态机,也不会被外部的客户端感知到。
要使得 follower 的日志集合跟自己保持完全一致,leader 必须先找到二者间最后一次达成一致的地方。因为一旦这条日志达成一致,在这之前的日志一定也都一致(回忆下前文)。这个确认操作是在 AppendEntries RPC 的一致性检查步骤完成的。
Leader 针对每个 follower 都维护一个 next index,表示下一条需要发送给该follower 的日志索引。当一个 leader 刚刚上任时,它初始化所有 next index 值为自己最后一条日志的 index+1。但凡某个 follower 的日志跟 leader 不一致,那么下次 AppendEntries RPC 的一致性检查就会失败。在被 follower 拒绝这次 Append Entries RPC 后,leader 会减少 next index 的值并进行重试。
最终一定会存在一个 next index 使得 leader 和 follower 在这之前的日志都保持一致。极端情况下 next index 为1,表示 follower 没有任何日志与 leader 一致,leader 必须从第一条日志开始同步。
针对每个 follower,一旦确定了 next index 的值,leader 便开始从该 index 同步日志,follower 会删除掉现存的不一致的日志,保留 leader 最新同步过来的。
根据上图的例子来看,现在的leader最新的日志文件保存的信息是:log index = 8;next index = 9,term = 3;set x = 4,然后第一个follower节点现在是落后了三个log index,当这个follower启动后,leader与它同步数据的步骤如下:
leader发送(3,9)的心跳给第一个follower,
follower收到(3,9)的请求之后发现自己的log信息为(3,5 + 1),并不相等,则返回拒绝的响应,
leader收到第一个follower的响应之后,然后向再次发送(3,8)的请求,
follower收到(3,8)的请求之后发现自己的log信息为(3,5 + 1),并不相等,则返回拒绝的响应,
leader收到第一个follower的响应之后,然后向再次发送(3,7)的请求,
follower收到(3,7)的请求之后发现自己的log信息为(3,5 + 1),并不相等,则返回拒绝的响应,
leader收到第一个follower的响应之后,然后向再次发送(3,6)的请求,
follower收到(3,6)的请求之后发现自己的log信息为(3,5 + 1),相等,则返回正确的响应,表示接受创建(3,6)位置数据同步的请求,
以此类推,follower将日志补满。
整个集群的日志会在这个简单的机制下自动趋于一致。此外要注意,leader 从来不会覆盖或者删除自己的日志,而是强制 follower 与它保持一致。
这就要求集群票选出的 leader 一定要具备“日志的正确性”,这也就关联到了前文提到的:选举上的限制。
4.安全性保证
目前为止我们描述的这套机制还不能保证每个节点的状态机会严格按照相同的顺序 apply 日志。想象以下场景:
- Leader 将一些日志复制到了大多数节点上,进行 commit 后发生了宕机。
- 某个 follower 并没有被复制到这些日志,但它参与选举并当选了下一任 leader。
- 新的 leader 又同步并 commit 了一些日志,这些日志覆盖掉了其它节点上的上一任 committed 日志。
- 各个节点的状态机可能 apply 了不同的日志序列,出现了不一致的情况。
因此我们需要对“选主+日志复制”这套机制加上一些额外的限制,来保证状态机的安全性,也就是 Raft 算法的正确性。
4.1 对选举的限制
我们再来分析下前文所述的 committed 日志被覆盖的场景,根本问题其实发生在第2步。Candidate 必须有足够的资格才能当选集群 leader,否则它就会给集群带来不可预料的错误。Candidate 是否具备这个资格可以在选举时添加一个小小的条件来判断,即:
每个 candidate 必须在 RequestVote RPC 中携带自己本地日志的最新 (term, index),如果 follower 发现这个 candidate 的日志还没有自己的新,则拒绝投票给该 candidate。这里并不是已提交到状态机的日志index,而是日志序列里面的index。
Candidate 想要赢得选举成为 leader,必须得到集群大多数节点的投票,那么它的日志就一定至少不落后于大多数节点。又因为一条日志只有复制到了大多数节点才能被 commit,因此能赢得选举的 candidate 一定拥有所有 committed 日志,至于未提交的日志,稍后一定会变为提交状态,因为可以选出来的Leader一定是日志最多,未提交的日志一定是复制超过半数。
因此我们才会断定地说:Follower 不可能比 leader 多出一些 committed 日志。
比较两个 (term, index) 的逻辑非常简单:如果 term 不同 term 更大的日志更新,否则 index 大的日志更新。
Candidate的日志长度要等于或者超过半数节点才能选为Leader
当Leader故障时,Followers上日志的状态很可能是不一致的。有的多有的少,而且Commit Index也不尽相同。
为什么不是检查Commit Index?
因为Leader故障时,很有可能只有Leader的Commit Index是最大的。
例:如下图黑色框是主节点
这里我们先不看日志的复制,只看选举。
- a阶段 S1是term2的主节点并且写入了然后同步给了S2,其他节点仍然未同步,然后S1就挂了。
- b阶段S5成为了term3的主节点,因为S2只能投自己一票,因为他日志最多,但是会拒绝S3,S4,S5这3个节点的拉票,因为他们的日志没自己多,所以b阶段可能S5成为了term3的Leader。
- c阶段S5挂了,我们剩余S1,2,3,4四个节点,那么S3,S4最多可以获取到2票,因为S1,S2日志比他多,他永远不会投S3,S4,所以接下来的Leader会在S1,S2中选举出来,因为他们可以获取到最多4票,我们的S1最终赢得了选举成为term4的Leader。
- d阶段S1挂了我们剩余S2,3,4,5四个节点,我们S2,S3,S5都有可能成为leader,看谁先苏醒,因为谁先苏醒意味着谁可以先获取到3票,但是S5是一定不会投票给S2,S3的。因为他最后一条日志是term3,index2,比S2,S3都高。所以阶段D我S2,3,5都有可能成为Leader,下图就是S5成为Leader,然后日志覆盖,这样会有问题。
4.2 对提交的限制
除了对选举增加一点限制外,我们还需对 commit 行为增加一点限制,来完成我们 Raft 算法核心部分的最后一块拼图。
回忆下什么是 commit:
当 leader 得知某条日志被集群过半的节点复制成功时,就可以进行 commit,committed 日志一定最终会被状态机 apply。
所谓 commit 其实就是对日志简单进行一个标记,表明其可以被 apply 到状态机,并针对相应的客户端请求进行响应。
然而 leader 并不能在任何时候都随意 commit 旧任期留下的日志,即使它已经被复制到了大多数节点。Raft 论文给出了一个经典场景:
上图从左到右按时间顺序模拟了问题场景。
阶段a:S1 是 leader,收到请求后将 (term2, index2) 只复制给了 S2,尚未复制给 S3 ~ S5。
阶段b:S1 宕机,S5 当选 term3 的 leader(S3、S4、S5 三票),收到请求后保存了 (term3, index2),尚未复制给任何节点。
阶段c:S5 宕机,S1 恢复,S1 重新当选 term4 的 leader,继续将 (term2, index2) 复制给了 S3,已经满足大多数节点,我们将其 commit。
阶段d:S1 又宕机,S5 恢复,S5 重新当选 leader(S2、S3、S4 三票),将 (term3, inde2) 复制给了所有节点并 commit。注意,此时发生了致命错误,已经 committed 的 (term2, index2) 被 (term3, index2) 覆盖了。(这是因为S5可以满足作为主的一切条件:1. term = 3 > 2(S5先苏醒term+1),2. 最新的日志为(3,2),比大多数节点(如S2/S3/S4的日志都新),然后S5会将自己的日志更新到Followers,于是S2、S3中已经被提交的日志(2,2)被截断了,这是致命性的错误,因为一致性协议中不允许出现已经应用到状态机中的日志被截断)
Leader 只允许 commit 包含当前 term 的日志。
针对上述场景,问题发生在阶段c,即使作为 term4 leader 的 S1 将 (term2, index2) 复制给了大多数节点,它也不能直接将其 commit,而是必须等待 term4 的日志到来并成功复制后,一并进行 commit。
阶段e:在添加了这个限制后,要么 (term2, index2) 始终没有被 commit,这样 S5 在阶段d将其覆盖就是安全的;要么 (term2, index2) 同 (term4, index3) 一起被 commit,这样 S5 根本就无法当选 leader,因为大多数节点的日志都比它新,也就不存在前边的问题了。
以上便是对算法增加的两个小限制,它们对确保状态机的安全性起到了至关重要的作用。
5.日志压缩
我们知道 Raft 核心算法维护了日志的一致性,通过 apply 日志我们也就得到了一致的状态机,客户端的操作命令会被包装成日志交给 Raft 处理。然而在实际系统中,客户端操作是连绵不断的,但日志却不能无限增长,首先它会占用很高的存储空间,其次每次系统重启时都需要完整回放一遍所有日志才能得到最新的状态机。
因此 Raft 提供了一种机制去清除日志里积累的陈旧信息,叫做日志压缩。
快照(Snapshot)是一种常用的、简单的日志压缩方式,ZooKeeper、Chubby 等系统都在用。简单来说,就是将某一时刻系统的状态 dump 下来并落地存储,这样该时刻之前的所有日志就都可以丢弃了。所以大家对“压缩”一词不要产生错误理解,我们并没有办法将状态机快照“解压缩”回日志序列。
注意,在 Raft 中我们只能为 committed 日志做 snapshot,因为只有 committed 日志才是确保最终会应用到状态机的。
上图展示了一个节点用快照替换了 (term1, index1) ~ (term3, index5) 的日志。
快照一般包含以下内容:
- 日志的元数据:最后一条被该快照 apply 的日志 term 及 index
- 状态机:前边全部日志 apply 后最终得到的状态机
当 leader 需要给某个 follower 同步一些旧日志,但这些日志已经被 leader 做了快照并删除掉了时,leader 就需要把该快照发送给 follower。
同样,当集群中有新节点加入,或者某个节点宕机太久落后了太多日志时,leader 也可以直接发送快照,大量节约日志传输和回放时间。
同步快照使用一个新的 RPC 方法,叫做 InstallSnapshot RPC。