Jepsen 报告震动 Go 社区:NATS JetStream 会丢失已确认写入

本文永久链接 – https://tonybai.com/2025/12/11/jepsen-report-nats-jetstream-data-loss-acknowledged-writes
大家好,我是Tony Bai。
近日,一则重磅消息在 Go 社区引发了不小的震动。分布式系统领域的“终极拷问者”——Jepsen——发布了一份针对 Go 生态中流砥柱级消息系统 NATS 及其子系统 JetStream 的深度分析报告。
报告的结论是严峻的,甚至可以说是颠覆性的:在特定的、可复现的故障模式下,NATS JetStream 可能会丢失已经被服务器确认 (acknowledged) 并声称“已成功持久化”的数据。
对于一个以持久化和可靠性为核心卖点的系统而言,这无异于一声惊雷。这份报告,对于所有正在使用或考虑使用 NATS JetStream 的 Go 开发者来说,都是一份必读的“警示录”。它深刻地揭示了在一个分布式系统中,“持久化”的承诺与现实之间的微妙鸿沟。

背景科普:NATS 与 JetStream 是什么?
在深入 Jepsen 的发现之前,让我们先快速了解一下今天的主角。
-
NATS:是一个用 Go 语言编写的、开源、高性能的消息中间件。它以其极致的性能、简单的 API 和轻量级的设计,在 Go 社区乃至整个云原生领域享有盛誉。其核心(有时被称为 “Core NATS”)提供的是一种“尽力而为” (best-effort) 的消息传递,速度飞快,但不保证消息的持久性或送达。
-
NATS JetStream:这是 NATS 的一个内置子系统,旨在为需要更高可靠性的场景提供解决方案。通过引入 Raft 共识算法,JetStream 在 NATS 的核心之上,构建了一个持久化的、可复制的日志(流)。它向用户承诺提供“至少一次” (at-least-once) 的消息传递保证——即已被确认的消息,不应丢失。
正是 JetStream 的这份“不丢失”的承诺,成为了 Jepsen 本次“拷问”的核心目标。
核心发现:“懒惰”的 fsync 默认策略
Jepsen 报告中最核心、也最具普遍警示意义的发现,在于 NATS JetStream 的默认 fsync 策略。
问题根源:
NATS JetStream 宣称,一旦客户端的 publish 请求被服务器确认,该消息就“已成功持久化”。然而,Jepsen 的测试发现,这并不完全准确。
默认情况下,NATS JetStream 每两分钟才调用一次 fsync 将数据从操作系统的页面缓存 (page cache) 刷入物理磁盘。
这意味着,在任何两次 fsync 之间,都存在一个长达两分钟的窗口期。在这段时间内,所有被服务器立即确认的写入,实际上只存在于内存中。
后果是什么?
如果在这两分钟的窗口期内,发生协调性的断电、内核崩溃、或多个节点快速连续地重启,那些仅仅存在于内存中的、已经被确认为“持久化”的数据,将永久丢失。
Jepsen 的测试通过一个名为 LazyFS 的工具,精确地模拟了这种“断电即失忆”的场景,并成功复现了数据丢失:在一个测试运行中,NATS 丢失了大约 30 秒的写入,共计 131,418 条已被确认的消息。
与 Raft 理论的背离:
这实际上与 Raft 论文的建议相悖。Raft 明确指出,节点在响应客户端之前,必须“将新的日志条目刷入 (flush) 它们的磁盘”。MongoDB, etcd, TiDB, Zookeeper 等其他基于共识的系统,都遵循了这一“先落盘,再确认”的原则。
NATS 的选择,是一种典型的性能与持久性之间的权衡。通过异步 fsync,它获得了极高的写入吞吐量,但牺牲了对“灾难性事件”的防护能力。
NATS 团队的回应:
NATS 团队已经意识到了这个问题,并在文档中补充说明了这一风险。他们建议,对于需要更强持久性保证的用户,可以将 sync_interval 设置为 always,但这会将吞吐量降低到每秒几百条消息。
更深层次的风险:文件损坏与脑裂
除了 fsync 的问题,Jepsen 还发现了几个在文件损坏场景下,可能导致更严重后果的漏洞。
数据块 (.blk) 文件损坏导致大量数据丢失
Jepsen 发现,即使只是在一个 5 节点的集群中的少数节点上,对 JetStream 的数据块文件 (.blk) 引入单个比特位的错误或截断,也可能导致集群丢失大量已确认的写入,甚至出现数据分歧(脑裂)——不同的节点返回不同的消息集,整个流的数据变得像“瑞士奶酪”一样千疮百孔。
在一个测试中,对两个节点的文件进行比特翻转,最终导致三个节点丢失了高达 78% 的已确认消息。
快照 (snapshot) 文件损坏导致流被删除
更令人不安的是,当快照文件损坏时,一个节点可能会错误地认为某个流已经“孤立”(orphaned),并做出删除该流所有数据的决定。在 Jepsen 的测试中,一个数据已损坏的节点,竟然成功地成为了集群的领导者,并立即删除了包含所有测试消息的流,导致了数据的完全丢失。
这暴露了 NATS 在面对数据损坏时,其领导者选举和恢复机制的潜在脆弱性。
一个单一的 OS 崩溃也可能导致数据丢失和脑裂
Jepsen 还设计了一个精巧的实验,证明在异步网络环境下,仅仅一次单节点的操作系统崩溃(模拟断电),就可能导致已提交写入的丢失和持久性的脑裂。
场景复现:
- Leader 节点将一次写入复制给了 Follower A,并收到了确认。此时,写入在 Leader 和 Follower A 的内存中被认为是“已提交”的。
- Leader 节点在将这次写入刷入磁盘之前,也还未成功复制给 Follower B 的时候,突然发生了 OS 崩溃。
- Leader 节点重启后,它内存中那份“已提交”的写入已经丢失。
- 此时,集群中存在两个“干净”的节点(重启后的 Leader 和从未收到写入的 Follower B)。它们可以组成新的多数派,选举出新的领导者,并继续处理请求。
- 从这个新的多数派的视角看,那次丢失的写入仿佛从未发生过。
Jepsen 的测试成功地在 NATS 2.12.1 中复现了这一理论场景,并导致了持久性的副本分歧(脑裂)。
Go 开发者的核心启示
这份报告,并非对 NATS 的“死刑判决”,而是一次深刻的、关于分布式系统复杂性的现实教育。对于 Go 社区的开发者,它至少带来了三点核心启示:
-
魔鬼在默认配置中:永远不要盲目相信软件的默认配置。NATS JetStream 默认的sync_interval,是为了性能而优化的,而非持久性。你需要根据你的业务场景(是能容忍丢失少量近期数据,还是要求金融级别的“绝不丢失”),来审慎地做出权衡和配置。
-
“已确认”不等于“已落盘”:在与任何分布式存储系统交互时,请仔细阅读其文档,搞清楚一个“成功的”写入响应,其背后的持久性承诺到底是什么级别的。是“已写入 Leader 内存”、“已写入多数派内存”,还是“已在多数派节点上 fsync 到磁盘”?这三者之间,差之毫厘,谬以千里。
-
拥抱混沌工程:Jepsen 的工作方法,正是混沌工程思想的极致体现。它告诉我们,仅仅通过单元测试和集成测试,永远无法发现分布式系统在真实世界故障模式下的脆弱性。我们需要引入更复杂的、模拟真实世界混乱(网络分区、进程暂停、磁盘错误)的测试手段。
小结
NATS 依然是一个出色、高性能的 Go 原生消息系统。Jepsen 的这份报告,如同一次严苛的“体检”,指出了它在追求极致性能的过程中,所做出的一些高风险权衡。对于我们 Gopher 而言,这不仅是一次了解 NATS 内部工作原理的机会,更是一堂关于如何批判性地思考、审慎地选择和配置我们所依赖的基础设施的必修课。
资料链接:https://jepsen.io/analyses/nats-2.12.1
还在为“复制粘贴喂AI”而烦恼?我的新专栏 《AI原生开发工作流实战》 将带你:
- 告别低效,重塑开发范式
- 驾驭AI Agent(Claude Code),实现工作流自动化
- 从“AI使用者”进化为规范驱动开发的“工作流指挥家”
扫描下方二维码,开启你的AI原生开发之旅。

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?
- 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
- 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
- 想打造生产级的Go服务,却在工程化实践中屡屡受挫?
继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!
我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。
目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2025, bigwhite. 版权所有.
Related posts:
评论