Skip to content
Published on

确定性模拟测试找到的那个 bug — KAFKA-19880,以及该怎么读「零 bug」

分享
Authors

引言 — 站在一个无法复现的 bug 面前

生产环境里发生过一次奇怪的事。日志里留下了痕迹,可无论怎么努力重现同样的情形,它再也没出现过。测试跑一百次都是绿的。最后只能把它归为「偶发现象」,把工单关掉。半年后,同样的事情以更糟的形式又回来了。

Aiven 的 Ivan Yurchenko 把这类 bug 叫作 heisenbug — 由 IO、执行调度以及其他随机性带来的非确定性所造成的 bug。网络会延迟、丢失、乱序投递数据包。操作系统会随心所欲地调度线程。磁盘会故障。用他的话说,这类 bug 在生产环境炸开时,光靠遥测数据去理解它,实际上是不可能的。

在这种情况下,「多写点测试」这种建议不太管用,因为你无法为自己想象不到的东西写测试。本文要追踪的,是从另一个角度攻克这个问题的技术 — 确定性模拟测试(DST) — 以及这项技术在 2025 年 11 月实际找到的一个 Apache Kafka bug。也会说说为什么这个案例远比宣传材料有意思:因为结果并不干净。

确定性模拟测试到底是什么

Antithesis 的文档把 DST 定义为:把被测软件放进一个模拟出来的、确定性的环境中。核心就是两个词。

模拟。 系统的一部分或全部,不在真实的硬件、网络、操作系统上运行,而是跑在模拟器上。这样测试挽具就能控制模拟层里发生的事 — 什么时候发生什么样的故障。

确定性。 把时钟、线程交错、系统提供的随机数这些非确定性会渗入的来源,全部变成确定性的。所以只要固定种子,同一次运行就会被原样重演。这为什么重要,前一节的场景已经说明了 — 比起找到 bug,重新召回它才是难的那一半。

据文档所说,这套方法的实用化在 2010 年前后分别发生在 FoundationDB 和 AWS,几乎算是一次同步发明(想法本身更早就有了)。在 AWS,Al Vermeulen 把它引入用来测试一个内部锁服务的早期实现,而记录最早的相关讨论之一,是 Will Wilson 在 2014 年 Strange Loop 上的演讲。FoundationDB 打造的那套框架,后来成了一家叫 Antithesis 的公司的技术根基。

实现方式大致分成两种。

  1. FoundationDB 方式。 在设计系统的阶段,就把所有非确定性的组件都做成可插拔的。这很强大,但也意味着系统本身及其全部依赖,从一开始就得围绕 DST 来搭建。用 Antithesis 文档的说法,对一个已经在生产环境里的系统来说,这通常是不现实的。
  2. 确定性 hypervisor 方式。 把普通的、非确定性的软件原封不动地放进一个确定性 hypervisor 里跑。Antithesis 卖的正是这个,也就是说你不需要动自己的代码。

文档里还有一点很容易被忽略。人们往往把「打造一个完全确定性的系统」看作主要的技术难题,但实际上,充分且高效地探索状态空间,同样是个复杂的课题 — 因为对绝大多数软件来说,状态空间都大得离谱。这就是为什么 DST 总是和基于属性的测试/模糊测试、以及故障注入结伴出现。(基于属性的测试本身,我们另文讨论过。)

再补一个概念。当模拟器掌控着时钟,代码在执行相当于 time.Sleep() 的操作时,其实不需要真的等待。于是就能把远超挂钟时间的逻辑时间(logical/application time)压缩着跑完。下面出现的数字,全都是这个单位。

Aiven 的实验 — 把 Diskless Kafka 放进模拟器

2026 年 3 月 5 日,Aiven 公开了这个过程。对象是 Inkless — 一个用来实现 KIP-1150: Diskless Topics 的临时 Apache Kafka 分支。这是一项用对象存储代替 broker 本地磁盘作为主存储的功能,KIP wiki 上目前的状态是 Accepted。

动机很明确。他们正往既有的 Kafka 代码里塞进大量新的代码路径,既得确认这些新路径本身是对的,更重要的是要确认没有把既有的 Kafka 路径搞坏

搭建方式是这样的:没有另起炉灶写新挽具,而是扩展了 Kafka 现成的 Ducktape 系统测试套件。

  • Docker Compose 里 14 个 Ducktape worker(用 Inkless 的代码同时跑 broker 和客户端)、给 Inkless 控制平面用的 PostgreSQL、以及 MinIO 存储
  • 一个不断向 Diskless topic produce、并检查是否全部被 consume 的系统测试(inkless_produce_consume_test.py),并在其中导入 Antithesis SDK 加了额外的断言
  • 把镜像 push 到仓库,然后一个 HTTP POST 就能启动一次运行

