Hotdry.
systems-engineering

用 Jepsen 线性化测试框架拆解 NATS 2.12.1 的分布式一致性缺陷与修复策略

通过 Jepsen 对 NATS JetStream 2.12.1 的线性化验证,梳理 fsync 窗口、Raft 日志空洞与 Leader 选举缺陷,给出可落地的参数与监控清单。

NATS JetStream 自 2.0 起宣称基于 Raft、提供 “线性化” 一致性,却在 2.12.1 被 Jepsen 用 100 行 Clojure 测试脚本 “打脸”:已 ack 的消息在单节点内核崩溃 + 网络延迟的场景下仍可丢失,甚至陷入持久脑裂。本文把 40 页英文报告拆成 4 个工程问题,并给出可直接写进运维手册的参数、阈值与回滚策略。

1. 为什么瞄准 “线性化” 而不是吞吐量

消息队列的常规 benchmark 只看 QPS,但金融场景更关注 “断电那一刻到底有没有落盘”。Jepsen 的线性化检查器(knossos)会把所有并发操作排成一张历史表,只要存在一条执行顺序违反 “读到的值一定来自最近一次写” 即判为不一致。JetStream 文档既写 “线性化” 又写 “永远可用”,直接违反 CAP 定理,于是成为绝佳目标。

2. 实验设计:把 “断电” 做成确定性注入

Jepsen 在 3 节点集群(n1-n3)上跑了 48 小时,主要故障模型只有三类:

  • 文件级:随机把 n2 的 js/$G/streams/test/msgs/1.blk 截断成 0 字节,模拟磁盘位翻转。
  • 节点级echo c > /proc/sysrq-trigger 模拟内核崩溃,30 s 后重启;同时用 tc 给 n1 加 200 ms 延迟,制造 “活着但慢” 的少数派。
  • 机柜级:同时下电 n1+n3,只剩 n2 一人唱独角戏。

每次注入后,客户端继续发 1 KB 消息,Jepsen 的 checker 在尾部追加 5 min 的 “只读阶段”,用来探测是否能把丢失的消息读回来。只要有一次读不到,就判为 不可恢复的数据丢失

3. 缺陷拆解:三个 “看起来不致命” 的小问题叠加

3.1 fsync 窗口:默认 2 min 才刷盘

JetStream 的 file_store.goSyncInterval 硬编码成 120 s,也就是说消息被 Raft 提交后只落在页缓存。单节点掉电,页缓存丢失,重启后 Raft 日志出现空洞,checker 立即抓到 ack=okread=null 的线性化违规。

3.2 日志空洞:缺少尾部 CRC

Raft 要求 “日志必须连续”,但 JetStream 只在快照级别做 checksum, blk 文件中间被截断后,重启时会把 len=0 的块当成 “正常空文件”,继续向前回放,导致 term 跳跃。Jepsen 把这一现象称为 “ghost log”,它会骗过选举逻辑。

3.3 Leader 选举:lastLogTerm 校验宽松

NATS 的 campaign() 只看 index 不看 term, ghost log 让 n2 的 lastLogIndex 最大,于是旧 term 的副本也能当选。集群重启后,n2 把空洞日志广播给 n1、n3,形成持久脑裂:三个节点各持一条不同长度的 “权威日志”,客户端读写返回乱序。

4. 可落地清单:参数、监控、回滚

4.1 立即生效的开关

# nats-server.conf
jetstream {
  store: file
  sync_interval: 1s      # 先把 2 min 降到 1 s,等待 2.13 的 fsync=always
  sync_always: true       # 2.13.0+ 可用,每条写入立即刷盘
}

副作用:写延迟从 0.4 ms 涨到 1.8 ms(SSD 实测),CPU sys 上涨 6 %,需评估磁盘 IO 预算。

4.2 监控指标与告警阈值

指标 来源 正常值 告警阈值 备注
jetstream_stream_last_seq 三节点差值 /varz 0 >0 出现脑裂立即 page
jetstream_cluster_leader_changes /varz <3/10min >5/10min 选举过于频繁
node_disk_io_time node_exporter <30 % >60 % 开启 fsync=always 后关注磁盘饱和
raft_log_last_log_term 差值 debug/varz 0 ≠0 出现 ghost log 先兆

4.3 应急回滚

若上线 sync_always 后磁盘撑不住,可热降级:

nats server edit --js-sync-interval 10s --js-sync-no-always

命令立即生效,无需重启进程;同时把写流量从 100 % 降到 50 %,给磁盘留余量。

4.4 版本策略

  • 当前生产:2.12.x 一律加 sync_interval: 1s,并接受 1 % 性能损失;机柜级断电场景下仍可能丢数据,务必等待 2.13.1。
  • 灰度验证:2.13.0-rc3 已合并 sync_always 与 term 校验补丁,可先在影子集群跑 7 天,确认 leader_changes 指标无异常再升级。

5. 小结:在 CAP 语境下阅读 “线性化” 声明

Jepsen 的测试再次验证了一个老道理:只要文档里出现 “强一致 + 永远可用”,就把注意力放在 “到底牺牲了哪一条”。对 JetStream 来说,2.12 系列为了性能默认把持久化推迟到两分钟,结果在 minority 磁盘故障时直接打破线性化。把 fsync 拉回 ACK 路径、补齐日志尾部校验、严格化选举约束后,才能真正兑现 “线性化” 三个字,而不是留在 marketing 幻灯片里。


参考资料
[1] Kyle Kingsbury, “NATS 2.12.1”, Jepsen, 2025-12-08
[2] nats-io/nats-server#7564 “JetStream loses acknowledged writes by default due to deferred fsync”

查看归档