消费组每分钟 rebalance 一次:max.poll.interval 与静态成员

12 个消费者一个没少,整组却每分钟重平衡一次,积压三天涨到三百万。心跳正常也会被踢,元凶是两次 poll 的间隔,静态成员只解决重启那一半。

周三上午,订单事件消费组的积压曲线突然抬头。前一天晚上还是两万条,中午变成四十万,下午三点三百万。消费 TPS 从 8000 掉到 800,下游的对账任务开始延迟。

消费者是 12 个副本,跑在 K8s 里,没有任何 Pod 重启记录。CPU 不高、内存正常、网络没告警。看日志,每分钟都有一轮这样的输出:

Preparing to rebalance group order-event-consumer in state STABLE with 12 consumers
Revoking previously assigned partitions [order-event-0, order-event-3, ...]
(Re-)joining group
Successfully joined group with generation 871

十二个消费者,一个都没少,但整组每分钟重平衡一次。每次重平衡,所有消费者都要停下手里的活,撤销分区、重新加入、重新分配,中间有几秒到几十秒完全不消费。一分钟一次,等于把消费能力砍掉了九成。

一、先分清是哪一类 rebalance

Kafka 的 rebalance 有三种触发源,处理方式完全不同,混在一起查会绕远路。

第一种是成员数变化:有消费者加入、退出,或者被协调器判定为失联踢出。第二种是订阅关系变化:用正则订阅 topic 时,匹配到的 topic 数量变了,等价于订阅变了。第三种是分区数变化:topic 扩了分区,需要重新分配。

先看消费组的实际状态:

kafka-consumer-groups.sh --bootstrap-server kafka-0:9092 \
  --describe --group order-event-consumer --members --verbose

--members 会打出每个成员的 CONSUMER-ID、HOST、#PARTITIONS。这里有个关键判据:如果 CONSUMER-ID 每分钟都在变,说明成员是「被踢掉又用新 ID 加回来」,属于第一种;如果 ID 稳定、只是分配变了,那要往第二、第三种查。

我们是前者。12 个成员,每个的 ID 每分钟换一次,HOST 没变。也就是说,协调器一直在把还活着的成员踢出去。

二、心跳没断,为什么还会被踢

这里是最容易误判的地方。很多资料说「消费者被踢是因为心跳超时」,于是大家去调 session.timeout.ms 和 heartbeat.interval.ms。但 Kafka 的心跳是独立线程发的,跟你的 poll() 循环没关系。

也就是说:你的消费逻辑卡了十分钟,心跳线程仍然在每 3 秒发一次心跳给协调器。协调器看到心跳正常,session.timeout 永远不会触发。这种情况下的踢人,来自另一个参数——max.poll.interval.ms。

它的语义是「两次 poll() 调用的最大间隔」。超过这个时间没来拉数据,协调器就认为这个消费者已经死了(哪怕它还在发心跳),直接踢出组,触发 rebalance。默认值 300000,也就是 5 分钟。

我们的消费循环长这样:

while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
    for (ConsumerRecord<String, String> r : records) {
        orderService.handle(r.value());   // 里面有一次下游 HTTP 调用
    }
    consumer.commitSync();
}

max.poll.records 用的是默认值 500。单条处理平均 180ms(一次下游查询加一次写库),500 条就是 90 秒;大促期间下游变慢到 400ms,500 条变成 200 秒;再碰上批量写库的锁等待,最坏一次跑到了 6 分 40 秒。

超过 5 分钟,成员被踢,分区被撤销,剩下的成员重新分配,积压加剧;积压加剧之后每批处理更慢,被踢得更频繁。正反馈,越滚越糟。

参数 默认值 我们的值 作用
max.poll.records 500 500 一次 poll 最多返回多少条
max.poll.interval.ms 300000 300000 两次 poll 的最大间隔
session.timeout.ms 45000 45000 心跳超时判定
heartbeat.interval.ms 3000 3000 心跳发送间隔
fetch.min.bytes 1 1 攒够多少字节才返回

排查时可以先从 JMX 或 Prometheus 里确认 last-rebalance-seconds-ago 和 rebalance-rate-per-hour,这两个指标能直接量化「抖得有多厉害」。我们当时 rebalance-rate-per-hour 是 58,健康值应该在 1 以下。

三、把「处理时间」压回 interval 以内

方向有两个:调大 interval,或者缩短单批处理时间。调大 interval 只是把阈值往后挪,积压时照样会被踢,而且真卡死时恢复更慢。所以我们选择压处理时间。

第一步把批量降到 100:

props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 100);
props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300000);

100 条 × 180ms ≈ 18 秒,离 5 分钟有充足余量。代价是 poll 次数变多,但 poll 本身开销很小,吞吐几乎不受影响。

