半夜三点,你的 Kafka 消费者在互相踢来踢去:一次 rebalance 风暴的现场重建

2026-11-14 5 0

半夜三点,手机震了。手机不在我兜里,在 Kafka 集群的告警里。

消息积压、消费组重平衡、offset 提交失败、日志刷得比打印机还快。我盯着监控大屏,看着那根代表「活跃消费者」的曲线像心电图一样抽搐,心里只有一个念头:这玩意儿又来了。

如果你也用过 Kafka,你大概率也见过类似的场景。而我要说的不是「怎么把 Kafka 调优到极致」这种正确的废话,而是三件更实在的事:

第一,消费者组重平衡不是偶发抖动,它是一颗定时炸弹,参数就是引信。
第二,你的消费者大概率在「假装消费」,offset 提交的那一瞬间,才是真正的崩溃现场。
第三,别拿 Kafka 当数据库用,这是十有八九会后悔的架构决定。

下面把这三件事拆开聊。


一、消费者组:你以为你在消费,其实你在排队

先对齐一下基本盘。Kafka 的消费者组(Consumer Group)机制,本质上是一个自动分区分配协议:

每个分区同一时刻只归一个消费者所有;消费者死了,组内其他消费者重新瓜分分区;新的消费者加入,同样触发一次重新瓜分。这个「重新瓜分」的过程就叫 rebalance。

关键在于 Kafka 2.x 之后的默认分配策略是 RangeAssignor 或 CooperativeStickyAssignor,前者按 topic 逐个区间切分。举个例子:

topic-a: 12 个分区
topic-b:  3 个分区
消费者数量: 3

RangeAssignor 的分配结果:
  consumer-1 → topic-a[0..3],  topic-b[0]
  consumer-2 → topic-a[4..7],  topic-b[1]
  consumer-3 → topic-a[8..11], topic-b[2]

看起来挺公平,对吧?但你把 topic-c 再加进来,它有 100 个分区,问题就来了:

consumer-1 → topic-a[0..3] + topic-c[0..33]   ← 38 个分区
consumer-2 → topic-a[4..7] + topic-c[34..66]  ← 37 个分区
consumer-3 → topic-a[8..11] + topic-c[67..99] ← 37 个分区

同一个 consumer 里,不同 topic 的分区会呈数量级差异。如果 topic-c 的单条消息处理耗时是 topic-a 的十倍,那么 consumer-1 就是那个拖后腿的倒霉蛋。而此时组里所有人都在等它,它自己也在等一批它处理不过来的消息。

死锁式拖垮。教科书都没教你,因为这不是 bug,这是策略默认值在替你做你没同意过的决定。

更糟的是:rebalance 期间整个组是停摆的

哪怕是 CooperativeStickyAssignor 这种增量式的,也只是把「全部撤销再全部分配」变成了「先撤销要动的,再分配要动的」。在旧分区撤销完成之前,新分区不会开始消费。

所以 rebalance 风暴的破坏力是乘法级的:一次 30 秒的 rebalance,遇上 10 个消费者同时上下线,积压会瞬间吃掉你一整个容量水位。


二、四个翻车姿势,我猜你至少中两个

姿势一:把 poll 循环写成了一段巨型业务代码

Kafka 消费者的心跳机制是这样的:消费者线程在后台线程里发心跳保活,而只有调用 poll() 时才会把这期间积累的消息交给你的代码。也就是说——

你的业务代码跑得越久,poll() 间隔就越长;poll() 间隔越长,越容易超过 max.poll.interval.ms。

超过之后,Kafka 认为这个消费者死了,把它踢出组,触发 rebalance,而你的业务代码还在那跑着、还在写库、还在发 MQ。它以为自己是消费者,其实它已经是个野生消费者了——它提交的 offset 会被直接拒绝,因为它的 generation id 已经过期。

真实代码长这样:

// ❌ 一看就危险
while (true) {
  ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
  for (ConsumerRecord r : records) {
    enrich(r.value());      // 调 RPC,200ms
    save(r.value());        // 写库,可能 300ms
    notify(r.value());      // 发消息,200ms
    audit(r.value());       // 同步落审计表,500ms
  }
  consumer.commitSync();     // 一次性提交
}

一批 500 条消息,每条 1.2 秒 → 600 秒处理时间。默认 max.poll.interval.ms 是 300000(5 分钟)。恭喜,你稳定触发一次「消费者假死」。

