Raft_选举与日志复制流程
导语: 在上一篇中, 我们拆解了 Raft 的核心组件和节点状态. 但静态的结构无法体现分布式系统的魅力, 今天我们要让这些齿轮转动起来. 在 Raft 的世界里, 系统大部分时间都处于"太平盛世"(正常承接请求), 此时的核心任务是日志复制; 而一旦"国王驾崩"(Leader 宕机), 系统就会进入危机时刻, 触发领导者选举. 本文将带你穿梭于这两种状态之间, 并在微观层面透视节点状态参数的每一次跳动.
一、 正常承接请求:日志复制的流转艺术
当集群稳立了一个 Leader 后, 所有的目光都聚焦在如何高效、安全地把客户端的指令同步给整个集群.
1. 日志复制的全流程与状态演变
我们以客户端发送一条 SET X 10 的指令为例, 来看看底层的状态参数是如何翻涌的.
深入节点状态的改变细节:
- 接收与追加: Leader 收到请求, 将其封装为一个 Entry.
- Leader 状态改变: 将该 Entry 追加到自己的
log[]中 (持久化状态改变, 必须落盘). 此时commitIndex和lastApplied不变.
- Leader 状态改变: 将该 Entry 追加到自己的
- 广播 RPC: Leader 向所有 Follower 发送
AppendEntries.- Leader 状态改变: 读取自身的
nextIndex[]来决定发给 Follower 哪一条日志.
- Leader 状态改变: 读取自身的
- Follower 接收与落盘: Follower 收到日志, 进行一致性校验(Log Matching, 后文详解).
- Follower 状态改变: 校验通过后, 将日志追加到自己的
log[]中 (持久化状态改变), 并向 Leader 返回 ACK.
- Follower 状态改变: 校验通过后, 将日志追加到自己的
- Leader 统计与提交: Leader 收到多数派 ACK.
- Leader 状态改变:
- 更新这几个 Follower 对应的
matchIndex[](已复制的最高索引)和nextIndex[](Leader独有易失性状态改变). - 发现多数派的
matchIndex已经达到当前日志索引, 于是推进自己的commitIndex(易失性状态改变).
- 更新这几个 Follower 对应的
- Leader 状态改变:
- Leader 应用与响应:
- Leader 状态改变: 发现
commitIndex > lastApplied, 于是把日志应用到业务层(状态机), 并将lastApplied推进到与commitIndex对齐 (易失性状态改变). 最后回复客户端.
- Leader 状态改变: 发现
- 异步通知 Follower: 在下一次心跳或日志复制中, Leader 会带上新的
commitIndex.- Follower 状态改变: Follower 发现 Leader 的提交进度超前了, 于是推进自己的
commitIndex和lastApplied, 应用状态机 (易失性状态改变).
- Follower 状态改变: Follower 发现 Leader 的提交进度超前了, 于是推进自己的
2. Log 文件组织结构
Raft 的日志并不是简单的文本流, 它在磁盘上通常被组织为一个结构化的数组. 每一条 Log Entry 必须包含三个核心元素:
- Index(索引): 该日志在整个日志序列中的单调递增编号.
- Term(任期): 创建该条日志时, 当时 Leader 的任期号. (这是解决后续所有冲突的终极比对维度)
- Command(指令): 真正要应用到状态机的业务操作(如
SET X 10).
3. 提交(Commit)的严谨定义
在 Raft 中, "提交"是一个极其神圣的词. 定义: 当一条日志被 Leader 安全地复制到了集群的大多数(Majority, N/2+1)*节点上, 并且该日志是*当前任期由该 Leader 创建的, 这条日志就被定义为"已提交(Committed)". 意义: 一旦日志被提交, Raft 保证它永远不会被覆盖或丢失, 即使发生无数次极端宕机, 它最终也一定会出现在所有节点的状态机中.
4. Log Matching 特性(绝对的连续性保证)
Raft 是如何保证 Follower 不会瞎写日志的?这归功于 Log Matching(日志匹配特性):
- 如果不同节点上的两条日志拥有相同的 Index 和 Term, 那么它们存储的 Command 一定相同.
- 更狠的保证: 如果不同节点上的两条日志拥有相同的 Index 和 Term, 那么它们之前所有的日志都完全一模一样.
如何实现? Leader 每次发 AppendEntries 时, 都会带上前一条日志的 prevLogIndex 和 prevLogTerm. Follower 收到后, 会先去自己的日志库里查:
- "我本地有
prevLogIndex这个位置的日志吗?" - "如果有, 它的 Term 和 Leader 传过来的
prevLogTerm一样吗?" 只要有一项对不上, Follower 直接拒绝这次追加!这就迫使 Leader 必须往回倒退找寻分歧点, 强制用 Leader 的日志覆盖 Follower, 保证了日志的绝对对齐.
二、 选举:危机时刻的权力交接
当系统风平浪静时, 大家都是 Follower. 但只要 Leader 的心跳停止跳动, 系统就会进入选举倒计时.
1. 触发选举的条件与场景
- 场景: Leader 机器掉电断网、Leader 所在的机房发生网络分区(Network Partition)、或者启动初始化时.
- 触发机制: 每个 Follower 内部都有一个 Election Timer(选举超时定时器). 只要收到 Leader 的心跳, 定时器就会被重置. 一旦定时器走完(超时), 该节点就认为 Leader 已死, 拉开选举大幕.
2. 选举过程与微观状态改变
- 揭竿而起(Follower -> Candidate):
- 状态改变:
- 节点身份变为 Candidate.
- 自身的
currentTerm增加 1 (持久化状态改变). - 给自己投一票, 即
votedFor设为自己的 ID (持久化状态改变). - 重置选举定时器.
- 状态改变:
- 四处拉票: Candidate 向其他所有节点广播
RequestVoteRPC.- 日志发挥的决定性作用(Leader Completeness 保证): Candidate 在拉票时, 必须在 RPC 中带上自己最后一条日志的
lastLogIndex和lastLogTerm. - 其他节点收到后, 会对比自己的日志. **规则是:只有当 Candidate 的日志比自己"新", 或者一样"新"时, 才会把票投给它. **(判断标准:Term 更大的更新; Term 相同则 Index 更大的更新). 这就保证了没同步完数据的落后节点, 绝对当不了 Leader.
- 日志发挥的决定性作用(Leader Completeness 保证): Candidate 在拉票时, 必须在 RPC 中带上自己最后一条日志的
- 投票处理:
- 被拉票节点的状态改变: 如果发现 Candidate 日志够新, 且自己在当前 Term 还没投过票, 就会投赞成票, 并将
votedFor设为该 Candidate 的 ID, 同时更新自己的currentTerm(持久化状态改变).
- 被拉票节点的状态改变: 如果发现 Candidate 日志够新, 且自己在当前 Term 还没投过票, 就会投赞成票, 并将
3. 获胜的判断条件
选举有三种可能的结果:
- 赢了: 获得了集群中多数派(N/2+1)的选票. 立刻晋升为 Leader, 开始发送心跳镇压其他 Candidate, 并初始化自己的
nextIndex[]和matchIndex[]. - 输了: 收到了别人发来的合法 Leader 心跳(且对方 Term >= 自己的 Term). 立刻退化为 Follower.
- 平局(Split Vote): 没人获得多数票. 等待定时器再次超时,
currentTerm再加 1, 开启下一轮.
4. 避免无限循环的投票分裂:随机选举超时
如果多个节点同时超时, 同时变成 Candidate, 它们可能会把票平分, 导致谁也选不上, 下一轮又同时发起, 陷入死循环. **Raft 的解法极度优雅:随机化. ** Raft 规定每个节点的 Election Timeout 是一个随机区间(通常是 150ms ~ 300ms). 这就像赛跑时故意让大家的起跑线错开. 总有一个节点会最先超时并拉票, 等其他节点还没醒过神来, 它就已经拿到多数票当选了.
三、 系统异常场景:解决日志不一致的终极博弈
Leader 宕机重选虽然解决了可用性问题, 但会遗留一个巨大的历史包袱:日志不一致.
1. 系统异常导致日志不一致的场景
设想这样一种混乱局面:一个节点当了 Leader, 收了日志还没同步就挂了; 另一个节点当了 Leader, 同步了一半又挂了. 经过几轮折腾, 集群的日志会变成什么样?
你可以看看下面这张图(这也是 Raft 论文中最著名的 Figure 7 场景再现):
========================================================================
场景:新任 Leader (A) 上任时, 面对一群日志千奇百怪的 Follower
------------------------------------------------------------------------
Index: 1 2 3 4 5 6 7 8 9 10
------------------------------------------------------------------------
Leader A: [T1] - [T1] - [T1] - [T4] - [T4] - [T5] - [T5] - [T6] - [T6] - [T6]
(真理源头)
Follower B: [T1] - [T1] - [T1] - [T4] - [T4] - [T5] - [T5] - [T6] - [T6]
(缺失日志:网速慢, 落后了一截)
Follower C: [T1] - [T1] - [T1] - [T4]
(严重缺失:很久以前就断网了)
Follower D: [T1] - [T1] - [T1] - [T4] - [T4] - [T5] - [T5] - [T6] - [T6] - [T6] - [T6]
(存在未提交的废弃日志:曾经是 T6 的 Leader, 多写了一条还没提交就挂了)
Follower E: [T1] - [T1] - [T1] - [T4] - [T4] - [T4] - [T4]
(存在严重冲突的脏日志:曾经是 T4 的 Leader, 断网期间自己傻傻收了请求, 其实都是废数据)
========================================================================
注: [Tx] 代表该日志条目是在任期 x 生成的.
2. 独裁统治:如何解决日志不一致?
在 Raft 中, 解决冲突的原则只有一条:Leader 永远是对的(Leader Append-Only 特性). 任何与 Leader 日志冲突的 Follower 日志, 都会被无情地截断和覆盖.
具体抹平冲突的流程如下:
- 上任初始化: Leader A 刚当选时, 先把所有 Follower 的
nextIndex都设为自己最新一条日志的后一个位置(即 Index=11). - 发生拒绝: Leader 发送带心跳的
AppendEntries(prevLogIndex=10, prevLogTerm=T6)给 Follower E. - Log Matching 发挥威力: Follower E 发现自己 Index=10 的位置根本没日志, 或者 Term 对不上, 直接拒绝.
- 回退摸底: Leader 收到拒绝, 毫不气馁, 把发给 E 的
nextIndex减 1(变成 10), 再发prevLogIndex=9的探测包. - 找到共识锚点: 就这样一路被拒绝一路回退, Leader 发送
prevLogIndex=4, prevLogTerm=T4时, Follower E 发现:"哎?这个我本地有, 对上了!" 于是返回 ACK. - 无情覆盖: Leader 找到了最后的一致点(Index=4). 在下一次 RPC 中, Leader 会把 Index 5 到 10 的所有正确日志一起打包发过去. Follower E 会把自己 Index 5 之后的那些脏日志全部删除(截断), 老老实实地写入 Leader 传来的新日志.
最终结果: 不管历史遗留问题有多复杂, 随着 Leader nextIndex 的不断回朔, 所有节点的日志最终都会被"强制刷机", 变得和当前 Leader 绝对一致. 这就是共识算法在混沌中重塑秩序的暴力美学!

浙公网安备 33010602011771号