bigywater.tech
Kafka 内部原理 · 34

Kafka 能不能做到 Exactly Once

答案是"在 Kafka 内部的读—处理—写循环里可以,出了 Kafka 就不行"。这次用一个的消费—转换—生产程序验证前半句:从 eos-in 读 100 条,每条乘 2 后写入输出 topic,处理到第 5 批(offset 40 到 49)时在输出已经发送、但提交尚未发生时把进程杀掉(Runtime.halt()),重启后继续跑完,再用两种隔离级别审计输出 topic。对照组是普通的"先写输出、再 commitSync() 提交 offset",实验组是事务版:beginTransaction、写输出、sendOffsetsToTransaction、commitTransaction。

验证:非事务版崩溃重启后,输出里有 10 条重复
输入 100 条,输出 topic 里却有 110 条:崩溃前第 5 批的 10 条输出已经写进去了,但输入 offse
验证:事务版在同一个崩溃点之后,read_committed 读到的正好是 100 条,没有重复
事务版崩溃并重启之后,日志里同样物理存在 110 条:read_uncommitted 能看到崩溃前那个被中断的事务写入

验证:非事务版崩溃重启后,输出里有 10 条重复

命令 · java // 每批 poll 10 条 -> 逐条写输出(key = "in-" + 输入 offset,value = 输入 * 2)-> flush -> commitSync() // 第 5 批 flush 之后、commitSync 之前 halt
真实实测 · 本机执行输出
CRASH[plain]: 第 5 批的输出已发送(flush),还没提交 offset/事务,进程被杀 (该批 offset 40~49)
DONE[plain] 处理到 offset 99

eos-out-plain [read_uncommitted] 读到 110 条,唯一 key 100 个,重复的 key 10 个 [in-40, in-41, in-42]…,缺失 0 个
eos-out-plain [read_committed]   读到 110 条,唯一 key 100 个,重复的 key 10 个 [in-40, in-41, in-42]…,缺失 0 个

输入 100 条,输出 topic 里却有 110 条:崩溃前第 5 批的 10 条输出已经写进去了,但输入 offset 没提交,重启后这一批被重新处理,再写了一遍。不管用哪种隔离级别读,重复的 key 都是 in-40 到 in-49 这 10 个,没有缺失。这是第 30 篇里"先处理后提交"至少一次语义在输出 topic 上的样子。

验证:事务版在同一个崩溃点之后,read_committed 读到的正好是 100 条,没有重复

命令 · java // producer 设置 transactional.id="eos-proc-1",启动时 initTransactions() // 每批:beginTransaction() -> 逐条写输出 -> flush -> sendOffsetsToTransaction(输入 offset+1, groupMetadata) -> commitTransaction() // 第 5 批 flush 之后、sendOffsetsToTransaction/commitTransaction 之前 halt;重启后用同一个 transactional.id
真实实测 · 本机执行输出
CRASH[txn]: 第 5 批的输出已发送(flush),还没提交 offset/事务,进程被杀 (该批 offset 40~49)
DONE[txn] 处理到 offset 99

eos-out-txn [read_uncommitted] 读到 110 条,唯一 key 100 个,重复的 key 10 个 [in-40, in-41, in-42]…,缺失 0 个
eos-out-txn [read_committed]   读到 100 条,唯一 key 100 个,重复的 key 0 个,缺失 0 个

事务版崩溃并重启之后,日志里同样物理存在 110 条:read_uncommitted 能看到崩溃前那个被中断的事务写入的 10 条,加上重启后重新处理写入的 10 条。但 read_committed 只读到 100 条,key 各出现一次,没有重复也没有缺失。也就是说,崩溃前那个未提交的事务被终止了(重启后用同一个 transactional.id 调用 initTransactions() 会处理掉遗留的未完成事务),它写出的 10 条对 read_committed 的消费者不可见,重新处理的那 10 条才是最终有效的。输入 offset 的提交(sendOffsetsToTransaction)和输出消息在同一个事务里,要么一起生效,要么一起作废,这就是"处理完成"和"记录进度"被原子化的效果。

验证的边界:事务只覆盖写回 Kafka 的部分

这次实验里"输出"本身就是另一个 Kafka topic,所以能被纳入事务。而第 30 篇里输出是本地文件(sink),崩溃后同样的时机就出现了 10 条重复或者 5 条丢失,事务帮不上忙。这里不是新做了一次实验,是把两篇里已经测到的现象放在一起:Kafka 事务保证的是 Kafka 内部读—写的原子性,如果处理过程有外部副作用(写数据库、调用外部接口、写文件),这些副作用要自己做到幂等或者与 offset 放在同一个外部事务里,Kafka 事务不管它们。

结论

Kafka 可以在"读 Kafka、处理、写 Kafka"的场景里做到 Exactly Once:这次同一个崩溃点,非事务版输出 110 条(10 条重复),事务版对 read_committed 的消费者是精确的 100 条。前提是消费者读取输出时必须使用 isolation.level=read_committed——read_uncommitted(默认值)下事务版一样能看到 110 条。出了 Kafka 的外部系统需要自己保证幂等。

分步演示:读—处理—写循环里,事务是怎么消灭重复的

每一步都是本文上面两节在同一崩溃点halt、用两种隔离级别审计输出topic得到的原始结果。

同一个崩溃点,事务版配合read_committed做到精确100条
这一步新验证的版本/隔离级别

    参考与说明

    • 数据来自本机 3 节点 Kafka 4.3.1 集群,输入输出 topic 均为 1 分区 3 副本,每种模式只跑了一次,崩溃点固定在第 5 批。
    • 第一次审计时,我的审计消费者在等待入组期间就因为连续几次空 poll 提前结束,读到 0 条,这个输出没有采用;修正为"读到过数据之后才开始计算空 poll"后重新审计,上面是重新审计的结果(数据本身没有重新写入)。
    • 我没有测量 initTransactions() 在重启时处理遗留事务用了多长时间,也没有验证 transaction.timeout.ms 到期后自动中止未完成事务的行为。
    • 没有验证:多分区、多个输入 topic 的事务;Kafka Streams 的 processing.guarantee=exactly_once_v2;事务对吞吐和延迟的影响;生产者被 fence(同一个 transactional.id 两个实例同时存在)时的行为。
    京ICP备2026067502号-1京公网安备11010502063487号