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