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.go 把 SyncInterval 硬编码成 120 s,也就是说消息被 Raft 提交后只落在页缓存。单节点掉电,页缓存丢失,重启后 Raft 日志出现空洞,checker 立即抓到 ack=ok 但 read=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”