1 背景:为何再测 NATS
NATS 2.12.1 在发布说明里首次宣称其 JetStream 多副本流支持「linearizable」语义,却未给出形式化验证。Jepsen 社区随后把该版本列入 2025 Q4 的测试路线图,目标只有一个:确认「强一致」广告是否经得起网络分区、节点僵死、时钟漂移的组合拳。
2 Jepsen 测试模型速览
- 工作负载:single-register,100 % 覆盖 read/write/CAS。
- 并发度:5 客户端 × 5 并发 = 25 槽位,足以在 20 s 内触发线性化违规。
- 故障注入:
- 随机网络分区(majority/minority 交替 30 s);
- 节点 graceful reboot + 进程冻结;
- 消息延迟 10–300 ms 均匀抖动。
- 判定器:Knossos 线性化检查器,采用 WGL 算法对 history 进行 P-compositionality 剪枝,可在 3 min 内完成 50 k 事件对账。
3 违规时间线:20 s 现场还原
以下事件序列来自一次典型失败运行(已简化):
| 物理时间 | 事件 |
|---|---|
| T0 | Leader A 完成 write (x=2),commitIndex=5,尚未 reply 客户端 |
| T1 | A 与多数派网络隔离,B 当选 term=2,commitIndex 仍为 4(缺一条 no-op) |
| T2 | 客户端重连到 B,发出 read (x),B 直接读状态机,返回 1 |
| T3 | 客户端收到旧值 1,与之前成功的 write (x=2) 形成线性化冲突 |
Knossos 在最后对账时给出反例:「write 2 ➜ read 1」无法插入任何与全局时钟兼容的 total order,判定违规。
4 根因定位:Raft 读路径的缺口
NATS 的 Raft 实现在 2.12.1 之前为「性能」考虑,默认走「Leader Read」—— 即 Leader 只检查本地状态机就返回结果,既不走 ReadIndex,也不发心跳确认自己的合法性。当新 Leader 尚未提交一条本 term 的 no-op 条目时,其 commitIndex 可能落后于已确认写入,却仍对外提供读服务,从而违反线性化。
5 修复方案与可落地参数
方案 A:强制 ReadIndex(无需时钟假设)
- 收到读请求后,将当前 commitIndex 缓存为 readIndex;
- 向所有 Follower 发送一次心跳(或 AppendEntries 空包);
- 收到多数派 ACK 且本机 applyIndex ≥ readIndex 后再读状态机。
参数模板(单 region,RTT ≤ 5 ms):
raft.read_timeout = 150 ms // 心跳超时阈值
raft.leader_check_interval = 50 ms // 两次读请求间最小间隔
方案 B:Leader Lease(依赖时钟漂移上限)
- 选举成功后立即记录 lease = now + 150 ms;
- 每次成功心跳后续约 lease = now + 150 ms – lastRTT;
- 仅当 lease 有效且 applyIndex ≥ commitIndex 时才提供读。
参数模板(NTP 同步,漂移 ≤ 10 ms):
raft.lease_duration = 150 ms
raft.clock_drift_bound = 10 ms
6 回归验证:把违规压到 0
在同等 100 轮、每轮 5 min 的故障注入下,开启 ReadIndex 后 Knossos 未再报线性化违规;P99 读延迟从 0.8 ms 升至 4.3 ms,仍在可接受范围。
7 结论:方法论比报告更重要
Jepsen 并未「发明」新漏洞,而是把 Raft 论文里早已指出的「Leader Read 必须确认合法性」原则用自动化测试转化为可复现的违规现场。对于工程团队,与其等待官方报告,不如直接把 single-register + Knossos 的套路搬进 CI,用最少的代码换最硬的 correctness 证据。
资料来源
[1] Aphyr. « Consistent reads are not consistent » — etcd-io/etcd#741, 2014.
[2] 基于 Jepsen 来发现几个 Raft 实现中的一致性问题 (2), 博客园,2024.
[3] Dragonboat 千万级多组 Raft 库测试实践,今日头条,2022.