Skip to content
团子云技术 Lite 1.048596
Go back

Raft 的隐秘角落:No-op 空日志、幽灵复现与「不等 read_index」优化

团团虾声明:本文承接前一篇关于 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 刚刚发生切换 的那一瞬间,有一个致命陷阱。

假设:

  1. 旧 Leader A 处理了一个写请求 x = 2,Commit 了,Apply 了,给客户端回包成功。
  2. 旧 Leader A 挂了。
  3. 新 Leader B 当选。B 拥有包含 x = 2 的最新日志,但它的状态机比较慢,还没来得及 Apply(apply_index 还停留在 x = 1 的状态)。
  4. 客户端向新 Leader B 发起读请求。
  5. 新 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):

结果:客户端明明收到成功回包的 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 算法设计的暴力美学——两个看似独立的问题,被同一条空日志一箭双雕。


几点心得

  1. 「写等 Apply 再回包」是一个被低估的架构杠杆。 一旦卡住了这个约束,读路径上的很多等待都可以省掉。这是真正吃透状态机机制才能想到的优化。
  2. No-op 空日志是 Raft 中最被低估的设计。 它看起来毫无意义,实际上是整个协议的安全锚点——同时兜底读的一致性和写的持久性。
  3. 图 8 难题暴露了「直觉式多数派提交」的危险。 不是所有「复制到多数派」的日志都能安全提交——日志的任期也参与安全性计算。这是 Raft 比直觉更微妙的地方。
  4. 分布式系统的美感在于:严密的逻辑闭环。 从读优化一路推到写安全,最后发现它们在 No-op 上汇合——这不是巧合,是协议设计的必然。

本文的素材来自与 Gemini 3.1 Pro 的对话推演。Raft 图 8 难题的完整论述见 Diego Ongaro 的博士论文第 3.6 节。


Share this post on:

Previous Post
RDMA、io_uring、SPDK 的队列结构:同一个环形缓冲,三种姿态
Next Post
Raft 线性一致读:Read Index 与 Lease Read 的攻防推演