bigywater.tech
Kafka 内部原理 · 30

Offset 提交后宕机,消息会不会重复

"先提交还是先处理"决定了崩溃后是重复还是丢失。这次做了一个可以精确审计的实验:3000 条消息、每批 10 条,消费者把处理结果(这里就是把 offset 写进一个 sink 文件)落盘。分别用"先处理再提交"和"先提交再处理"两种顺序,在第 5 批的同一个位置用 Runtime.halt()(等同 kill -9)杀掉进程,同一个消费组重启后读到底,最后统计 sink 文件里每个 offset 出现了几次。

验证:先处理、后提交——崩溃后重复处理 10 条,一条不丢
崩溃时第 5 批(offset 40 到 49)已经处理完并写进了 sink,但提交没来得及执行,已提交的位置还停在 4
验证:先提交、后处理——崩溃后丢失 5 条,没有重复
这次第 5 批(offset 40 到 49)在处理之前就已经提交了,位置是 50。

验证:先处理、后提交——崩溃后重复处理 10 条,一条不丢

命令 · java // 每批 poll 最多 10 条,enable.auto.commit=false // process-first: 处理整批(写 sink)-> commitSync();第 5 批处理完、提交之前 halt
真实实测 · 本机执行输出
CRASH process-first: 第 5 批已全部处理,还没提交 (最后处理 offset=49)
RESTART 正常读完,最后处理 offset=2999
process-first: 处理记录总数=3010,唯一 offset=3000,丢失(从未处理)=0,重复处理=10(offset 40~49)

崩溃时第 5 批(offset 40 到 49)已经处理完并写进了 sink,但提交没来得及执行,已提交的位置还停在 40。重启后消费者从 40 重新开始,这 10 条又被处理了一遍:sink 里一共有 3010 行、3000 个不同的 offset,没有任何 offset 缺失,重复的正好是 40 到 49。这就是"至少一次"的样子。

验证:先提交、后处理——崩溃后丢失 5 条,没有重复

命令 · java // commit-first: 每批先 commitSync(),再逐条处理(写 sink);第 5 批提交之后处理到第 5 条时 halt
真实实测 · 本机执行输出
CRASH commit-first: 第 5 批已提交,只处理了 5 条 (最后处理 offset=44)
RESTART 正常读完,最后处理 offset=2999
commit-first: 处理记录总数=2995,唯一 offset=2995,丢失(从未处理)=5(offset 45~49),重复处理=0

这次第 5 批(offset 40 到 49)在处理之前就已经提交了,位置是 50。进程只处理到 offset 44 就崩溃,重启后从 50 开始,offset 45 到 49 这 5 条再也没有被处理:sink 里只有 2995 行、2995 个不同的 offset,缺的正是 45 到 49,没有任何重复。这就是"至多一次"的样子。

验证:sink 里能看出去重的可能,重复的部分并没有制造出新的 offset

在"先处理、后提交"的实验里,sink 一共 3010 行,但不同的 offset 只有 3000 个,多出来的 10 行都是完全相同的 offset(40 到 49)。这个数字来自上面同一个 sink 文件的统计,不是另外做的实验:如果处理动作本身是幂等的(比如以 offset 为主键写入,或者输出天然是"覆盖写"),这 10 次重复处理对最终结果的影响就是零。我没有单独实现一个"以 offset 为主键的下游"去验证,所以这里只能说:重复的都是相同 offset 的相同内容,具备被幂等处理吸收的条件。

结论

两种顺序在同一个崩溃点上的结果:先处理后提交,重复 10 条、丢 0 条(至少一次);先提交后处理,重复 0 条、丢 5 条(至多一次)。所以"提交后宕机,消息会不会重复"的答案取决于提交发生在处理之前还是之后:处理之后提交才会重复,处理之前提交就会丢失。多数业务选择"先处理后提交",再让处理逻辑幂等来吸收重复。

分步演示:同一个崩溃点,两种提交顺序结果完全相反

每一步都是本文上面两节用Runtime.halt()在同一崩溃点触发、审计sink文件得到的原始结果。

提交在处理前还是后,决定了崩溃后是重复还是丢失
这一步新验证的模式

    参考与说明

    • 数据来自本机 3 节点 Kafka 4.3.1 集群,topic 为 1 分区 3 副本、3000 条消息;每种顺序只跑了一次,崩溃点固定在第 5 批,所以重复 10 条、丢失 5 条这两个数字是这个崩溃点的确定结果,换一个崩溃点数字会变,但"处理之后提交只会重复、处理之前提交只会丢失"的方向不会变。
    • 崩溃用 Runtime.getRuntime().halt(0) 模拟,不执行任何 shutdown hook;sink 是本地文件(每次写入后 flush),不涉及事务。
    • 和《enable.auto.commit 的风险》一起看:那篇看到自动提交让"提交和处理脱钩",这篇把"提交在处理前还是后"这个顺序单独拆出来。
    • 没有验证:Kafka 事务和 isolation.level=read_committed 提供的"处理并提交原子化";在 Kafka 内部的"读-处理-写"场景下用生产者事务实现精确一次;崩溃发生在提交请求已发出但响应未回来的瞬间时,提交是否生效。
    京ICP备2026067502号-1京公网安备11010502063487号