Kafka Consumer 的 poll() 不只是"拿数据",它还是消费者向组协调器证明"我还活着并且在处理"的方式:max.poll.interval.ms 规定两次 poll() 之间最长能隔多久。这次用一个的 Java Consumer,在 poll 之间模拟不同的处理耗时,看超过这个上限之后会发生什么,最后加一组对照排除别的原因。
命令 · 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() 除了拉数据,还承担了入组和分区分配的驱动工作。
===== 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 条"推断的,没有逐条比对。
===== 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,要么把耗时的处理放到别的线程。
每一步都是本文上面几节运行三个独立Java Consumer得到的原始结果。