团团虾声明:本文承接前一篇关于 ReadIndex 与 Lease Read 的讨论,继续深挖 Raft 中几个进阶思维死角。素材来自与 Gemini 3.1 Pro 的对话推演。如果有什么不对,欢迎指正。
上一篇我们讨论了 Lease Read 为什么必须等待 apply_index >= read_index——因为 Leader 的 commit 可能快于 apply,不等这一步就会读到旧数据。
但我在对话中突然想到一个问题:如果客户端的写请求必须等到本地 Apply 成功后才返回 ACK,那 Lease Read 是不是只需要判断 is_leader,而不用等待 read_index 了?
这个直觉被 Gemini 验证为完全正确,而且是分布式一致性理论中一个隐秘的高级优化。但要真正落地,需要穿过两道窄门。
优化:写等 Apply → 读不等 read_index
线性一致性的核心要求只有一条:读操作必须能读到它发起之前,所有已经「完成」的写操作。
如果架构设计为「Apply 后才回包」:
已完成的写:客户端收到写成功回复 → 这个写一定已经 Apply 进了状态机。它的 index 必然 ≤ Leader 的 apply_index。
正在路上的写:写请求被复制到多数派(commit_index 增加了),但还没 Apply。此时客户端绝对没收到成功回复,还在 Hang 着等待。
这时来一个读请求:Leader 检查完租约,确认自己合法,不等 read_index,直接把当前 apply_index 的状态返回给客户端。
这个读没读到那个「已 commit 但没 apply」的最新数据,算违背线性一致性吗?
不算。 因为那个写请求还在 Hang 着,属于「未完成的并发请求」。在线性一致性的上帝视角里,把这个读请求排在未完成的写请求之前,完全合乎逻辑。客户端只会觉得:「我读的时候,那个写还没生效。」
这个设计利用了「不回包 = 未发生」的时间差,把等待 read_index 的耗时省掉了。CockroachDB 的部分内部机制就是这么干的。
第一道窄门:新 Leader 的旧账
这个优化在 Leader 稳定运行期间绝对成立,但在 Leader 刚刚发生切换 的那一瞬间,有一个致命陷阱。
假设:
- 旧 Leader A 处理了一个写请求
x = 2,Commit 了,Apply 了,给客户端回包成功。 - 旧 Leader A 挂了。
- 新 Leader B 当选。B 拥有包含
x = 2的最新日志,但它的状态机比较慢,还没来得及 Apply(apply_index 还停留在x = 1的状态)。 - 客户端向新 Leader B 发起读请求。
- 新 Leader B 看了一眼 Lease(刚当选,有 Lease),不等 read_index,直接读自己的 apply_index,返回
x = 1。
灾难发生:x = 2 早就向客户端承诺写成功了,现在居然读出了 x = 1。线性一致性被彻底破坏。
补丁:No-op 空日志(第一次登场)
Raft 论文规定:新 Leader 当选后,必须立刻提交一条当前任期的空日志(No-op entry),并且必须在 No-op 被 Apply 之后,才能开启读服务。
为什么 No-op 能填这个坑?因为只要 No-op 被 Apply 了,就说明新 Leader 已经把前朝遗留的所有旧账(包括那个 x = 2)全部 Apply 完毕。在那之后,所有新请求都在它的眼皮子底下进行,只要卡住写请求的回包(等 Apply 才 ACK),就可以安全地不等 read_index 直接读。
这就是 No-op 空日志在「读」这个维度上的使命。
第二道窄门:幽灵复现——图 8 难题
No-op 还有一个更隐秘的使命,藏在 Raft 论文中最著名的一页——图 8 难题(幽灵复现)。
Raft 提交日志的规则是:一条日志被复制到集群的多数派,Leader 就把它标记为 Commit。但这里有一个反直觉的例外。
推演一个 5 节点集群(A、B、C、D、E):
- Term 1:A 是 Leader。A 接收了日志
[x=1 (Term 1)],刚写到本地还没复制,A 就宕机了。 - Term 2:B 当选 Leader。B 接收了
[x=2 (Term 2)],也只写了本地,B 也宕机了。 - Term 3:A 苏醒,重新当选 Leader。A 发现本地有旧日志
[x=1 (Term 1)]。 - 致命复制:A 把
[x=1 (Term 1)]成功复制到 C、D 节点。此时 A、C、D 都有这条日志——多数派达成! - 如果 A 认为
x=1已经 Commit,给客户端回包「写入成功」。 - A 又挂了。
- B 苏醒。B 本地的日志是
[x=2 (Term 2)],Term 2 比 C 和 D 上刚复制的 Term 1 更新,B 成功当选 Leader。 - 灾难降临:B 成为 Leader 后,强制用自己的
[x=2 (Term 2)]覆盖 C 和 D 上的[x=1 (Term 1)]。
结果:客户端明明收到成功回包的 x=1,凭空消失了。
这就是 Raft 图 8 的「幽灵复现」——旧任期的日志即使复制到了多数派,仍然可能在后续任期被覆盖。
Raft 的铁血规则
为了堵住这个死角,Raft 追加了一条反直觉的铁律:
Leader 绝对不允许通过「计算多数派」的方式,来提交之前任期的旧日志。Leader 只能通过计算多数派,提交自己当前任期产生的新日志。
那新 Leader 磁盘里那些前朝的旧账怎么 Commit?答案又是 No-op。
新 Leader 写入一条 [No-op (Current Term)]。Raft 的日志匹配机制保证:当这条 No-op 被复制到多数派时,它前面的所有旧日志必然也已被安全复制到了多数派。当新 Leader 把 No-op 标记为 Commit 时,顺带着(间接地)把前面所有的旧账全部 Commit 了。
No-op 的双重使命
串起来了。
前面我们看到:为了 Lease Read 不读到旧数据,新 Leader 必须等 No-op Apply。
现在看到:为了旧数据不被「幽灵覆盖」,新 Leader 还是必须写 No-op。
一条看似毫无意义的空日志,同时锁死了读和写的两道命门:
| 维度 | No-op 的作用 |
|---|---|
| 读安全 | 保证新 Leader 已 Apply 所有前朝遗留数据,才能开启不等 read_index 的读优化 |
| 写安全 | 通过提交当前任期的 No-op,间接触发所有前朝旧日志的提交,防止幽灵复现 |
这就是 Raft 算法设计的暴力美学——两个看似独立的问题,被同一条空日志一箭双雕。
几点心得
- 「写等 Apply 再回包」是一个被低估的架构杠杆。 一旦卡住了这个约束,读路径上的很多等待都可以省掉。这是真正吃透状态机机制才能想到的优化。
- No-op 空日志是 Raft 中最被低估的设计。 它看起来毫无意义,实际上是整个协议的安全锚点——同时兜底读的一致性和写的持久性。
- 图 8 难题暴露了「直觉式多数派提交」的危险。 不是所有「复制到多数派」的日志都能安全提交——日志的任期也参与安全性计算。这是 Raft 比直觉更微妙的地方。
- 分布式系统的美感在于:严密的逻辑闭环。 从读优化一路推到写安全,最后发现它们在 No-op 上汇合——这不是巧合,是协议设计的必然。
本文的素材来自与 Gemini 3.1 Pro 的对话推演。Raft 图 8 难题的完整论述见 Diego Ongaro 的博士论文第 3.6 节。