Raft核心原理与工程实践_经典极端场景与 Raft 的完美解法
Raft 深水区探秘_经典极端场景与 Raft 的完美解法
导语: 在前面的文章中, 我们看到 Raft 是如何在 Leader 宕机时力挽狂澜的. 但是, 现实世界往往比单纯的宕机要复杂得多: 网络可能会被切断一半、节点可能会不断重启、甚至会出现跨越好几个任期的"幽灵日志". 今天, 我们将走进 Raft 的"深水区", 看看 Raft 协议是如何通过严密的数学逻辑, 将这些足以摧毁系统的致命异常一一化解的.
一、典型异常: 日志提交后, Leader 宕机
我们知道一旦有一个日志被提交, 就永远不会丢失. 那么如果一个 Leader 提交了日志之后, 立刻宕机了, 此时这个日志的操作如何写到状态机中呢? 那些还没有同步这条日志的 follower 如何同步这一条日志呢? 这三个问题, 刚好串联起了 Raft 协议中最核心的三个机制: 选举安全限制(Election Restriction)、状态机解耦以及日志匹配强制覆盖(Log Matching).
1. 为什么一旦被提交, 就永远不会丢失?
这是由 Raft 的“多数派交集(Quorum Intersection)”和“选举限制”共同在数学上保证的.
1. 多数派的数学交集 在 Raft 中, “已提交(Committed)”的唯一定义是: 这条日志已经被安全地写入了集群中大多数(Majority, 即 N/2+1)节点的本地磁盘中. 同时, Raft 的选举规则规定: 一个节点必须获得大多数节点的选票, 才能成为新 Leader. 根据数学的抽屉原理, 包含已提交日志的多数派 和 给新 Leader 投票的多数派, 这两个集合之间 必定至少存在一个重合节点.
2. 选举限制(Leader Completeness 铁律) Raft 在 Candidate 发起 RequestVote(拉票)RPC 时, 增加了一个强制性的校验规则:
- Candidate 在拉票时, 必须带上自己最后一条日志的
lastLogIndex和lastLogTerm. - Follower 收到拉票请求后, 会对比自己的日志. 如果 Follower 发现自己的日志比 Candidate 的还要新 (Term 更大, 或者 Term 相同但 Index 更大), Follower 会无情地投出反对票!
推演结论: 因为那个“重合节点”身上拥有最新被提交的日志, 如果某个 Candidate 身上没有这条被提交的日志, 它的日志就一定比“重合节点”旧. 那么, “重合节点”绝对不会给它投票. 因此, 这个 Candidate 永远凑不够多数票, 绝对当不上 Leader. 反过来说, 能当上新 Leader 的节点, 它的本地磁盘里必然已经包含了所有已提交的日志. 这就是日志永不丢失的终极秘密.
2. Leader 提交后立刻宕机, 如何写到状态机?
这个问题点出了 Raft 架构设计中的一个绝妙之处: 共识模块(日志落盘)与状态机(业务执行)是高度解耦的.
假设 Leader A 将 SET X 10 复制到了多数派(此时日志在逻辑上已提交), 但还没来得及执行 Apply 操作写到内存的 HashMap(状态机)里, 也没来得及通知客户端, 直接拔电宕机了.
接下来的流转如下:
- 新 Leader 接管: 按照第一点的逻辑, 新选出来的 Leader B 磁盘里绝对包含这条
SET X 10的日志. - 确认提交边界: Leader B 上任后, 由于之前的 Leader A 没来得及广播这条日志已提交, Leader B 初始时并不知道这条日志能不能应用. Raft 规定, 新 Leader 上任后, 必须在自己的当前 Term 内提交一条新日志 (如果客户端没请求, 通常会发一条隐式的空日志 No-Op), 以此来探明集群的存活状况.
- 推进 commitIndex: 当新 Leader B 在自己的 Term 内成功完成了一次多数派复制, 它就能确认: 包括
SET X 10在内的所有历史日志都绝对安全了. 于是, Leader B 会向前推进自己的commitIndex. - 异步应用(Apply): 一旦 Leader B 的
commitIndex覆盖了SET X 10这条日志, Leader B 内部的后台线程就会立刻将这条日志应用(Apply)到它自己的状态机中.
至于那个死掉的老 Leader A 的状态机?已经不重要了. 客户端因为之前没有收到 A 的响应(超时), 会向集群发起重试或查询, 此时新 Leader B 的状态机里已经有了 X=10, 系统对外表现出完美的一致性.
3. 还没同步这条日志的 Follower 如何同步?
那些在老 Leader 任期内因为网速慢、断网等原因, 没收到这条日志的少数派 Follower, 在新 Leader 上任后, 会面临被“强制刷机”的命运.
依靠的是 Raft 的 Log Matching(日志匹配特性) 机制:
- 新 Leader 宣誓主权: Leader B 上任后, 会为每一个 Follower 维护一个
nextIndex(初始值为 Leader 自己最后一条日志的 Index + 1). - 碰壁与回退: Leader B 向那个落后的 Follower C 发送心跳或日志复制(
AppendEntries)时, 会带上前一条日志的元数据信息. Follower C 一看, 本地压根找不到匹配的日志, 于是拒绝并回复False. - 精准定位: Leader B 收到
False后, 毫不气馁, 将发给 C 的nextIndex减 1, 再发一次. 如此循环, 直到找到 Follower C 磁盘里和 Leader 完全一致的那条历史日志(也就是“共识锚点”). - 无情覆盖与同步: 找到分歧点后, Leader B 会把分歧点之后的所有正确日志(包括那条已提交的
SET X 10)一次性打包发给 Follower C. Follower C 会删掉自己冲突的脏数据, 乖乖把缺失的日志补齐. - 应用状态机: 同步完日志后, 在下一次心跳中, Follower C 发现 Leader 的
commitIndex已经前进了, 于是 C 也跟着推进自己的commitIndex, 并将日志应用到自己的状态机.
二、 场景二: 网络分区与"任期怪兽"(幽灵节点)
这是分布式系统中最常见的灾难: 网络分区(Network Partition / 脑裂).
1. 灾难现场: 被孤立的节点疯狂自嗨
假设我们有一个 5 个节点的集群(A, B, C, D, E), A 是 Leader, 当前的 Term(任期)是 1. 突然, 机房网络交换机故障, 整个网络被劈成了两半:
- 大区: A, B, C 互相连通.
- 小区: D, E 互相连通.
系统状态演变:
- 大区继续工作: A 依然能和 B、C 通信, 3 个节点构成了多数派(Majority), 所以 A 继续当 Leader, 正常处理客户端的读写请求.
- 小区陷入疯狂: D 和 E 收不到 A 的心跳. D 的选举定时器超时, 变成了 Candidate, 将自己的 Term 加 1(变成 2), 向 E 拉票.
- 死循环发生: D 只能拿到自己和 E 的 2 张票, 达不到多数派(3 张). 于是 D 再次超时, Term 变成 3... 变成 4...
- 几个小时后, 网络故障排除了. 此时, D 的 Term 已经增加到了 10000.
2. 致命威胁: 王者归来还是搅局者?
网络恢复的瞬间, D(Term 10000)重新连上了大区. Raft 有一条铁律:"见大即怂". Leader A 收到 D 的消息, 一看对方的 Term 是 10000, 远大于自己的 Term 1, A 会立刻认为自己过时了, 直接退位变成 Follower!
这就导致了一个极其恶心的现象: 一个落后了几个小时、毫无用处的"幽灵节点", 仅仅因为被关了禁闭导致 Term 虚高, 一回来就干翻了稳定工作的 Leader, 迫使整个集群停顿并重新选举.
3. Raft 工程演进的解法: 预投票机制(Pre-Vote)
为了解决这个刺头, 后来的 Raft 工业实现(如 etcd)引入了论文中的一个关键优化:Pre-Vote(预投票)阶段.
有了 Pre-Vote, D 节点在超时后, 不能立刻增加自己的 Term. 它必须先发起一轮"模拟选举":
- D 问大家: "如果我参选, 你们会投给我吗?"(此时 D 的 Term 不变).
- 网络恢复后, D 向 A、B、C 发送 Pre-Vote.
- A、B、C 发现自己一直在正常接收 Leader 的心跳, 活得好好的, 于是果断拒绝 D.
- D 发现自己连模拟选举都拿不到多数票, 于是乖乖退回 Follower 状态, 悄悄通过 Leader 的
AppendEntries同步丢失的数据.
完美解决! 稳定运行的 Leader 得到了保护, 幽灵节点无法再扰乱朝纲.
三、 场景三: "幽灵复现"与跨任期提交陷阱 (Raft 最难懂的细节)
如果说上一个场景是工程优化, 那这个场景就是 Raft 协议核心的理论底座. 它对应了 Raft 论文中最晦涩难懂的 Figure 8.
1. 灾难现场: 已复制到多数派的日志竟然被覆盖了?
请仔细看下面的状态推演图. 我们要弄清楚一个核心问题:Leader 能不能仅仅因为前任遗留下来的日志凑齐了"多数派", 就直接将其标记为已提交?
图中最上面一行数字是日志的 index, 每个框中的数字代表 term. 整个推演过程如下:
- 时刻 (a): S1 是当前的 Leader (Term 2), 并发起写请求, 将
index=2的日志复制到了 S2; - 时刻 (b): S1 宕机了. S5 凭借 S3、S4、S5 的选票成为新 Leader (Term 3), 并在
index=2的位置写入了一条来自客户端的新日志; - 时刻 (c): S5 宕机了, S1 恢复并重新当选为 Leader (Term 4). S1 继续把自己宕机之前的跨任期日志(
index=2, Term 2)同步到了 S3. 此时, 这条Term 2的日志已经成功复制到了大部分节点上(S1、S2、S3).
2. 致命的逻辑漏洞
很多人的直觉判断是: 既然 (c) 时刻中 [Term 2] 的日志已经复制到了多数派, 数量超过了集群半数, 它应该就可以被安全"提交"了. 但这其实极其危险!
假设 S1 此时判定 [Term 2] 已提交(甚至可能已经向客户端返回了成功), 但紧接着发生了图中的 情况 (d):S1 再次宕机. 此时 S5 重新启动.
在重新选举时, S5 会凭借自己身上那条 [Term 3] 的日志去向 S2、S3、S4 拉票. 由于 S5 日志的 [Term 3] 大于 S2 和 S3 上的 [Term 2], Raft 的选举规则会让 S5 成功当选 Leader. 当选后, S5 会强行将自己 [Term 3] 的日志同步给所有人, 导致 S1、S2、S3 上的 [Term 2] 日志被彻底覆盖.
灾难发生了: 一条已经被认为是"已提交"的日志竟然凭空消失了!这违背了分布式系统最底线的状态机安全(State Machine Safety)原则. 这里暴露出一个根本问题:单纯以"包含该日志的副本数量超过半数"作为提交标准, 在跨任期的场景下是靠不住的, 因为存在旧任期日志被新任期日志强行覆盖的可能.
3. Raft 的完美解法: 绝不直接提交前任日志
为了封死这个漏洞, 避免图 (d) 的灾难发生, Raft 在提交规则上制定了一条非常违反直觉、但极其严密的限制:
Leader 绝对不能仅通过"计算副本数"的方式, 去直接提交之前任期(Previous Term)遗留的日志.
Leader 只能通过"计算副本数"来提交自己当前任期(Current Term)产生的日志.
对应到图中的 情况 (e), 破局之道非常简单保守: 在图 (c) 时刻, S1 (Term 4) 虽然把 [Term 2] 复制到了多数派, 但它只能看着, 不能直接提交.
S1 必须继续等待, 直到有客户端发来一条新的请求, 产生一条属于当前任期的日志(即图 e 中的 index=3, Term 4, 而 index=2 Term 2 是任期 2 的日志). 一旦这条属于 Term 4 的日志被成功复制到多数派, S5 就再也不可能赢得选举了. 此时, 由于 Raft 的 Log Matching(日志匹配)特性, 在提交由于多数派达成的 [Term 4] 日志的同时, 它前面未提交的所有日志(包含那条 [Term 2])也就随之顺理成章地被一并隐式提交了.
其他一些共识算法在处理这个问题时, 做法可能是让新 Leader 强行把旧日志加上当前的任期号重新发送. 但 Raft 为了保证日志中的 Term 号一旦写入就不会随时间或日志文件变化而改变, 选择了这种做法. 这不仅使得判定 Entry 是否合法变得更容易, 也减少了再次发送冗余日志的网络开销, 用极简的规则堵死了系统中隐藏最深的"幽灵复现"漏洞.
四、 场景四: Follower 闪断与 RPC 幂等性
相比于前两个惊心动魄的场景, 这个场景更偏向于日常的工程细节.
1. 灾难现场: Leader 的消息重发
Leader A 向 Follower B 发送了一条 AppendEntries(追加日志 x=1). Follower B 顺利收到, 将其落盘写入了本地, 然后向 Leader 发送 ACK. 但是, 在这个 ACK 飞在半空中的时候, Follower B 突然断网了 2 秒钟, 导致 ACK 丢包了.
此时 Leader A 的超时定时器触发, 认为 B 没有收到日志, 于是 Leader A 毫不犹豫地重新发送了一模一样的 AppendEntries 给 B.
2. 致命威胁: 数据会重复写入吗?
等 B 网络恢复时, 它收到了 Leader 发来的那条它明明已经写过一次的请求. 如果在状态机里执行两次 x=1(或者转账 100 元执行两次), 业务数据就全乱了.
3. Raft 的完美解法: 严格的幂等性校验
Raft 的日志流设计天然具备幂等性(Idempotency). Follower B 收到重发的 AppendEntries 时, 会执行严格的校验逻辑:
- 提取请求中携带的日志条目.
- 检查本地日志的相同
Index处, 是否已经有一条Term完全一样的记录? - 发现本地已经有了!
- Follower B 默默地丢弃这条重复的日志, 不进行任何覆盖, 但仍然向 Leader 热情地返回
Success ACK.
这种设计使得 Leader 永远可以毫无顾忌地重试任何超时的 RPC 请求, 而不用担心把 Follower 的数据搞乱.
结语: 从"Pre-Vote"对稳定性的保护, 到"只能提交当前任期日志"的严密推理, 再到"幂等性"工程设计. Raft 之所以伟大, 不在于它描绘了一个多么理想的系统, 而在于它预判了物理世界所有的肮脏与混乱, 并用几条极简的规则, 为数据的一致性筑起了一道不可逾越的高墙.

浙公网安备 33010602011771号