"先提交还是先处理"决定了崩溃后是重复还是丢失。这次做了一个可以精确审计的实验:3000 条消息、每批 10 条,消费者把处理结果(这里就是把 offset 写进一个 sink 文件)落盘。分别用"先处理再提交"和"先提交再处理"两种顺序,在第 5 批的同一个位置用 Runtime.halt()(等同 kill -9)杀掉进程,同一个消费组重启后读到底,最后统计 sink 文件里每个 offset 出现了几次。
命令 · 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。这就是"至少一次"的样子。
命令 · 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 一共 3010 行,但不同的 offset 只有 3000 个,多出来的 10 行都是完全相同的 offset(40 到 49)。这个数字来自上面同一个 sink 文件的统计,不是另外做的实验:如果处理动作本身是幂等的(比如以 offset 为主键写入,或者输出天然是"覆盖写"),这 10 次重复处理对最终结果的影响就是零。我没有单独实现一个"以 offset 为主键的下游"去验证,所以这里只能说:重复的都是相同 offset 的相同内容,具备被幂等处理吸收的条件。
两种顺序在同一个崩溃点上的结果:先处理后提交,重复 10 条、丢 0 条(至少一次);先提交后处理,重复 0 条、丢 5 条(至多一次)。所以"提交后宕机,消息会不会重复"的答案取决于提交发生在处理之前还是之后:处理之后提交才会重复,处理之前提交就会丢失。多数业务选择"先处理后提交",再让处理逻辑幂等来吸收重复。
每一步都是本文上面两节用Runtime.halt()在同一崩溃点触发、审计sink文件得到的原始结果。