enable.auto.commit=true 是默认值,auto.commit.interval.ms 默认 5000。大家通常的印象是"自动提交可能重复消费"。这次用的崩溃模拟验证:消费者拿到数据后,用 Runtime.halt()(等同 kill -9,不关闭消费者、不做最后一次提交)终止进程,再用 kafka-consumer-groups.sh 读出这个组真正提交的 offset,对比崩溃前已经真正处理到哪里。结果比"可能重复"严重得多:换一种写法,会直接丢掉几千条。
命令 · java // topic commit-demo: 1 分区,3000 条;max.poll.records=10;每条消息处理 200ms(一批 2 秒) // enable.auto.commit=true;从拿到第一批数据起运行 7 秒后 Runtime.halt(0) 模拟崩溃 // 分别用 auto.commit.interval.ms=5000(默认)和 1000
interval=5000: 崩溃前已处理=40 条(最后处理的 offset=39) -> 崩溃后组的已提交 offset = 30 interval=1000: 崩溃前已处理=40 条(最后处理的 offset=39) -> 崩溃后组的已提交 offset = 30
已经处理到 offset 39,但提交的只有 30:重启后同组的消费者从 offset 30 开始,offset 30 到 39 这 10 条会被再处理一次,也就是重复最近一批。更有意思的是把 auto.commit.interval.ms 从 5000 降到 1000,结果完全一样(都是 30)。我对这个现象的解释是(结果是实测的,解释没有读源码验证):自动提交是在 poll() 调用里顺带触发的:每批处理要 2 秒,poll() 每 2 秒才被调用一次,1 秒的间隔根本没机会生效,提交只会发生在"下一次 poll() 时",此时才把上一批的位置提交上去;崩溃发生在第 4 批处理完、下一次 poll() 之前,所以第 4 批一定没提交。所以重复的上限受"两次 poll() 的间隔"支配,而不是只看 auto.commit.interval.ms。
命令 · java // 同样的 enable.auto.commit=true,auto.commit.interval.ms=5000 // 主线程只负责 poll,把每条记录放进队列,另有一个 worker 线程每条处理 200ms // 从拿到第一批数据起运行 7 秒后 Runtime.halt(0)
MODE=handoff 崩溃前已处理=34 条(最后处理的 offset=33) 队列里还没处理=2965 崩溃后组的已提交 offset = 3000 (topic 一共只有 3000 条)
主线程只管拉数据,7 秒内就把 3000 条全部拉到手放进了队列,而 worker 只处理了 34 条。自动提交并不关心 worker 处理到哪里,它提交的是"poll() 已经返回过的位置",于是提交的 offset 是 3000。崩溃后同组的消费者从 3000 开始,也就是说 offset 34 到 2999 这大约 2966 条消息(队列里的 2965 条,加上崩溃时 worker 手里正在处理的那一条)从未被处理,也再也不会被这个组读到——这是丢消息,而不是重复消息。按"日志末尾 3000 减去已提交 3000"计算,这个组的 LAG 会显示为 0,监控上看起来一切正常(我只打印了已提交 offset 那一列,没有单独截取 LAG 列,这是按定义推算的)。
enable.auto.commit=true 的风险有两种:如果处理和 poll() 在同一个线程里同步完成,最坏是重复最近一批(这次是 10 条),而且缩短 auto.commit.interval.ms 未必能改善,因为提交只发生在 poll() 里;一旦处理被交给别的线程,自动提交就与处理进度脱钩,进程崩溃时会丢掉队列里所有未处理的数据(这次约 2966 条)。所以只有"每次 poll() 返回的数据在下次 poll() 之前都已经处理完"这个前提成立时,自动提交才是安全的(至少一次语义)。
每一步都是本文上面两节用Runtime.halt()模拟崩溃、查询已提交offset得到的原始结果。