这里该学的不是工具,而是不变量。测试始终只检查三件事。

  • 测试永远不会卡死(Antithesis 的 reachable 算子)
  • 写入的消息全部会被读到(always_or_unreachable)
  • 坏事不会发生 — 消息被乱序读取、出现重复 offset、或者分区集合发生变化(unreachable)

DST 不是魔法。你不告诉它「错」长什么样,它就什么都抓不住。模拟器只负责制造出奇怪的状态,判定它奇怪与否的,是这三行。

结果一 — 2,200 个逻辑小时,零 bug

测试在 Antithesis supervisor 里,总共对着 Kafka 集群持续 produce 和 consume 了大约 2,200 个逻辑小时。什么问题都没发现。

Aiven 给这个结果配上的那句话,我认为是全文最有价值的一句。

通过的测试可能是最可疑的一种测试。它可能只是意味着你还没找到问题。

顺带一提,Aiven 表示他们整体上按挂钟时间跑了大约 200 小时,相当于约 9,700 个逻辑小时(1.1 年)。前面那 2,200 是其中花在这个 Inkless 系统测试上的部分。

我们得把「零 bug」到底证明了什么说清楚。它不是 Inkless 正确性的证明。它的意思是:在这个工作负载下、这三条不变量下、这些种子下,没有找到违反项。工作负载没触及的代码路径、不变量没表达出来的性质,依然是盲区。

结果二 — 从对照组里跑出来的 KAFKA-19880

于是 Aiven 又跑了一组对照。为了重新核实自己的假设,他们没有对着 Inkless 分支,而是把 Kafka 原本系统测试的一部分,对着未经修改的上游 Apache Kafka 原版代码放进 Antithesis 跑了一遍。这是一种健全性检查(sanity check),看看这套挽具到底能不能真的抓到 bug。

结果就从那里跑出了 KAFKA-19880。标题是这样的:

First batch in producer epoch can be appended out-of-order even for idempotent producer

用 Aiven 自己的话说,这「令人意外」。本来是为了验证自家新代码而搭的装置,反倒从自己立的对照组那一侧带回来一个 bug。照搬 Aiven 在文章结论里的原话,Apache Kafka 是一款社区在测试和设计上始终认真、非常成熟、经过实战检验的软件。

机制是这样的。生产者的配置是 enable.idempotence=truemax.in.flight.requests.per.connection=5,并向一个刚创建的空 topic 发送足够形成多个批次的记录。但集群和目标 broker 之间存在一个会影响元数据传播的连接问题。

生产者: enable.idempotence=true, max.in.flight.requests.per.connection=5
目标: 刚创建的空 topic input-topic-0

correlationId=21  base sequence 0     -> NotLeaderOrFollowerException  (broker 还不知道自己是 leader)
correlationId=22  base sequence 1458  -> NotLeaderOrFollowerException
correlationId=23  base sequence 2823  -> NotEnoughReplicasException    (ISR 大小 1 < min.isr 2)
correlationId=24  base sequence 4188  -> 成功 (!)
correlationId=25  base sequence 5553  -> ...

结果: 这个分区里第一个被写入的批次,其 base sequence
      不是 0,而是 4188。
      即便开启了幂等性,第一个批次仍以逻辑上乱序的状态被接受。

按 issue 正文的说法,到 correlationId 24 时请求成功了,于是第一个被写入的批次,即便在逻辑上已经乱序,仍然被接受。前面失败的那些批次之后可能会以新的 producer epoch 重试 — 报告者的案例里也确实如此 — 但这不重要,因为预期的记录顺序早就被打破了。

条件也很清楚。分区不需要是空的,只要 producer 状态是空的、并且该 epoch 最初的几个请求像上面那样失败,就足够了。反过来,如果该 epoch 的第一个请求成功了,这种情况就不可能发生:如果 r1 成功而 r2 因 NotLeaderOrFollowerException 失败,那么 r3 会因 OutOfOrderSequenceException 被拒绝。报告者把这处矛盾归因于 broker 端 ProducerAppendInfo.java 里的校验逻辑。

这个案例真正的教训 — 可复现性

这正是 DST 价值显现的地方。以下是报告者在 issue 正文里亲自写下的一句话。

在 Antithesis 下,这个问题我能相当稳定地实时复现。我怀疑要搭一个能以过得去的频率抓到它的本地环境,不会容易。

但他没止步于此,而是把这个问题缩减成了 ReplicaManagerTest 里的一个单元测试。

// 摘自并简化自 KAFKA-19880 issue 正文中的复现代码
val r0 = MemoryRecords.withIdempotentRecords(
  Compression.NONE, producerId, producerEpoch, 0, new SimpleRecord("record 0".getBytes()))
val r1 = MemoryRecords.withIdempotentRecords(
  Compression.NONE, producerId, producerEpoch, 1, new SimpleRecord("record 1".getBytes()))

