有一类消费延迟问题很绕:进程没挂,网络也通,监控里的心跳看着还算正常,但消费者还是反复重平衡,最后在提交 offset 时撞上了 CommitFailedException。
这类问题很容易把人带偏。很多排查会先盯着 session.timeout.ms,然后把它调大。过一会儿故障又回来。
因为 Kafka 判断一个消费者是否还能干活,不只看心跳。
一段很容易出事的消费代码
先看一个缩短了超时时间的最小例子:
java
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-risk");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class.getName());
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 10_000);
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 100);
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
consumer.subscribe(List.of("orders"));
while (true) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofSeconds(1));
for (ConsumerRecord<String, String> record : records) {
callRiskService(record); // 假设单条 P99 要 200ms
}
consumer.commitSync();
}
}
如果一次 poll() 真拿到 100 条,串行处理大约要 20 秒。可这里的 max.poll.interval.ms 只有 10 秒。
进程仍然活着,后台线程在前半段也能正常发心跳。但两次 poll() 之间拖得太久,Kafka 客户端会认为这个成员已经无法继续推进消费,主动离开消费组。分区被重新分配后,这个旧成员再提交 offset,就可能收到 CommitFailedException。

心跳和 poll 管的是两件事
我会把消费者的状态拆成三条线看。
心跳线 判断"这个进程还在不在"。经典消费组协议下,消费者按 heartbeat.interval.ms 发心跳;超过 session.timeout.ms 没收到心跳,协调器才把它移出组。
poll 线 判断"这个进程是不是还在干活"。max.poll.interval.ms 限制两次 poll() 调用之间的最长间隔。默认值是 5 分钟。它防的是活锁:进程没死、心跳没断,但业务线程卡住,分区一直被占着却没有进度。
进度线 看业务是否真的向前走。offset 有没有持续提交、records-lag-max 是否上涨、最后一次成功处理是什么时候,都属于这一条。
所以,心跳正常只能证明消费者没有失联,不能证明它还在正常消费。
Kafka 4.0 之后又多了一个容易答旧的地方。新 Consumer Protocol 已经可用,但客户端默认仍是 group.protocol=classic。切到 group.protocol=consumer 后,心跳间隔和 session timeout 改由 broker 控制,客户端的 heartbeat.interval.ms、session.timeout.ms 不再生效;max.poll.interval.ms 这条业务活性边界仍然在。
为什么调大 session timeout 没用
上面的代码不是心跳丢了,而是处理一批消息耗时超过了 max.poll.interval.ms。
这时只调大 session.timeout.ms,改的是故障检测时间,没改两次 poll() 的间隔。两套超时不是前后级关系。
直接把 max.poll.interval.ms 从 5 分钟改成 30 分钟倒是可能不报错,但代价也很直接:业务线程活锁时,分区最多要被它多占 25 分钟。只改参数,常常只是把报警推迟。
先算一遍处理预算
单线程、单条成本接近时,可以先用一个很粗但实用的估算:
text
批次处理 P99 ≈ max.poll.records × 单条处理 P99
批次处理 P99 + 提交 P99 < max.poll.interval.ms × 70%
70% 不是 Kafka 官方规定,是我会留的工程余量。GC、下游抖动、DNS、连接池等待都会吃掉剩下的时间。
拿前面的例子算,单条 P99 是 200 毫秒,max.poll.records=100,光处理就可能到 20 秒。要守住 10 秒的 poll 间隔,批次上限至少得先降到 35 条附近,而不是继续盯着心跳参数。
最先该试的通常是这几件事:
- 降低
max.poll.records,让单批处理时间有明确上界。 - 给下游调用设超时,别让一条消息无限占住消费线程。
- 把批次处理耗时、单条 P99 和提交耗时分开监控,别只看总耗时。
- 一条消息本身就可能处理几分钟时,再有意识地调大
max.poll.interval.ms,同时接受故障接管会变慢。
慢任务丢到线程池,也不是扔进去就完了
业务确实很慢时,可以让 poll 线程只负责拉取和组协调,把处理交给有界线程池。队列快满时,对相关分区执行 pause();队列下降后再 resume(),poll 线程仍持续调用 poll()。
但这里有两个坑。
KafkaConsumer 不是线程安全的。poll、pause、resume、offset 提交这些动作应留在消费线程,不要让工作线程直接操作 consumer。
另一个是 offset 顺序。工作线程完成顺序可能是 102、100、101,这时不能因为 102 做完就直接提交 103。要按分区维护"连续完成到哪里",只提交已经连续处理成功的下一个 offset。否则进程一重启,100 和 101 可能被跳过去。
这个方案能把拉取和处理解耦,但代码复杂度明显上升。只是偶发慢请求,先缩批次、补超时,往往更划算。
监控别只放一条 lag
我现在至少会把下面几项放在一起看:
last-poll-seconds-ago:距离上次调用poll()过去了多久。time-between-poll-max:两次poll()的最大间隔。records-lag-max:当前最落后的分区差多少条。rebalance-total和failed-rebalance-total:消费组是否在频繁抖动。CommitFailedException数量,以及最后一次成功提交 offset 的时间。- 业务处理 P99、线程池队列长度、下游超时率。
lag 很重要,但单独看不够。低峰期没有新消息时,消费者就算卡住,lag 也可能不增长;last-poll-seconds-ago 却会直接暴露 poll 线程已经很久没回来。
文中的配置行为和指标名,按 Apache Kafka 4.3 的 Consumer Configs、KafkaConsumer Javadoc、Consumer Rebalance Protocol 与官方监控文档核对。