Kafka 消费积压 180 万条,加 Pod 也没用?从 lag 斜率定位到热点分区与 rebalance 风暴

举报
yd_243555368 发表于 2026/11/05 01:02:15 2026/11/05
【摘要】 消费 lag 冲到 180 万但消费者 CPU 只有 15%,加实例也不见好转。本文用 kafka-consumer-groups 的 offset 差值与 lag 斜率先排除假积压,再用 records-lag-max 揪出单分区倾斜、用日志统计 rebalance 次数,给出改复合 key 打散热点、扩分区、max.poll.interval.ms 与批量处理对齐、group.instance

问题现象

凌晨两点被叫起来,监控上某个 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 缓慢下降但永远追不上。

第二步:三条主线分别定位

消费积压只可能有三种原因,按成本从低到高排:

  1. 生产突增:看 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。本例写入速率正常,排除。

  1. 消费线程被卡:看 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 也不扩容」的真相。

  1. 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 不拆,加多少实例都是白加。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。