// val order = List(r0, r1)  // 正常顺序 -- 没有乱序问题
val order = List(r1, r0)     // r1 被乱序接受
                             // r0 之后以更高的 epoch 重试并被接受

这条链路正是 DST 真正的卖点。模拟器稳定地重现出一次罕见的交错 → 人去审视它 → 压缩成一个不靠工具也能跑的确定性单元测试。最终产物是 20 行 Scala 代码,任何人不需要 Antithesis 订阅也能跑。工具是梯子,不是终点。

而结局并不干净

从这里开始,是宣传材料里不会出现的部分。

首先,报告者本人也没有把握。issue 正文里最后一个问题是这样的 — 就我读文档的理解,这是个 bug,我原以为 enable.idempotence 会阻止这种情况,我说得对吗,还是说这其实是可以接受的行为,只是文档需要改进? Aiven 的博客也保持着同样的语气 — 这到底是文档缺失还是严格意义上的 bug,还有待判断,他们在等社区的答案。

那这个 issue 现在处于什么状态呢?截至 2026 年 7 月查询 JIRA,情况是这样的。

KAFKA-19880
  Summary:     First batch in producer epoch can be appended out-of-order
               even for idempotent producer
  Status:      Open
  Resolution:  Unresolved
  Priority:    Minor
  Reporter:    Ivan Yurchenko
  Created:     2025-11-12
  Affects:     4.0.1, 4.1.0
  Fix Version: (无)
  Components:  core, producer
  评论数:        1 条

报告出来八个月了,评论只有一条。2026 年 3 月 10 日,Travis Bischel(Go 版 Kafka 客户端 franz-go 的作者)留下一行 — 「Likely related: KAFKA-14312」。

打开那条 KAFKA-14312,标题是这样的:「Kraft + ProducerStateManager: produce requests to new partitions with a non-zero sequence number should be rejected.」也就是说,发往新分区、序列号非零的 produce 请求应当被拒绝。报告者同样是 Travis Bischel,报告日期是 2022 年 10 月 18 日,优先级 Major。而状态是 Resolved / Won't Fix。关闭日期是 2024 年 11 月 12 日。

当然,Travis 只说了「likely related」,没说是同一个问题 — 这个判断至今没人下过。但拼出来的画面是这样的:一个最先进的模拟器跑了几百个小时,拖回来一个异常行为,它能复现,也被压缩成了单元测试 — 而它究竟是该修的 bug,还是文档没写清楚的规范,这个问题此前已经以相邻的形式被提出过一次,被判定「不修」而关闭,如今又再度悬而未决。优先级是 Minor。

DST 自动化的是发现。而判断它是不是 bug、给它排优先级、找人去修,这些依然是人和组织的活儿。买了工具,前一半解决了,后一半原封不动地留在那里。

数字看起来更漂亮的一个案例 — 以及该怎么读它

如果 Aiven 的「零 bug」结果让人觉得不够劲爆,还有一个常被引用的反例。WarpStream 在 2024 年 3 月写过一篇文章。读之前先把出处说清楚 — WarpStream 卖的是建在对象存储之上的 Kafka 替代品(文章本身也带了这条披露),而这篇文章是一位 Antithesis 客户在写 Antithesis。下面的数字全部是自我报告的数值。

  • Antithesis 在跑 WarpStream 工作负载的挂钟 6 小时里,模拟出了280 个应用小时
  • 其中,大约花了160 个应用小时,才不再发现新的「行为」而进入停滞(stall)。WarpStream 从这里得出的结论很实在 — 超过 160 小时之后再继续跑,收益递减,应该把投入转向把测试本身做得更精细
  • 第一天就抓到的是一个指标埋点库里的数据竞态(data race)。这个 bug 从项目第一个月起就存在,此前在 CI 里开着 Go 的 race detector,字面意义上跑了数万小时,一次都没抓到过。Antithesis 只用了 233 秒运行时间就抓到了它。
  • 更有意思的是一个数据丢失的 bug。Agent 为了降低 S3 的 PUT 成本,会把多个客户端的 Produce 请求在内存里攒大约 250 毫秒,再合并成一个文件 flush 到对象存储。一次给 flush 加上 speculative retry 的重构,微妙地弄坏了错误处理逻辑,导致在极短的一瞬间,一个本该 flush 失败的文件被当成了成功。负责把这一状态提交为元数据的后台 goroutine 每 5 毫秒轮询一次,而问题所在的两次状态转换之间的间隔通常不到 1 微秒 — 也就是说,网络故障和某个特定的线程交错这两件罕见的事,必须同时撞上。在预发环境里从没见过,在 Antithesis 里则大约每挂钟一小时就出现一次。他们表示现在已经修复。

