bigywater.tech
Kafka 内部原理 · 28

Offset 提交:自动提交和手动提交有什么区别

Offset 提交决定了消费者重启后从哪里继续读。自动提交、commitSync()、commitAsync() 三种方式的区别不只是"写起来方不方便"。这次量了它们的开销,验证了 commitSync() 不带参数时提交的到底是哪个位置,并看了异步提交失败时代码是怎么被通知的。

验证:三种方式读完 3000 条,总耗时几乎一样;单次提交调用的开销 sync 约 1.8ms,async 约 0.2ms
两轮结果一致:同步提交每次调用要等 Coordinator 返回,约 1.8~1.9 毫秒;
验证:不带参数的 commitSync() 提交的是"已经拉到"的位置,不是"已经处理完"的位置
这是这篇文章里最需要留意的一个结果:commitSync() 不带参数,提交的是这个消费者当前的读取位置(这次是 100

验证:三种方式读完 3000 条,总耗时几乎一样;单次提交调用的开销 sync 约 1.8ms,async 约 0.2ms

命令 · java // topic commit-demo: 1 分区 x 3 副本,3000 条消息;每次 poll 最多 100 条,共 30 批 // sync: 每批 commitSync();async: 每批 commitAsync();auto: enable.auto.commit=true,不显式提交 // 每种模式都新建一个消费组,从头读完 3000 条,各跑两次
真实实测 · 本机执行输出
模式=sync   总耗时 8.653s  提交 30 次  累计 54.3ms  平均 1.81ms/次
模式=async  总耗时 8.602s  提交 30 次  累计  6.3ms  平均 0.21ms/次
模式=auto   总耗时 8.704s  提交  0 次
模式=sync   总耗时 8.646s  提交 30 次  累计 56.8ms  平均 1.89ms/次
模式=async  总耗时 8.581s  提交 30 次  累计  6.6ms  平均 0.22ms/次
模式=auto   总耗时 9.366s  提交  0 次

两轮结果一致:同步提交每次调用要等 Coordinator 返回,约 1.8~1.9 毫秒;异步提交只是把请求发出去,约 0.21~0.22 毫秒,大约是前者的九分之一。但三种模式读完 3000 条的总耗时都在 8.6 秒左右(auto 有一次 9.4 秒),差别小于波动。原因是这个测试大部分时间花在启动、入组和等待分区分配上,30 次同步提交总共才多出 50 毫秒,在这个数据量下完全被淹没。所以结论只能是"每次调用的开销 sync 比 async 大约一个数量级",而不能说"用 sync 会让整体变慢"。

验证:不带参数的 commitSync() 提交的是"已经拉到"的位置,不是"已经处理完"的位置

命令 · java // 同一批 poll 拿到 100 条,只处理前 10 条(最后处理的 offset=9)后提交,然后关闭消费者,再用同一个组重新启动 // noarg: c.commitSync() // explicit: c.commitSync(Map.of(tp, new OffsetAndMetadata(lastProcessed + 1)))
真实实测 · 本机执行输出
[noarg]    本批拿到 100 条,只真正处理了 10 条(最后处理的 offset=9)
[noarg]    重启后同组消费者拿到的第一条 offset = 100
[explicit] 本批拿到 100 条,只真正处理了 10 条(最后处理的 offset=9)
[explicit] 重启后同组消费者拿到的第一条 offset = 10

这是这篇文章里最需要留意的一个结果:commitSync() 不带参数,提交的是这个消费者当前的读取位置(这次是 100,也就是这批 100 条拉完之后的位置),而不是实际处理到哪里。我只处理了 offset 0 到 9 这 10 条,重启后同组的消费者直接从 offset 100 开始,offset 10 到 99 这 90 条从未被处理就永远跳过了。换成显式提交 lastProcessed + 1(也就是 10),重启后从 offset 10 继续,一条没丢。所以手动提交的价值不只是"自己决定什么时候提交",还在于可以精确提交"处理完的位置"。

验证:异步提交失败时不会抛异常,只会调用回调

命令 · java // max.poll.interval.ms=6000,每批处理 9 秒后 commitAsync(callback)
真实实测 · 本机执行输出
[ 3232ms] 第 1 批 100 条,模拟处理 9 秒(超过 max.poll.interval.ms=6000)
[12248ms] commitAsync() 调用返回了,没有抛异常
[12248ms] commitAsync 回调: CommitFailedException
[15271ms] 第 2 批 100 条,模拟处理 9 秒
[24277ms] commitAsync() 调用返回了,没有抛异常
[24277ms] commitAsync 回调: CommitFailedException

同样的"处理太慢被踢出消费组"场景,在《Consumer 为什么必须调用 poll()》里 commitSync() 会直接抛出 CommitFailedException。换成 commitAsync(),调用本身正常返回、没有异常,失败信息只出现在回调里。如果不传回调,这个失败会悄无声息(这次没有单独测不传回调时的日志表现,只验证了带回调时的行为)。所以用异步提交时,回调里要处理失败,否则程序会以为提交成功。

结论

三种方式的区别:sync 每次提交约 1.8ms,async 约 0.2ms(这个量级的差别在小数据量下不会影响整体耗时);不带参数的 commitSync() 提交的是读取位置而非处理进度,处理一半就提交会永久跳过未处理的消息;异步提交失败只通过回调通知,不会抛异常。

分步演示:只处理了10条却提交了100条的位置,会发生什么

每一步都是本文上面几节运行Java Consumer、重启验证offset得到的原始结果。

不带参数的commitSync()提交的是"拉到哪",不是"处理到哪"
这一步新验证的结果

    参考与说明

    • 数据来自本机 3 节点 Kafka 4.3.1 集群,group.protocol=classic,3000 条消息由 console producer 写入。
    • 自动提交本身的风险(间隔、崩溃时的丢失或重复)留给接下来两篇《enable.auto.commit 的风险》《Offset 提交后宕机,消息会不会重复》,这里的 auto 模式只用于对比耗时。
    • 没有验证:commitAsync 多次调用的顺序与重试问题;提交时携带 metadata;commitSync 的超时参数;在数据量大、网络慢的环境里 sync 与 async 对总吞吐的影响。
    京ICP备2026067502号-1京公网安备11010502063487号