更阴险的是:它只在偶发时发作。消息量小的时候一切正常,流量一涨就突然开始「莫名其妙重复消费」,而你查监控会发现消费者数量在剧烈抖动,却看不到任何错误日志——因为代码里压根没有处理 CommitFailedException。

姿势二:手动提交 offset,但提交时机是错的

自动提交(enable.auto.commit=true)是定时批量提交,语义上是「至少一次」,偶尔会重复。而手动提交看起来精确,实际上写法不对就变成「至少一次」甚至「两次都不确定」。

错误示范:

// ❌ 先提交,后处理 —— 宕机即丢消息
consumer.poll();
consumer.commitSync();
process(records);

// ❌ 处理完了,但 offset 是「下一批」的位置 —— 断在缝里
for (ConsumerRecord r : records) {
  process(r);
}
consumer.commitSync();  // 提交的是 poll 之后的位置

正确的做法是记住每条消息的 offset,处理成功后手动提交到「下一条待处理消息的 offset」:

// ✅ 逐条精确提交
for (ConsumerRecord<String, String> r : records) {
  try {
    process(r.value());
    consumer.commitSync(Map.of(
        new TopicPartition(r.topic(), r.partition()),
        new OffsetAndMetadata(r.offset() + 1)));
  } catch (Exception e) {
    log.error("处理失败, 将重试", e);
    break;  // 不提交,等 rebalance 后重投
  }
}

但逐条 commitSync 本身也很贵——它是一次网络往返加一次元数据刷盘。高吞吐场景更常见的是「批量攒一批提交一次」,牺牲一点精度换吞吐:

// ✅ 批量提交:记录本批最大 offset
long lastOffset = -1;
Map<TopicPartition, OffsetAndMetadata>> offsets = new HashMap<>();

for (ConsumerRecord r : records) {
  process(r.value());
  lastOffset = r.offset();
  offsets.put(new TopicPartition(r.topic(), r.partition()),
              new OffsetAndMetadata(lastOffset + 1));
}
consumer.commitSync(offsets);

姿势三:把 max.poll.records 调到几千条然后祈祷

这招的思路是「一次拉多点,减少 poll 次数」,听起来很美。但它同时放大了一切:单批处理时间变长、内存占用飙升、失败时重复消费的范围变大、rebalance 时的「已拉取未处理」数据全部作废重投。

更糟的是它制造了一种错觉:你以为 poll 间隔短是因为你处理得快,其实是因为你一批拉得多。参数没解决根本问题,只是把炸弹的引信拉长了。

姿势四:给每个消费者发一个唯一 group.id

我见过这个操作:「每个实例配一个独立的消费组,保证每个实例都消费一遍全量数据。」

恭喜,你用 Kafka 实现了一个极其昂贵、极其缓慢的广播系统。每条消息会被复制 N 份,N 等于实例数。扩容十倍,存储和带宽成本直接十倍。而且这类「广播」场景,Kafka 的正确答案是完全不同的东西——要么用不同的 topic,要么用外部扇出服务,要么干脆用 pub/sub 中间件。

Kafka 适合的是分区级别的并行消费,不是实例级别的广播。这个误解能烧掉一整个季度的云账单。


三、一个不那么容易翻车的消费者长什么样

把上面四个坑合起来,安全的骨架大概是这样:

Properties p = new Properties();
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokers);
p.put(ConsumerConfig.GROUP_ID_CONFIG, "order-consumer");
p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
p.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "200");
p.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, "300000");
p.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "10000");
p.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, "3000");
p.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
      "org.apache.kafka.clients.consumer.CooperativeStickyAssignor");

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(p);
consumer.subscribe(List.of("orders"));

while (running) {
  ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(200));

  // 优雅关闭:把当前批次处理完再退出,避免最后一批丢失
  if (!running && records.isEmpty()) break;

  for (ConsumerRecord r : records) {
    process(r.value());
    offsets.put(tp, new OffsetAndMetadata(r.offset() + 1));
  }
  if (!offsets.isEmpty()) {
    consumer.commitSync(offsets);
    offsets.clear();
  }
}

要点就四条:

  • 关掉自动提交,把 offset 提交变成显式、可控、可推理的行为。
  • 用 CooperativeStickyAssignor,把全量 rebalance 降级成增量 rebalance。
  • 批量处理 + 批量提交,把单条消息的 offset 记准,提交到下一条的位置。
  • 处理逻辑要快。如果单条处理超过秒级,就该把慢操作拆出去:写库走批量、RPC 走异步、复杂的富化走预计算。

