Kafka 消费积压 180 万条,加 Pod 也没用?从 lag 斜率定位到热点分区与 rebalance 风暴
问题现象
凌晨两点被叫起来,监控上某个 Kafka 消费组的 lag 从几万一路冲到 180 万,消费端 Pod 的 CPU 却只有 15%,日志里没有任何报错。第一反应是「是不是下游慢了」,但把所有下游耗时打点翻了一遍,p99 稳定在 40ms,说明不是单条消息变慢,而是消费者根本没在消费。
第一步:先判断是真积压还是假积压
不要只看 Grafana 上那条曲线,先用官方工具把每个分区的 offset 差值打出来:
kafka-consumer-groups.sh --bootstrap-server kafka-01:9092 \
--describe --group order-consume
输出里三个关键列:CURRENT-OFFSET(已提交位点)、LOG-END-OFFSET(分区最新位点)、LAG(两者之差)。两个坑要避开:
LAG是分区级差值,总积压必须自己累加,别拿平均值看;- 如果某个分区
CURRENT-OFFSET是-,说明该分区没有分配任何消费者,这时 lag 算不出来,实际是「无人消费」,不是「消费慢」。
加 --state 看组状态,加 --members --verbose 看谁拿了哪些分区:
kafka-consumer-groups.sh --bootstrap-server kafka-01:9092 \
--describe --group order-consume --state
kafka-consumer-groups.sh --bootstrap-server kafka-01:9092 \
--describe --group order-consume --members --verbose
本次输出是 Stable,分区分配也齐全,12 个分区对应 12 个 consumer。那问题就落在「位点不动」上——再看一眼 lag 有没有在动:
for i in 1 2 3; do
kafka-consumer-groups.sh --bootstrap-server kafka-01:9092 \
--describe --group order-consume | awk 'NR>1{s+=$6}END{print "total_lag="s}'
sleep 30
done
# total_lag=1812330
# total_lag=1808652
# total_lag=1805140
30 秒只消化了 3600 条,折合 120 msg/s,而生产端是 3000 msg/s。这就是「消费能力被打到地板」的典型曲线:lag 缓慢下降但永远追不上。
第二步:三条主线分别定位
消费积压只可能有三种原因,按成本从低到高排:
- 生产突增:看 topic 的入流量是否翻倍。用
kafka-run-class.sh kafka.tools.GetOffsetShell隔分钟采样,算出真实写入速率:
kafka-run-class.sh kafka.tools.GetOffsetShell \
--bootstrap-server kafka-01:9092 --topic order-event --time -1 \
| awk -F: '{s+=$3}END{print s}'
间隔 60 秒跑两次相减,得到的就是 msg/s。本例写入速率正常,排除。
- 消费线程被卡:看 broker 端的
messages-in-per-sec与客户端 JMX 的records-consumed-rate。客户端侧只要加两个参数就能暴露:
# 关键指标(JMX / Prometheus 同名)
kafka_consumer:type=consumer-fetch-manager-metrics,client-id=*
records-consumed-rate # 实际吞吐,本例 120/s
records-lag-max # 单分区最大积压,本例 15 万,远超均值
fetch-latency-avg # 拉取耗时,本例 8ms,正常
这里出现了一个强信号:records-lag-max 和平均值差了几十倍,说明积压集中在少数分区——典型的「key 倾斜 + 单分区串行消费」。查一下热点 key:
kafka-console-consumer.sh --bootstrap-server kafka-01:9092 \
--topic order-event --property print.key=true --max-messages 20000 \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -5
# 11822 tenant_8842
# 2031 tenant_1027
# 1180 tenant_3390
一个租户占了 59% 的消息量,哈希后全落在一个分区上,那个分区再怎么加消费者也只能有一个实例处理——这就是「加 Pod 也不扩容」的真相。
- Rebalance 风暴:如果日志里出现
Rebalance in progress或Attempt to heartbeat failed since group is rebalancing,且频繁刷屏,那消费时间全被 rebalance 吃掉。排查三步:
# 1) 组内 rebalance 次数
kafka-consumer-groups.sh --bootstrap-server kafka-01:9092 \
--describe --group order-consume --members --verbose | grep -c rebalance
# 2) 客户端日志里统计每分钟次数
kubectl logs -l app=order-consume --since=1h \
| grep -c 'Revoke previously assigned partitions'
# 3) 检查处理单批耗时(超 max.poll.interval.ms 就会被踢出组)
# 日志里搜 MaxPollIntervalMs 或 "member exceeded max poll interval"
本例第二种原因占主导,同时叠加了轻微的 rebalance(max.poll.records=500 而单批处理耗时 p99 到 52 秒,超过 max.poll.interval.ms=30000,消费者被反复踢出组,每隔几分钟重来一次)。
第三步:按顺序止血
1)先把热点分区拆开(治本)。租户维度的消息不该只按 tenant 做 key,改成 tenant + 业务子键 复合 key,让同一租户的消息分散到多分区:
// 改造前后对比
// 之前:所有消息按租户聚合,热点租户必然打爆单分区
producer.send(new ProducerRecord<>("order-event", tenantId, body));
// 之后:保持同租户内有序的前提下,用子键打散分区
String key = tenantId + ":" + (orderId.hashCode() & 7);
producer.send(new ProducerRecord<>("order-event", key, body));
如果业务要求全租户严格有序,那就只能扩分区(分区数从 12 扩到 48),再让消费者实例数跟上——注意:分区只能加不能减,扩容后 key 到分区的映射会变,历史消息的有序性会断一次,要选低峰窗口。
2)把 max.poll.interval.ms 和单批处理量对齐。原则是 max.poll.records × 单条耗时 p99 < max.poll.interval.ms × 0.6:
# 之前:500 × 100ms = 50s > 30s,必被踢
spring.kafka.consumer.max-poll-records=100
spring.kafka.consumer.max-poll-interval-ms=180000
spring.kafka.consumer.session-timeout-ms=15000
spring.kafka.consumer.heartbeat-interval-ms=3000
# 更关键:把批量处理拆成异步,poll 线程只负责拉
spring.kafka.listener.type=batch
spring.kafka.listener.ack-mode=MANUAL_IMMEDIATE
3)用静态成员消掉「发版即全量 rebalance」。滚动发布时每个 Pod 上下线都会触发一次全组 rebalance,12 个实例发一次版就是 12 次:
# application.yml
spring:
kafka:
consumer:
properties:
group.instance.id: ${HOSTNAME} # 静态成员,重启不触发 rebalance
partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor
# 增量协作式再均衡,只迁移必要分区,不全停
4)临时兜底:把不可恢复的消息扔进死信。有些消息本身会炸消费线程(反序列化失败、下游返回 4xx),不隔离掉就会一直卡在同一 offset 上重试:
# 开启无限重试但要能看到失败次数
spring.kafka.consumer.properties.max.poll.records=100
# 自定义 SeekToCurrentErrorHandler / DefaultErrorHandler,超过 3 次投递到 order-event.DLT
kafka-topics.sh --bootstrap-server kafka-01:9092 \
--create --topic order-event.DLT --partitions 12 --replication-factor 3
第四步:验证是否真的追上
不要等告警报消失,自己算收敛时间。消化速度(msg/s)减去写入速度就是净收敛速率:
# 每 30 秒采样一次,观察 lag 斜率
while true; do
L=$(kafka-consumer-groups.sh --bootstrap-server kafka-01:9092 \
--describe --group order-consume | awk 'NR>1{s+=$6}END{print s}')
echo "$(date +%H:%M:%S) lag=$L"
sleep 30
done
改造后指标:records-consumed-rate 从 120/s 升到 3100/s,records-lag-max 从 15 万降到 3000 以内,180 万积压约 11 分钟清空(1800000 ÷ (3100−3000) 的净速率在前期更快,实际曲线是先快后稳)。
第五步:让下次能提前 20 分钟发现
只监控平均 lag 是没用的,平均值会被健康分区稀释。三条告警必须同时上:
# 1) 单分区最大积压(最快发现倾斜)
kafka_consumer_records_lag_max{group="order-consume"} > 50000
# 2) 积压持续增长 10 分钟(区分「瞬时抖动」和「追不上」)
deriv(kafka_consumer_records_lag_max{group="order-consume"}[10m]) > 0
# 3) rebalance 次数突增(风暴预警)
increase(kafka_consumer_group_rebalance_total{group="order-consume"}[15m]) > 3
另外给消费组加上 records-consumed-rate 的环比告警:掉 50% 立即报,这比 lag 增长更早暴露问题——lag 是结果,消费速率才是原因。
小结
Kafka 积压排查的顺序是固定的:先看 offset 差值确认是真积压 → 看 lag 变化斜率确认是「追不上」还是「没人消费」→ 分生产突增 / 消费卡住 / rebalance 三线并行排 → 用 records-lag-max 定位倾斜分区 → 治本改 key 或扩分区,治标调 poll 参数和静态成员。这次踩的坑是「以为加 Pod 就能扩容」,实际单分区串行决定了天花板,热点 key 不拆,加多少实例都是白加。
- 点赞
- 收藏
- 关注作者
评论(0)