半夜三点,手机震了。手机不在我兜里,在 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
排查顺序我一般这么走:
- 先看 lag 曲线是「尖峰」还是「阶梯」。尖峰 = 一次性事件(部署、扩容、GC 停顿);阶梯 = 持续性跟不上(处理逻辑太慢或分区不均)。
- 再看重平衡频率。如果每分钟都在 rebalance,去查
max.poll.interval.ms和实际 poll 间隔的差值。 - 然后查分区倾斜。用
kafka-consumer-groups --describe看每个 consumer 拿到几个分区、lag 差多少。 - 最后看消费者日志里的异常类型。
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 循环,实际上你在参与一场关于分区归属权的持续谈判。
谈不好,就半夜三点收到告警。
祝你的消费者组安分一点。