最后一条才是真正的解药。前面三条都是防御,这一条是治疗。消费逻辑越慢,所有参数调优都是在跟自己的架构较劲。


四、真出事了,怎么查

先看这三个指标,比翻日志快十倍:

# 消费延迟:最重要的北极星
consumer-lag / kafka.consumer:type=consumer-fetch-manager-metrics,client-id=X

# rebalance 频率与耗时
kafka-consumer-groups --describe --group order-consumer --offsets
kafka-consumer-groups --bootstrap-server X --describe --group order-consumer

# 每次 rebalance 到底谁走了
日志里搜:Revoking partitions / Assigning partitions / Member XXX leaving

排查顺序我一般这么走:

  1. 先看 lag 曲线是「尖峰」还是「阶梯」。尖峰 = 一次性事件(部署、扩容、GC 停顿);阶梯 = 持续性跟不上(处理逻辑太慢或分区不均)。
  2. 再看重平衡频率。如果每分钟都在 rebalance,去查 max.poll.interval.ms 和实际 poll 间隔的差值。
  3. 然后查分区倾斜。用 kafka-consumer-groups --describe 看每个 consumer 拿到几个分区、lag 差多少。
  4. 最后看消费者日志里的异常类型。CommitFailedException = 已被踢出组;RebalanceInProgressException = 正常的协作式撤销。

一个容易忽略的点:JVM 的 GC 停顿也能触发 rebalance。一次 Full GC 停 6 秒,超过了 max.poll.interval.ms 的一半,几台机器一起 Full GC,组就开始抖了。这时候你该调的不是 Kafka 参数,是堆内存和 GC 参数(或者直接把 max.poll.interval.ms 调大到 10 分钟)。


五、最后说点得罪人的

我最想吐槽的是这个:

把 Kafka 当数据库用。
存进去,就当它不会丢。删掉,就当它一定删了。查一下,就当它能像 MySQL 那样 WHERE。

Kafka 是日志,不是数据库。它擅长的是「顺序追加 + 高吞吐消费 + 按 offset 精确重放」,不擅长的是随机点查、条件查询、事务一致性(单分区有序不等于全局有序,多分区事务也不等于数据库事务)、以及「读一次就消失」的语义。

很多团队的做法是:Kafka 存一份,MySQL 再存一份,Redis 再缓存一份。然后某天发现三份数据对不上,谁也不知道哪份是真的,开始写「对账任务」,开始写「补偿任务」,开始加「数据修复脚本」。三个月后没人敢删任何一行代码,因为谁也不知道删了会触发什么。

这不是架构,这是技术债的复利模型。

更靠谱的做法是:Kafka 只做事件流(state your facts),状态放该放的地方(state your facts elsewhere)。消费端对账,作为兜底而不是主路径。幂等键 + 唯一索引做最后一道防线,别指望「我处理得够快就不会重复」。


六、收个尾

消息队列出问题的排查思路,其实跟所有分布式系统一样:

  • 先确认是不是错觉。lag 涨了不一定是真的,可能只是消费者重启。确认完再动手。
  • 先分清类型。尖峰还是阶梯,扩容还是退化。这个判断能砍掉 80% 的错误排查方向。
  • 先降级再根治。先把 lag 压下去(临时加消费者、临时提 max.poll.records),再回头修架构。
  • 最后写进文档。这四个坑我踩过,你不用踩第二遍。

Kafka 的复杂度不来自它多强,而来自它把「顺序」和「并行」这两个天然矛盾的东西,用一个看起来优雅的协议糊在了一起。你以为你只是在写一个 while 循环,实际上你在参与一场关于分区归属权的持续谈判。

谈不好,就半夜三点收到告警。

祝你的消费者组安分一点。

相关文章

分布式系统里没有「现在」:时钟、超时与租约,一次讲透
我们给支付接口加了幂等键,结果它照样扣了三次钱
Prometheus 悄悄吃掉了你 40G 内存:一次指标基数爆炸的现场拆解
你的 Go 服务正在偷偷漏 goroutine:context 取消、channel 关闭与那些没人告诉你的并发真相
你的定时任务跑了三遍,还把数据库打挂了:分布式调度里六个没人愿意告诉你的真相
为什么你的 Rust 团队,三个月后又写回了 Go?—— 所有权、生命周期与「编译期错误」的谎言

发布评论