bigywater.tech
Kafka 内部原理 · 21

Consumer 为什么必须调用 poll()

Kafka Consumer 的 poll() 不只是"拿数据",它还是消费者向组协调器证明"我还活着并且在处理"的方式:max.poll.interval.ms 规定两次 poll() 之间最长能隔多久。这次用一个的 Java Consumer,在 poll 之间模拟不同的处理耗时,看超过这个上限之后会发生什么,最后加一组对照排除别的原因。

验证:处理时间在上限之内,5 批数据正常消费并提交
对照组按预期工作:5 批各 100 条,最终提交的 offset 是 500。
验证:处理时间 9 秒超过 6 秒上限,提交失败,被踢出消费组,同一批数据被重新拉取
只是把每批处理时间从 1 秒拉长到 9 秒(超过 max.poll.interval.ms=6000),行为完全变了:拿

验证:处理时间在上限之内,5 批数据正常消费并提交

命令 · java // max.poll.interval.ms=6000, max.poll.records=100, session.timeout.ms=10000, group.protocol=classic // 每次 poll 拿到数据后 Thread.sleep(1000) 模拟处理,然后 commitSync()
真实实测 · 本机执行输出
===== A: 每批处理 1000ms =====
[60472] poll 返回 0 条          <- 前 3 次 poll 在加入消费组,没有数据
[61474] poll 返回 0 条
[62474] poll 返回 0 条
[62681] assigned [poll-demo-0]
[62718] poll 返回 100 条
[63731] poll 返回 100 条
... 共 5 批 ...
[67778] revoked [poll-demo-0]    <- 我主动 close() 时触发

kafka-consumer-groups.sh --describe --group grpA
grpA  poll-demo  0  CURRENT-OFFSET=500  LOG-END-OFFSET=2000  LAG=1500

对照组按预期工作:5 批各 100 条,最终提交的 offset 是 500。前 3 次 poll() 返回 0 条不是没数据(topic 里有 2000 条),从时间线看是这时消费者还在加入消费组、等待分区分配,assigned 出现在大约 2.2 秒之后,之后才开始返回数据。也就是说,poll() 除了拉数据,还承担了入组和分区分配的驱动工作。

验证:处理时间 9 秒超过 6 秒上限,提交失败,被踢出消费组,同一批数据被重新拉取

真实实测 · 本机执行输出
===== B: 每批处理 9000ms(max.poll.interval.ms=6000)=====
[76455] assigned [poll-demo-0]
[76488] poll 返回 100 条
[85495] 异常: CommitFailedException: Offset commit cannot be completed since the consumer is not
        part of an active group for auto partition assignment; it is likely that the consumer was kicked out of the group.
[85503] revoked [poll-demo-0]
[86496] poll 返回 0 条 ... [88514] assigned [poll-demo-0]
[88520] poll 返回 100 条
[97526] 异常: CommitFailedException: ...      <- 第二轮同样失败
[97527] revoked [poll-demo-0]
... 第三轮同样 ...

kafka-consumer-groups.sh --describe --group grpB
Consumer group 'grpB' has no active members.(没有任何已提交的 offset)

只是把每批处理时间从 1 秒拉长到 9 秒(超过 max.poll.interval.ms=6000),行为完全变了:拿到第一批 100 条、处理 9 秒后调用 commitSync(),收到 CommitFailedException,提示"消费者很可能已被踢出消费组",紧接着 revoked;消费者重新入组,约 3 秒后 assigned,又拿到 100 条,然后再次经历同样的失败。三轮之后,kafka-consumer-groups.sh 显示这个消费组没有任何已提交的 offset。所以这个消费者每一轮都在重新处理同一个起点的数据,永远推进不了进度。这次日志里没有单独打印每条消息的 offset,"同一批"这一点是根据"没有任何提交的 offset、每轮都是 100 条"推断的,没有逐条比对。

验证:对照组排除其他原因——同样处理 9 秒,默认上限下一切正常

真实实测 · 本机执行输出
===== C: 每批处理 9000ms,max.poll.interval.ms=300000(默认值)=====
[80146] poll 返回 100 条
[89169] poll 返回 100 条
[98181] poll 返回 100 条
[7196]  poll 返回 100 条
[16208] poll 返回 100 条
[25226] revoked [poll-demo-0]

grpC  poll-demo  0  CURRENT-OFFSET=500  LOG-END-OFFSET=2000  LAG=1500

默认值(ConsumerConfig 打印):
max.poll.interval.ms = 300000    max.poll.records = 500
session.timeout.ms = 45000       heartbeat.interval.ms = 3000

B 和 C 唯一的差别是 max.poll.interval.ms(6000 对 300000),其他条件(每批 100 条、处理 9 秒、session.timeout.ms=10000)都相同;C 正常提交到 500,没有异常。这说明 B 里的失败来自"两次 poll() 的间隔超过了 max.poll.interval.ms"。需要老实说明一个限制:实验里睡眠 9 秒小于 session.timeout.ms=10000,所以这组对照没法单独证明"心跳线程在主线程睡眠时仍在正常发送",只能说 B 和 C 的差别只在 max.poll.interval.ms,失败随它而变。默认值可以帮助理解量级:默认上限是 5 分钟,max.poll.records=500,所以业务里出问题通常是"单批处理太慢"或"单批太大",而不是消费者进程真的挂了。

结论

poll() 有三个作用:拉取数据、驱动加入消费组和分区分配、向协调器证明"我还在处理"。两次 poll() 的间隔一旦超过 max.poll.interval.ms,消费者就会被踢出组,commitSync() 抛 CommitFailedException,进度无法推进,数据被反复重新拉取。要避免它,要么缩小 max.poll.records,要么放宽 max.poll.interval.ms,要么把耗时的处理放到别的线程。

分步演示:同样处理9秒,一个消费组卡死,一个正常,差别只在一个配置

每一步都是本文上面几节运行三个独立Java Consumer得到的原始结果。

真正决定成败的不是处理快不快,是有没有超过max.poll.interval.ms
这一步卡住的消费组

    参考与说明

    • 数据来自本机 3 节点 Kafka 4.3.1 集群,topic 为 1 分区 3 副本、2000 条消息;三个 Consumer 分别使用独立的消费组(grpA、grpB、grpC),group.protocol=classic(新版 KIP-848 协议下 max.poll.interval.ms 的行为这次没有验证)。
    • 实验中 max.poll.interval.ms=6000 是为了缩短观察时间而设置的很小的值,默认是 300000(5 分钟)。
    • 和《Consumer Group:多个消费者如何分摊 Partition》一起看:被踢出组会触发 rebalance,这里只看到了单消费者场景下的 revoked/assigned,多消费者时的影响见下一篇。
    • 没有验证:心跳线程本身停止(比如整个进程被暂停)时 session.timeout.ms 的行为;enable.auto.commit=true 下的提交失败细节(日志里能看到异步自动提交同样失败,但没有细究);被踢出后消息被下一个消费者重复处理的影响。
    京ICP备2026067502号-1京公网安备11010502063487号