从这个案例该带走的,不是「233 秒 对 数万小时」这种戏剧性的对比,而是它背后的结构。开着 race detector 跑了数万小时的 CI,本质上更接近把同一种调度重复了数万次。重复不是探索。DST 卖的不是时间,而是交错的多样性。

在同一篇文章里,WarpStream 用了好几段篇幅主张 Antithesis 比 Jepsen 更好;由于这明显是一方当事人的意见,而非经过验证的事实,最好不要照单全收。(混沌工程这一类方法整体,我们在混沌工程篇里另文讨论过。)

诚实的权衡

就连 Antithesis 自家的文档,也把局限写得很明白 — 正因为这是厂商自己承认的约束,反而更值得信。

搭建成本很重。 文档说,搭出一套确定性模拟环境,是一项复杂且资源密集的工程。

不是所有系统都适用。 用文档的说法,不是每个系统都能被设计成可以在其周围搭出 DST 的样子。

外部依赖终究得打桩。 像 Antithesis 这样的平台确实让大多数软件都能用 DST 测试,但为了保证确定性,外部依赖仍然得打桩或以别的方式替换掉。这个约束比听起来要大。回头再看看 Aiven 的搭建 — 用的是 MinIO 而不是 S3,是 Postgres 容器而不是托管数据库。也就是说,你在模拟的不是生产环境,而是生产环境的一个模型,S3 真实的一致性行为、托管服务真实的故障模式,只会按这个模型的逼真程度反映出来。

状态空间探索才是真正的瓶颈。 WarpStream 那个 160 小时的停滞点说明了这一点。过了某个临界点,模拟器就不再发现新行为了,从那时起需要的不是更多时间,而是更好的工作负载和更锋利的不变量。这是人来做的设计工作,无法自动化。

它不是免费的。 Antithesis 是一款商用产品。我没能确认公开的价目表,所以不会在这里编数字,但 7×24 小时模拟要吃算力这件事本身是明摆着的。顺带一提,Aiven 在用 Antithesis 之前,其实已经在跑一套缩小版的 DST — 一种基于属性的 writer 测试,会把 producer 请求数、消息到达时刻、payload 形状这类输入随机化 — 并表示这有帮助。不过他们也补充说,为了这类测试得自己写并维护相当多的代码,而且有些地方用起来别扭。梯子不止一级。

而结果有可能落到人的问题上。 正如 KAFKA-19880 那一节所展示的。

那么,你该不该用

大概率值回成本的情况

  • 并发、状态、协调是本质所在的系统。Antithesis 文档举的例子是分布式数据库、金融交易引擎、分布式基础设施、区块链/共识协议、异步工作流。(像复式记账账本这种不变量清晰的领域尤其合适。)
  • 无法复现的生产异常已经在困扰你。
  • 你能把不变量写成一句话,比如「写进去的一定读得到」「offset 单调递增」。做不到这一点,打开 DST 也抓不住任何东西。
  • 你已经把基于示例的测试和确定性 CI 做得不错,需要往上再走一步。

属于过度的情况

  • 普通的 CRUD 应用和大多数 Web 服务。这里出现的 bug 通常是逻辑 bug,而不是交错 bug,逻辑 bug 用便宜得多的工具就能抓住。
  • 你还没打好单元测试和集成测试的底子。DST 是叠加在其上的一层,不是替代品。
  • 系统的核心价值就在外部 SaaS 调用上。为了确定性把这些全部打桩掉,你真正想测的东西也就随之消失了。
  • 时间不够,打算随便写两行不变量就算了事。那样得到的会是「零 bug」,而那正是 Aiven 警告过的、最可疑的那种结果。

建议从梯子最低的一级往上爬:固定种子的基于属性测试 → 自制的缩小版模拟器或确定性故障注入 → 等这些仍然抓不住某些东西、而那个 bug 的预期代价足够大时,再上确定性 hypervisor。Aiven 实际走的就是这个顺序。

结语

总结一下:DST 把非确定性的来源关进模拟器里,刻意去探索那些罕见的交错,并用种子把找到的东西重新复现出来。它的价值与其说是「找到更多 bug」,不如说是「找到的 bug 能被重新召回」。KAFKA-19880 被缩减成 20 行确定性单元测试,就是证据。

与此同时,这个案例也相当诚实地展现了这项技术的现状。跑了 2,200 个逻辑小时的自家代码什么都没跑出来,作为对照跑的成熟上游代码却跑出了一个,而它已经以 Minor/Open 的状态挂了八个月,同一系列里相邻的一个 issue 在 2022 年被报告、2024 年被以 Won't Fix 关闭。这不意味着这是一个失败的故事。它意味着,工具真正在起作用时,本来就长这样。

所以不要先挑工具。先用一句话,写下你的系统里绝对不能发生的那件事。如果这句话写不出来,任何模拟器都救不了你 — 而如果它写出来了,说来意外,你已经走完一半了。

参考资料