第二步把下游调用从「逐条」改成「批量」。原来每条消息查一次订单快照,现在按 100 条一批走一次 IN 查询,平均单条耗时从 180ms 降到 40ms。这一步的收益比调参数大得多。

第三步给每批处理加一个软超时:处理到 max.poll.interval 的 60% 时,把剩余的位点用 seek 回退,先提交已处理的部分,再跳出本轮。听起来有点脏,但它保证了「宁可重复消费,也不要把整组拖进 rebalance」。前提是消费逻辑幂等,后面会说。

四、静态成员:让发布不再引发全组抖动

处理时间的问题解决后,rebalance 从每分钟一次降到几小时一次。但每次发版还是会抖一轮——12 个 Pod 滚动更新,每起一个就触发一次重平衡,最坏 12 次。

group.instance.id 就是为这个场景设计的。给每个消费者一个稳定的 ID,重启后在 session.timeout 内用同一个 ID 回来,协调器不会认为成员数变化,也就不触发 rebalance:

props.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, System.getenv("POD_NAME"));
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 60000);

在 K8s 里直接用 POD_NAME(StatefulSet 的话天然稳定,Deployment 的 Pod 名每次变,得换成序号或从配置里读)。三个注意点:

第一,group.instance.id 在组内必须唯一。重复了会直接报 FencedInstanceIdException,两个消费者互相把对方踢掉,症状和这次的故障非常像。

第二,静态成员只覆盖「重启」这一种情况。如果处理时间仍然超过 max.poll.interval,照样被踢,而且因为 ID 一样,重新加入时会顶着旧成员的身份,排查起来更绕。

第三,session.timeout 要放大到「Pod 重启 + 镜像拉取 + JVM 启动」的总时长以上,否则还没起来就被判死。我们给到 60 秒,配合镜像预热,基本够用。

五、协作式再均衡,减少「全员停摆」

Kafka 2.4 之后有了增量协作式再均衡(CooperativeStickyAssignor)。老协议是「所有人先撤销全部分区,再重新分配」,一次 rebalance 期间整个组不消费;协作式只撤销「需要迁移的那部分」,其余分区继续消费。

切换要改配置,而且客户端和服务端都要支持(2.4+):

props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
    "org.apache.kafka.clients.consumer.CooperativeStickyAssignor");

升级有个坑:协议从 Eager 换到 Cooperative 需要经过一个「两组协议共存」的过渡版本,不能所有消费者一次性改。标准做法是先把 partition.assignment.strategy 配成 [CooperativeStickyAssignor, RangeAssignor] 推一轮,等所有成员都升级完,再去掉 RangeAssignor。中间会有一次 rebalance,属正常。

六、消费端改造:幂等是前提

上面所有「宁可重入队也不拖垮整组」的手段,都建立在同一个前提上:重复消费不会造成业务错误。

我们的处理是「唯一键 + 状态机」:每条消息带业务单号,落库前先 INSERT ... ON DUPLICATE KEY UPDATE,状态只能单向流转(待支付→已支付→已发货),重复消息进来时状态已经前进,直接跳过。这样即使同一批消息被处理两遍,结果也一样。

异步提交要慎用。commitAsync 不阻塞,性能好,但它在 rebalance 期间可能提交失败,而下一次 commitAsync 成功之后,失败的那次就永远补不回来了。我们的做法是「循环里 commitAsync,关闭和撤销分区时 commitSync」:

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    consumer.wakeup();
    try { consumer.commitSync(Duration.ofSeconds(5)); } catch (Exception ignored) {}
    consumer.close();
}));

wakeup() 会让阻塞中的 poll 抛 WakeupException,跳出循环,然后同步提交一次,把已经处理完的位点落下去。少了这一步,每次发布都会重复消费一批——量不大,但对账时会看到零星的重单。

小结

  • rebalance 有三种触发源,先用 --members 看 CONSUMER-ID 是否稳定,能立刻分清是「成员被踢」还是「订阅/分区变了」。
  • 心跳是独立线程,session.timeout 不生效时成员照样会被踢,元凶是 max.poll.interval.ms:两次 poll 的间隔超了。
  • 单批处理时间 × 批量必须留在 interval 以内,压处理时间比调大 interval 有效得多。
  • group.instance.id 能让滚动发布不触发全组重平衡,但它只解决重启,不解决处理超时,且 ID 必须唯一。
  • 协作式再均衡要分两阶段切换,一次改完会让组进入反复 rebalance。
  • 所有「重入队、重复消费」的容错手段都以幂等为前提,没有幂等就先别动这些开关。
查看「中间件」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

消费组每分钟 rebalance 一次:max.poll.interval 与静态成员 - 起航社区