文章目录
- [1. Raft背景](#1. Raft背景)
- [2. Raft算法优化思路](#2. Raft算法优化思路)
- [3. 领导者选举(Leader Election)](#3. 领导者选举(Leader Election))
-
- [3.1 Raft 角色](#3.1 Raft 角色)
-
- [1. 领导者(Leader):](#1. 领导者(Leader):)
- [2. 跟随者(Follower):](#2. 跟随者(Follower):)
- [3. 候选者(Candidate):](#3. 候选者(Candidate):)
- [3.2 任期](#3.2 任期)
- [3.3 随机超时](#3.3 随机超时)
- [3.4 通信方式](#3.4 通信方式)
- [3.5 选举流程](#3.5 选举流程)
-
- [3.5.1 选举详细流程下面以三个节点的集群来演示下选举的详细流程](#3.5.1 选举详细流程下面以三个节点的集群来演示下选举的详细流程)
- [3.5.2 多Candidate选举](#3.5.2 多Candidate选举)
- [3.5.3 平票问题](#3.5.3 平票问题)
- [3.5.4 脑裂问题](#3.5.4 脑裂问题)
- [4. 日志复制(Log Replication)](#4. 日志复制(Log Replication))
-
- [4.1 日志](#4.1 日志)
- [4.2 日志复制过程](#4.2 日志复制过程)
- [4.3 日志一致性](#4.3 日志一致性)
-
- [4.3.1 一致性检查](#4.3.1 一致性检查)
- [5. 安全性](#5. 安全性)
-
- [5.1 对选举的限制](#5.1 对选举的限制)
- [5.2 对提交的限制](#5.2 对提交的限制)
- [6. 节点变更问题](#6. 节点变更问题)
-
- [6.1 配置(configuration)](#6.1 配置(configuration))
- [6.2 节点变更可能带来的问题](#6.2 节点变更可能带来的问题)
- [6.3 节点变更策略](#6.3 节点变更策略)
-
- [6.3.1 串行更新](#6.3.1 串行更新)
- [6.3.2 单节点变更](#6.3.2 单节点变更)
- [6.3.2.1 单步成员变更法为什么可以解决集群节点变更带来的脑裂问题呢?](#6.3.2.1 单步成员变更法为什么可以解决集群节点变更带来的脑裂问题呢?)
- [6.3.3 两阶段切换集群成员配置](#6.3.3 两阶段切换集群成员配置)
- 阶段一
- 阶段二
- [1.**阶段一: C_old,new 日志尚未 commit**](#1.阶段一: C_old,new 日志尚未 commit)
- [2.**阶段二: C_old,new 已经 commit, C_new 下发之前**](#2.阶段二: C_old,new 已经 commit, C_new 下发之前)
- [3.**阶段三: C_new 已经下发,但尚未 commit**](#3.阶段三: C_new 已经下发,但尚未 commit)
- [4.**阶段四: C_new 已经 commit**](#4.阶段四: C_new 已经 commit)
- 7.小结

1. Raft背景
Paxos算法虽然理论上能够解决分布式的共识问题,但是其过于复杂,难以理解。实现 Paxos 算法的开源软件很少,比较有代表性的是Google的 Chubby。
正是由于 Paxos 算法的复杂性和实现难度,使得其在实际工程中的应用受到了限制。然而,分布式系统的发展迫切需要一种既高效又易于实现的分布式一致性算法。在这种背景下,Raft 算法应运而生,成为一种更具实用性和可读性的替代方案。
Raft 算法由斯坦福大学的 Diego Ongaro 和 John Ousterhout在2013 年发表的《In Search of an Understandable Consensus Algorithm》中提出。Raft 算法是一类基于日志复制的分布式共识算法 ,由于Raft 算法易于理解和实现,在提出后,迅速获得了广泛关注,并成为了分布式系统中实际应用最广泛的一致性算法之一。目前,已经有十多种语言的 Raft 算法实现框架,比较有代表性的有 etcd、Consul、CockroachDB等
所以说掌握了 Raft算法,就能比较轻松地处理绝大部分的一致性场景和需求,本文的大纲如下:

2. Raft算法优化思路
Raft算法为了达到易于理解的目的,主要做了两件事:
-
问题分解:将分布式共识问题拆分成主节点选举、日志复制、安全点,以及成员变更 4 个独立子问题逐一进行解决
-
状态简化:通过减少算法中需要考虑的状态数,使得算法更加清晰和易于理解
3. 领导者选举(Leader Election)
3.1 Raft 角色
在一个Raft集群中,每个节点有以下三种状态,一般也称这三种状态的节点为Raft集群的三种角色
1. 领导者(Leader):
a. 处理客户端请求
b. 管理和同步日志
c. 定期向 Follower 发送心跳(Heartbeat)信号,以表明自己仍然存活,并防止 Follower 发起选举
2. 跟随者(Follower):
a. 被动地响应 Leader 的日志同步请求
b. 响应 Candidate 发起的投票请求
c. 把客户端打到Follower的请求转发给Leader
3. 候选者(Candidate):
a. 在集群刚启动或 Leader 宕机时,Follower 节点可以转换为 Candidate 并发起选举
b. 当 Follower 长时间未接收到 Leader 的心跳信号时,会认为 Leader 可能已经失效,从而将自己状态转换为 Candidate 并发起选举 。Candidate 向其他节点请求选票,若获得超过半数节点的投票,就会成为新的 Leader
c. 如果选举胜出,Candidate 转变为 Leader;否则,如果有其他节点当选为 Leader,Candidate 会返回到 Follower 状态
节点状态转换如下图所示:

从上图可以看出,在集群启动时,所有的节点都处于Follower状态,如果在一段时间内,没有收到来自Leader的心跳信息,则Follower将切换成Candidate,然后发起投票,如果该Candidate收到 了多数(超过半数)的Follower的投票(包含该Candidate自己投给自己的一票),则该Candidate将切换成Leader。在选举过程中,如果该Candidate 发现有其他节点有比自己更新(即日志条目的任期号更高),它会自动放弃选举,并重新切回Follower。
一句话概括:系统中最多只有一个Leader,如果在某一段时间内没有Leader,Follower会通过选举投票的方式选出Leader。Leader会不停的给Follower发送心跳信息,保证自己的存活状态。如果Leader挂掉,Follower会切换成Candidate发起投票,重新选出Leader
3.2 任期
任期是一个整数,用于标识raft集群的一个时间段。这个跟国家行政类似,比如上一个五年是领导人 xx任期的五年,这个五年是领导人yy的任期,可以简单的理解为"朝代",用递增的整数表示。那对应到raft集群中,就可以简单的理解为,任期是某个节点处于leader的一个时间段。但这里需要明确一点,可能某些情况下,因为选票的分流,在选举期间内没有成功选出Leader,则会进入下一个任期。
任期示意图如下:

任期包含两个阶段:1. 选举阶段 2. 已选举出Leader的阶段。同上面所说,任期也可能只包含选举阶段,没有Leader,比如图中的任期3,这种情况下,会立即进入到下一个任期,开始新的选举。
任期具有如下特点:
-
每个节点都会保存当前的任期(即一个标识时间段的整数),并随着集群状态的变化进行更新
-
Follower等待Leader的心跳超时后,会推举自己为Candidate发起投票,此时会将当前任期编号加1。比如当前该Follower保存的任期为1,在推举自己为候选人激票时,会将任期编号增加到2
-
当一个节点发现自己保存的任期编号比另一个节点的任期编号小,它会主动更新自己的任期编号到最新的较大的任期编号,比如节点 A当前的任期编号是1,当收到来自节点 B 的请求投票的RPC 消息时,因为消息中包含了节点 B 的任期编号,且编号为2,那么节点A将把自己的任期编号更新为2
-
如果一个节点接收到一个比自己任期编号小的 RPC 请求,该节点会立即拒绝这个 RPC 请求(无论是投票请求还是日志追加请求)。这是因为这个请求的任期编号已经过时,代表着发出请求的节点拥有的是旧任期,不再被视为合法的领导者或候选者
-
如果一个Leader或者是Candidate发现自己的任期编号比其他节点小,该节点会立即退回到Follower,假设由于网络分区错误,集群中出现了两个Leader,LeaderA任期编号为4,LeaderB任期编号为5,当网络分区错误恢复后,LeaderA收到了来自LeaderB的心跳信息,LeaderA将退为Follower,接收LeaderB成为Leader
3.3 随机超时
Raft算法中的随机超时有以下两种情况:
-
Follower 等待 Leader 心跳信息的超时间隔是随机的
这里的随机化的超时机制可以防止多个 Follower 同时转换为 Candidate ,减少选举冲突
-
Candidate 等待选举结果的超时时间是随机的
当一个节点转换为 Candidate 并发起选举后,会等待其他节点的投票结果,这个等待时间是随机的 。如果在这个等待的时间段内,没有获得大多数选票,将再次随机设置一个等待时间,发起新一轮投票。这里的随机化的超时机制降低了多个 Candidate 同时发起选举的可能性
总的来说,在 Raft 算法中,随机超时机制是一个关键设计,保证在大多数情况下只有一个节点发起选举,避免多 Candidate选举带来的性能问题
3.4 通信方式
在 Raft 算法中,节点之间通过远程调用(RPC)进行通信,主要涉及以下三种类型的 RPC:
-
投票 RPC:由 Candidate节点在选举过程中向Follower发出
-
附加日志条目 RPC:Leader 节点在日志复制过程中将日志条目发送给其他 Follower 节点,同时也起到维持心跳的作用,确保 Leader 的存活状态
-
快照 RPC:当 Follower 的日志落后 Leader 太多时,Leader 会发送 Snapshot RPC 请求,通过快照的方式帮助 Follower 快速同步日志
3.5 选举流程

如上图所示:Follower在收到Leader心跳信息超时后,会推选自己为Candidate,将自身的任期+1,然后发起选举,如果在设置的时间内收到了多数选票,将晋升为新的Leader,如果没有获得足够多的选票,收到Leader的心跳包,则Candidate恢复为Follower角色
3.5.1 选举详细流程下面以三个节点的集群来演示下选举的详细流程
- 初始状态
初始时,每个节点的角色都是 Follower,任期Term为 0(假设任期编号从0开始),每个节点都设置了一个随机超时时间(节点A:100ms,节点B:120ms,节点C:160ms),如下图:

- 发起投票
由于节点A的随机超时的时间是设置的最小的,为100ms,所以在A在100ms后倒计时结束被唤醒,成为Candidate,并为自己发起投票,此时将自己的任期编号加1,变为1。先投自己一票,然后向其他的Follower发起投票RPC请求

- 响应投票
Follower节点B 和 C 收到 Candidate节点A的投票请求后,会如下处理:
a. 如果自身已经在任期编号为1的投票请求中投过票了,则会忽略该投票请求
b. 否则,将自己的选票投给Candidate,也就是节点A,并将自身保存的任期编号设置为1,然后重置随机超时时间
假设B 和 C 都没有在任期编号为1的票请求中投过票,此时都将选票投给A,并设置自身的任期编号为1,然后重置随机超时时间

- 结束投票
Candidate即节点A获得了大多数投票,成为Leader,然后Leader会不断的给Follower即节点B和C发送心跳信息,告知自己的存活状态,以防止其他节点发起新的选举篡权

3.5.2 多Candidate选举
从上面的选举过程可知,每个节点都会设置一个随机超时时间,这样可以降低了多个节点在同一时刻被唤醒成为Candidate的概率。但是也只能降低概率,并且由于系统可能存在网络延迟,所以仍然无法完全避免多个Follower同时成为Candidate发起投票,假设这里有两个Follower(A和B)同时被唤醒,转换为Candidate发起投票:
假设A和B设置的随机超时时间都是120ms,在A和B节点被同时唤醒之后,会各自为自己投上一票,然后开始向其他节点发送投票请求,假设节点C先收到A的投票请求,之后再收到B的投票请求,那样C将会把选票投给A,最终节点A获得两票(包含自己一票)成为Leader,而节点B只会获得一张选票(自己的一票),则会回退成Follower


3.5.3 平票问题
一般在集群中,节点的个数都会选择奇数个,很重要的一点就是防止两个Candidate同时发起选票获得相同票数,导致系统内无法选出Leader的情况。 但是由于系统可能出现故障,导致某个节点故障之后,依然可能会存在这个问题。假设集群中的节点是出现了偶数个,结果又会怎样呢?
假设集群中有A、B、C、D四个节点,其中A、B两个节点设置的随机超时时间都是120ms。现在假设A、B被同时唤醒,向其他节点发送投票请求,节点A和B在同一任期内竞选领导者时,由于每个节点在同一个任期内只能投票一次 ,A和B都已经投了自己的票,因此不会再给对方投票。然后节点C把票投给A,节点D把票投给B,这样节点A和B都获得了两张选票,出现了平票的情况,这种情况下是不会有Leader被选出来的,所有节点会恢复成Follower状态,重新设置随机超时时间,准备下一轮的选举,虽然会有下一次轮的选举,直到选出新的Leader,但是在这个过程中,集群都是处于不可用状态,所以选举的轮次越多,集群不可用状态越久,因此要尽量避免平票问题
节点A和节点B回退为Follower之后,重新设置随机超时时间,节点A50ms,节点B100ms,等待超时时间开启新一轮的选举:

3.5.4 脑裂问题
前面所说的情况是在集群完全正常的情况下,一个集群中只会存在一个Leader。假设在一个集群内发生了网络分区,形成了两个分区,选举情况将会怎样进行呢?
这里以5个节点的集群为例,集群节点A,B,C,D,E,节点A为集群Leader。假设发生了网络分区,[A,B,C]为一个分区,节点[D,E]为一个分区,由于A本身就是原集群的Leader,所以[A,B,C]分区内还是按照以前的集群模式A为Leader,向B、C节点发送心跳。而[D,E]由于发生了网络分区,收不到A节点的心跳信息了,假设D节点设置的随机超时时间较短,那么到时间后,会成为Candidate,发起投票,在标准 Raft 里,分区到 [D,E] 这种少数派(2/5)是选不出 Leader 的。因为当 D 变成 Candidate 发起 RequestVote 时,最多只能拿到 D、E 两票,达不到 多数派 3/5,所以选举不会成功;D 会反复超时重试,但仍然无法当选。

脑裂问题如何解决?
其实在网络恢复后,虽然有了两个Leader,Leader都会向其他的节点发送心跳信息,这里A和C会相相收到对方发送的心跳信号,但是在A节点收到C节点发送的心跳之后,会发现携带的任期比自身保存的任期要大,所以A节点会退成Follower,集群会再次恢复成只有一个Leader的状态

A 脑裂期间的写入,最终会被丢弃(回滚),但这些写本来就不应该对外成功。
4. 日志复制(Log Replication)
raft算法中第二个很重要的子问题就是日志复制,日志复制(Log Replication)是保证整个集群中的所有节点(follower)一致地存储相同状态的核心机制 。它的主要目标是通过将客户端提交的指令(通常是状态变化操作)复制到每个节点的日志中,确保所有节点都达成一致的状态,即一致性
4.1 日志
在raft日志其实是一种数据格式,主要用于存储客户端的一系列操作指令,日志由三部分组成,分别是:索引值(Log index)、任期编号(Term)、指令(Command)

-
索引值:日志条目对应的索引值,用来标识第几条日志,是一个连续单调递增的整数
-
任期编号:创建这条日志条目的 Leader(领导者)的任期编号
-
指令:客户端发起请求需要执行的指令,例如指令 X <- 2 表示将 X 变量赋值为 2
一条日志也叫日志项,从上图可以看出,在一个Leader的任期内,可以有多个日志项,比如任期1内有3个日志项,任期3内有4个日志项
日志还对应有两个状态:committed(已提交)和 applied(已应用)
-
committed:针对的是日志,对应于某个日志项被成功复制到集群的大多数节点之后,这个日志项就处于committed状态,比如索引值1-7所对应的日志项均处于committed状态,因为他们都被复制到了大多数节点
-
applied:针对的是状态机即节点,节点要将日志真正应用到状态机,即真正改变了节点上对应变量的值
4.2 日志复制过程
集群中只有Leader会跟客户端交互,接收客户端的指令,而这些指令除了要在客户端执行以外,还需要通过日志复制的方式将这些指令复制到各个Follower节点,以保证集群的一致性。

日志复制的过程可以总结如下:
-
Leader接收到客户端请求,请求中的指令创建一个新的日志项,然后追加(append)到当前本地日志中(此时Leader中该日志项的状态为uncommitted)
-
Leader通过日志复制RPC请求将该日志项复制其他(Follower)节点(此时在各个Follower中该日志项的状态为uncommitted)
-
当Leader确认将日志项成功复制到大多数节点后,Leader 会将该日志项标记为 committed ,之后Leader会将该日志项应用到自己的状态机,即真正执行指令,修改对应的值
-
Leader将执行结果返回给客户端
-
Leader通过心跳或新的日志复制请求将提交了该日志项的状态同步给Follower,如果Follower发现Leader已提交了该日志项,而自己还没有将该日志项应用至状态机,则会将该日志项应用至自己的状态机中
-
如果Follower节点出现宕机或者由于网络丢包,Leader 会通过不断重试发送日志复制请求来确保日志条目最终复制到Follower上

可以看出,在日志复制过程中,只要有半数以上的处于正常工作的状态,整个系统就可用,假如在复制日志的过程中,出现了节点宕机、进程中断等问题,可能导致日志不一致,这种情况会怎么处理呢?
4.3 日志一致性
从前面的日志复制过程可以看出,在日志复制过程中,只要有半数以上的处于正常工作的状态,整个系统就可用,假如在复制日志的过程中,出现了节点宕机、进程中断等问题,可能导致日志不一致,这种情况会怎么处理,怎么来保证各个节点日志的一致性呢?
首先看一下raft日志的特点,Raft 日志具体如下两个特性:
-
如果不同日志中的两个日志项有相同的任期编号和索引值,那么这两个日志项一定有相同的指令
-
如果不同日志中的两个日志项有相同的任期编号和索引值,那么这两个日志项之前的所有日志项也全部都相同
通过这两个特性其实可以看出,只要同步到位的日志都是一致的,在raft算法中,其实是以领导者日志为准来实现日志的一致性的,主要包括两个步骤:
-
Leader通过日志复制 RPC请求的一致性检查,找到 Follower节点上与自己具有相同日志项的最大索引值(在该索引值之前的日志项,Leader和Follower是一致的,之后不一致)
-
Leader强制 Follower更新不一致日志条目,Leader强制Follower将该索引值之后的所有日志项删除,并将Leader该索引值之后的所有日志项同步给Follower
4.3.1 一致性检查
Leader为了找到Follower节点上与自己具有相同日志项的最大索引值,每次日志复制请求除了发送该日志项之外,还要发送一些额外信息,这里引入两个新的概念:
-
prevLogIndex:Leader当前需要复制的日志项的前一个日志项的索引值
-
prevLogTerm:Leader当前需要复制的日志项的前一个日志项的任期编号
如下图,假设Leader当前需要将索引值为7的日志项发送复制到Follower,则prevLogIndex为6,prevLogTerm为3

下面以一个具体的例子来看
当前Leader的最大日志项索引为10,假设当前Leader需要将10号日志项复制给Follower,步骤如下:
-
Leader将索引值为10的日志项通过日志复制RPC请求发送给Follower,同时还会发送该日志项的prevLogIndex(9)和prevLogTerm(3),Follower收到消息后,判断自己没有索引值为9的日志,因此拒绝更新日志并向Leader失败信息
-
Leader收到Follower的失败响应后,将日志项的索引值-1,接着发送索引值为9的日志项并且携带prevLogIndex(8)和prevLogTerm(3)给Follower,Follower发现自己索引值为8的日志项中任期为4,指令为N<-5,和Leader发过来的日志项不一样,再次拒绝更新,向Leader响应失败
-
直至需要复制索引值为7的日志项时,Follower发现同步过来的prevLogIndex为6,prevLogTerm为3,与自己在索引值为6的日志条目相同(任期也是3),则接收该日志复制RPC请求
-
Leader收到跟Follower的成功响应后,Leader通过日志复制RPC消息,强制Follower复制并更新覆盖索引值为7之后的内容。保证Follower与Leader的日志状态一致
5. 安全性
前面分析了raft算法是如何进行Leader选举以及日志复制的,但是这套机制还不能够完全保证每个节点都会严格按照相同的顺序 apply 日志,这就可能造成各个节点的状态机不一致
假设有如下场景:
-
Leader 将某些日志项复制到了大多数节点上,在commit后发生了宕机
-
某个Follower 尚未被复制这些日志项,但是在Leader挂了之后,进行的选举中,该Follower成为了Leader
-
这个新的 leader 又同步并提交了一些新的日志,这些日志覆盖掉了其它节点上的上一任提交的日志
-
各个节点在进行apply时可能应用了不同的日志序列,导致出现不一致
所以要想保证各个节点状态机一致性,光有Leader选举和日志复制策略还是不够的,还要有一些额外的措施,这就是本小节要讨论的安全性限制策略
5.1 对选举的限制
回顾上述场景,为什么会出现日志被错误地覆盖,导致不一致。根本问题其实就是在第二步,一个落后的Follower(还没被复制上一任Leader的最新日志)就当选了新的Leader。那么他接下来的操作肯定会以自己的日志为准,导致集群中其它节点的日志被覆盖掉。所以这个Candidate来竞选Leader其实是不合格的,Candidate必须有足够的资格才能当选 leader,所以在Candidate发起选举投票的时候,可以加一个条件限制:
每个Candidate发起投票RPC请求时必须在请求体中包含自己本地日志最新的任期编号(term)和索引值(index),当Follower收到Candidate的投票请求时,如果发现该Candidate的日志还没有自己的新,则拒绝投票给该Candidate
在加了这个条件之后,再结合上本身Candidate就必须赢得集群大多数节点的投票才会成为Leader,同时一条日志只有复制到了大多数节点才能被commit,所以Leader就一定拥有所有committed日志。也就是说:Follower 不可能比 leader 多出一些 committed 日志
比较日志新旧的策略也很简单:(term, index)比较,先比较term,term更大的日志更新,term相同的话,index大的日志更新
5.2 对提交的限制
单独的对选举加一定限制还不能保证日志的正确性,不正确的提交(commit)同样会带来问题。回顾一下commit的作用:
当 leader 得知某条日志项被成功复制到集群的大多数节点后,就可以进行commit,表明该日志项可以被apply生效到状态机,committed(已提交)日志项一定最终会被状态机 apply。
但是不正确的commit也可能带来日志覆盖的问题,考虑如下场景:

图中的方框内的数字表示该日志项的任期term ,对应上面一栏的数字表示该日志项的索引值index ,一条日志项用(term, index)表示,从左到右随着时间集群状态更如下:
-
阶段a:S1是leader,收到请求后将日志项(2, 2)只复制给了S2,尚未复制给S3,S4,S5
-
阶段b:S1宕机,S5选举获得了S3、S4、S5三票,当选任期(term)为3的leader,收到客户端请求后保存了日志项(3,2),尚未复制给任何节点。【即便 S5 没复制到 (2,2),它也可能收到过 S1 的心跳(AppendEntries),心跳里带着 term=2,所以当上leader任期为3】
-
阶段c:S5宕机,S1恢复,S1重新当选term为4的leader,继续将日志项(2,2)复制给了S3,已经满足大多数节点(S1,S2,S3),于是S1将该日志项commit
-
阶段d:S1又宕机,S5恢复,S5选举获得了S2、S3、S4三票,重新当选【因为先比 lastLogTerm,再比 lastLogIndex】,将日志项(3,2)复制给了所有节点并commit。注意,此时发生了日志覆盖错误,已经committed的日志项(2,2)被(3,2)覆盖了
为了避免这个错误,需要在日志的提交阶段也加一个限制:
Leader只允许commit包含当前任期(term)的日志
有了这个限制,再来模拟一下上述场景,在加了这个限制后,其实上述阶段的阶段c就出错了,阶段c虽然S1恢复当选了term4的Leader,但是其并不能直接将日志项(2,2)commit,因为S1当前的日志为(4,3),必须等到(4,3)成功复制后才能commit
一旦有了这个限制,在阶段c就只有两种情况了:
-
日志项(2,2)始终没有被commit,这样S5在阶段d将其覆盖就是安全的
-
日志项(2,2)连同(4,3)一起被成功commit,这样的话,在阶段d S5就无法成功当选Leader (对选举的限制,当Follower收到Candidate的投票请求时,如果发现该Candidate的日志还没有自己的新,则拒绝投票给该Candidate),就不存在上述问题了

必须提交当前任期的条目 (4,3),才能让 (2,2) 安全地变成 committed。
6. 节点变更问题
集群中的节点数量并不是恒定不变的,比如随着业务的发展,集群需要扩容或者是缩容,那么就需要适当的增加或者是减少机器节点,又或者是某些几点出现了故障,需要变更机器等等,都需要变更集群的节点数量。Raft 算法如何处理集群成员节点变更的问题呢?
6.1 配置(configuration)
在介绍节点变更过程之间,需要先明确一个概念:配置(configuration)
在raft算法中,使用配置(configuration)来表示集群的节点集合,比如某个集群由A、B、C三个节点构成,那么集群的配置就是[A,B,C],在稳定的状态下,所有节点的配置都相同。从这里就可以知道,每个节点是通过这个配置信息来获取集群状态的,比如在选举、日志同步过程中,集群中有哪几个Follower,Leader需要向哪几个节点发送RPC通信都需要通过配置(configuration)来获取
6.2 节点变更可能带来的问题
集群中节点的变更很有可能会给集群的一致性带来影响,主要是会影响集群的多数派 。我们知道在raft中很多场合都需要多数派的支持,比如在投票中,只有当一个节点收到多数派投票(超过半数)才会成为Leader,在日志同步中,只有当Leader确认将日志项成功复制到多数派(超过半数)节点后,才会将该日志项标记为committed,类似的场景还有很多。
而集群节点的变更最主要的就是会影响到多数派,比如在一个三个节点的集群中原本只要2个节点就可以达到多数派,假设现在往集群新增两个节点,则需要三个节点才能达到多数派。
在raft集群中,同样是由Leader节点负责同步集群的配置信息,当集群中出现节点变更,几乎不可能保证所有节点同时进行配置的变更,由于网络延后等因素导致一部分节点配置已变更,另一部分没有变更在所难免,所以就会导致集群中部分节点使用的新的配置信息C_new,而有的节点使用老的配置信息C_old。
假设有如下场景中,原来集群有三个节点[server1,server2,server3],现在向集群新增了两个节点server4和server5。

当处于画框的时间点时,假如此时出了选举,server1和server2用的是旧的配置文件C_old,因此他们会从节点[server1,server2]中选出Leader;而节点server3、server4和server5已经是新的配置文件C_new,他们会从节点[server3,server4,server5]中选出新的Leader。此时集群就可能会有两个Leader,出现脑裂问题
6.3 节点变更策略
前面分析了在集群变更的时候很可能导致集群的一致性出现问题,那么又有什么策略可以解决这个问题呢?主要有以下这几种解决方案
6.3.1 串行更新
这种方法就是先将集群原来所有节点关闭,更新其配置后,再启动新的集群,显然这种方法很安全,可以保证集群始终只有一个Leader,但是这种方法会导致每次成员变更时都需要关闭集群,导致集群无法对外提供服务,对于高可用的业务场景显然不适用
6.3.2 单节点变更
每一次集群的变动只能新增或者删除一个节点,假设集群需要变更多个节点,那么需要分多个步骤来完成,每次只变更一个节点。比如原集群有3个节点,先需要扩容到5个节点,那么需要分两步,第一步扩容到4个节点,再由4个节点增加到5个节点,所以单节点变更法也叫单步成员变更法
详细步骤如下:
-
客户端向Leader提交一个集群成员变更请求,请求的内容新增或者删除节点,以及服务节点的地址信息
-
Leader在收到请求之后,向本地日志中追加一条配置信息日志,其中包含了新的集群配置信息C_new,之后,这条新的配置信息会随着RPC请求(AppendEntries)同步给所有的Follower节点,注意:配置信息日志被添加到日志中是立即生效(不需要commit之后再生效)
-
当配置信息日志被复制到新的配置信息C_new所标识的所有节点的多数派节点后,就commit该日志
提交配置日志的作用:
-
日志提交之后,才可以响应客户端,完成集群节点变更
-
标志着本轮节点变更已经结束,可以开始下一轮的节点变更
-
如果集群中有删除节点,那么提交日志之后,被删除的节点可以关机了
6.3.2.1 单步成员变更法为什么可以解决集群节点变更带来的脑裂问题呢?
这里可以枚举出奇偶节点情况下,新增或者删除节点的情况

以左上图(4 节点 → 5 节点)为例:
蓝色虚线框写着 3 of 4:表示 旧集群 C_old 的一个多数派集合(4 个节点里任取 3 个就算多数)
红色虚线框写着 3 of 5:表示 新集群 C_new 的一个多数派集合(5 个节点里任取 3 个就算多数)
从上图可以看出,不管原集群节点数量是奇数还是偶数,也不管是在原集群上新增一个节点还是删除一个节点,在集群的节点数变更之后,原集群的多数派和新集群的多数派一定存在交集,那么在同一个任期内,原集群C_old和新集群C_new中交集的那个节点只会进行一次投票,要么投票给C_old,要么投票给C_new,这样就避免同一个任期出现了两个Leader的现象
需要注意的是:单节点变更法虽然简单,很好理解,但是也有其缺陷,这种方式在串行化的方式下可以保证一个集群只能有一个Leader,但是并发执行单节点变更,可能会出现一次单节点变更还没完成,新一次单节点变更已经执行,导致集群出现脑裂问题 ,这里不过多阐述,感兴趣的话可以看看Raft论文




6.3.3 两阶段切换集群成员配置
虽然Raft论文中认为单步变更是更简单的办法,但节点变更有一定的问题,但是现在主流的实现都使用了Joint Consensus(联合共识)算法来完成集群变更,也就是小标题所说的两阶段切换集群成员配置
具体流程如下:
阶段一
- a. 客户端将新配置C_new发送给Leader,Leader取旧配置C_old和新配置C_new的并集(称为联合配置(表示为C_old,new)) 并立即apply即生效
- b. Leader将配置C_old,new包装成日志通过AppendEntries请求复制到Follower节点
- c. Follower收到C_old,new后立即生效,立刻应用该配置作为当前节点的配置,当C-old,new的大多数节点(即C_old的大多数节点和C_new的大多数节点)都切换后,leader将commit该日志
阶段二
- a. 紧接着Leader将新配置C_new包装成日志通过AppendEntries请求复制到Follower节点
- b. Follower收到C-new后立即生效,如果此时发现自己不在C-new列表,则主动退出集群
- c. Leader确认C-new的大多数节点都切换成功后,给客户端发送执行成功的响应
几个概念详细解释一下:
-
C_old,new:比如C_old为[A, B, C],C_new为[B, C, D],那么C_old,new就为他们的并集[A, B, C, D]
-
C_old,new的大多数节点:是指C_old中的大多数和C_new中的大多数,如下表所示,第一行因为C、D节点还没有被复制到日志,导致C_new的多数派不能达成,所以该日志不能被commit


上图展示了用两阶段提交方法集群节点变更过程中的几个过渡期,虚线表示已经创建但尚未 commit 的成员配置日志,实线表示 committed 的成员配置日志,在每一个时期,每一个任期下都不可能出现两个 Leader
原因如下:
1.阶段一: C_old,new 日志尚未 commit
在这个阶段,集群中节点可能处于旧配置 C_old 下,也有可能处于联合配置 C_old,new 下,但无论这两种情况的哪一种,只要原 leader 发生宕机,新 leader 都必须得到旧配置 C_old 下大多数节点的投票,所以不会出现两个 Leader
强调一下:C_old 节点发起选举需要 C_old 的大多数,C_old,new 发起选举需要 C_old 和 C_new 两者的大多数

2.阶段二: C_old,new 已经 commit, C_new 下发之前
在这个阶段,C_old,new 已经被 commit,表示联合配置 C_old,new 已经被应用到集群的大多数节点上(C_old 的大多数节点和 C_new 的大多数节点),因此当 leader 宕机时,新选出的 leader 一定是已经拥有 C_old,new 的节点,否则票数通不过,所以不可能出现两个 leader

3.阶段三: C_new 已经下发,但尚未 commit
在这个阶段,集群中可能有三种节点,集群中节点可能处于旧配置 C_old 下,也有可能处于联合配置 C_old,new 下,还有可能处于新配置 C_new 下,但由于已经经历了阶段 2,因此 C_old 节点不可能再成为 leader 。而无论是 C_old,new 还是 C_new 节点发起选举,都需要经过大多数 C_new 节点的同意,因此也不可能出现两个 leader

4.阶段四: C_new 已经 commit
在这个阶段,C_new 已经被 commit,因此只有 C_new 节点可以得到大多数选票成为 leader,所以也不会出现两个 Leader,至此,集群已经安全地完成了这轮变更,可以继续开启下一轮变更了

通俗点说:
阶段1:旧多数说了算
阶段2:旧多数 + 新多数都得同意
阶段3:仍然按"旧多数 + 新多数"(直到新配置 commit)
阶段4:新多数说了算
7.小结
Raft算法将共识问题分解成了多个相对独立的子问题,从而简化了共识的实现。其主要流程包括领导者选举以及日志复制 ,集群先选举出leader,然后leader负责复制,提交日志 。当然为了在任何异常情况下系统不出错,还需要满足一定的安全性,几乎需要对Leader Election,Log Replication两个子问题加一些限制条件。最后集群都是动态变化的,所以raft算法也应用了单节点变更以及联合共识机制来保证集群节点安全的变更
之后我会持续更新,如果喜欢我的文章,请记得一键三连哦,点赞关注收藏,你的每一个赞每一份关注每一次收藏都将是我前进路上的无限动力 !!!↖(▔▽▔)↗感谢支持!