告警响的时候,你手上只有三个事实:队列里有堆积、下游在报错、业务方在问什么时候恢复。真正要回答的是三个问题——积压是持续的还是瞬时的、是生产变快还是消费变慢、以及先动手还是先定位。顺序错了,扩容会把下游数据库一起带走。
1. 积压的两种形态
| 形态 | 曲线特征 | 判别方法 | 处理方向 |
|---|---|---|---|
| 持续积压 | 消费速率长期低于生产速率,lag 单调上升 | 消费 TPS 稳定但小于生产 TPS | 提升消费能力(优化、扩容、加分区) |
| 瞬时堆积 | 生产突增或消费短暂停摆,之后回落 | lag 尖峰后自行下降 | 先观察,避免过度反应 |
| 消费卡死 | lag 直线上升,消费 TPS 归零 | 消费位点不动,消费者有心跳 | 查消费者线程、下游阻塞、rebalance |
# Kafka:组级位点与 lag
$ kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
--describe --group order-consumer
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID
order-consumer order 0 8812342 8812342 0 consumer-1
order-consumer order 1 8790100 9120455 330355 consumer-2
LAG 是分区级的:只有一两个分区在涨,问题多半是分区倾斜或该分区的 consumer 卡住,不是整体能力不足。
2. 先分清"生产变快"还是"消费变慢"
| 观察量 | 生产变快 | 消费变慢 |
|---|---|---|
| 生产 TPS | 明显高于历史基线 | 平稳 |
| 消费 TPS | 平稳或同步上升 | 下降或归零 |
| 下游依赖延迟 | 正常 | 上升(DB、HTTP、缓存) |
| 消费者 CPU | 随量上升 | 可能不高(在等 IO) |
| lag 起点 | 与流量峰值同时出现 | 常伴随发布、配置变更、下游故障 |
# Kafka:两分钟的位点差,换算成生产/消费速率
$ kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
--describe --group order-consumer | awk 'NR>1{s+=$6; e+=$5} END{print "end",e,"committed",s}'
# 隔 60 秒再跑一次,两次差值就是这段时间的净增量
# RocketMQ:组内堆积量与消费位点
$ mqadmin consumerProgress -n 10.0.0.11:9876 -g order-consumer
#Broker Name #QID #Broker Offset #Consumer Offset #Diff Total
broker-a 0 9120455 8790100 330355
对比两条时间序列就够了:生产涨而消费不变是容量问题,生产不变而消费掉了是故障问题。前者扩容有效,后者扩容只会让更多消费者卡在同一处。
3. 消费变慢的六类根因
| 根因 | 证据 | 验证手段 |
|---|---|---|
| 下游依赖劣化 | 单条处理耗时上升,DB 慢查询增多 | 看依赖的 P99、慢日志;对比发布前后 |
| 单条处理变慢 | 消费 TPS 下降但 CPU 不高 | 抽样打点单条链路耗时,拆到每个外部调用 |
| 消费线程被阻塞 | 线程栈停在 socket read / 锁 / 连接池 | jstack 连抓三次,看是否停在同一处 |
| 重试风暴 | 同一消息反复重投,位点反复回退 | 看重试次数指标、日志里的重复 msgId |
| 分区数与消费者数不匹配 | 部分消费者空闲,部分吃满 | --describe 里每个 consumer 的分区数 |
| 消费端异常反复重投 | 异常日志密集、DLQ 增长 | 统计异常类型分布,确认是否可跳过 |
线程阻塞最像"网络问题":日志全是超时,实际是连接池打满或下游挂住连接。看线程栈等的是不是 SocketInputStream、连接数是否到上限,就能区分。
# Spring Kafka:把轮询与处理时间的上限调对,避免 rebalance 风暴
spring:
kafka:
consumer:
max-poll-records: 200 # 一次拉多少条
max-poll-interval-ms: 300000 # 处理超时会触发 rebalance
session-timeout-ms: 45000
heartbeat-interval-ms: 3000
listener:
type: batch # 批量消费,摊薄单条开销
ack-mode: manual_immediate
max.poll.interval.ms 小于实际处理耗时,消费者被踢出组、触发 rebalance、位点回退,消息再被消费一遍——积压看起来像"越消费越多"。
4. 观测入口:Kafka 与 RocketMQ 各看什么
| 指标 | Kafka | RocketMQ |
|---|---|---|
| 位点差 | LAG(LOG-END-OFFSET - CURRENT-OFFSET) |
Diff Total(Broker Offset - Consumer Offset) |
| 消费速率 | 位点差分 / 时间 | 位点差分 / 时间 |
| 分区级倾斜 | 每行 LAG 对比 |
按 #QID 对比 |
| 消费者成员 | CONSUMER-ID、HOST、CLIENT-ID |
consumerConnection 看连接与订阅 |
| 堆积告警 | Kafka Exporter 的 kafka_consumergroup_lag |
控制台堆积量 / 自建采集 |
$ kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 --list
$ kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
--group order-consumer --members --verbose
$ mqadmin consumerConnection -n 10.0.0.11:9876 -g order-consumer
告警落在持续时长上:瞬时一万条可能只是批量任务,持续一小时才是事故。
5. 紧急处理的顺序
1. 止血:限流生产端 / 降级非关键消费 / 扩消费者(受分区数约束)
2. 定位:消费 TPS、单条耗时、线程栈、下游 P99、重试次数
3. 补偿:追平堆积、修数据、把跳过的消息补回来
| 止血动作 | 生效速度 | 副作用 |
|---|---|---|
| 生产端限流 | 立即 | 业务侧请求被拒或排队 |
| 降级非关键消费(先丢掉可重建的消息) | 立即 | 数据不完整,需要事后补 |
| 扩容消费者(未超分区数) | 分钟级 | 更多并发压向下游 |
| 提高批量大小 | 分钟级 | 单批失败重试成本变大 |
| 临时跳过毒消息进 DLQ | 立即 | 需要人工处理 DLQ |
顺序不能反:先扩容再定位等于给下游盲加压;先定位再止血,可能积压已超出保留期被删。
6. 扩容的上限与加分区的代价
消费者实例数不能超过分区数,这是硬约束。第 13 个消费者订阅 12 个分区的 topic,会有一个实例从头到尾分不到分区,白占资源。
# 加分区:只能增不能减,且会改变 key 到分区的映射
$ kafka-topics.sh --bootstrap-server kafka-1:9092 \
--alter --topic order --partitions 24
$ kafka-topics.sh --bootstrap-server kafka-1:9092 --describe --topic order
| 动作 | 收益 | 代价 |
|---|---|---|
| 扩消费者(分区足够) | 线性提升消费并行度 | 下游压力同步上升 |
| 加分区 | 突破并行度上限 | 同 key 的顺序性被破坏,历史消息分布重排 |
| 消费内多线程 | 提升单实例吞吐 | 分区内顺序性丢失 |
按 key 保序的场景(订单状态、账户流水)加分区要谨慎:扩容后同一订单号落到新分区,与历史消息分开,消费端看到的顺序就不再是生产顺序。
7. 追平与重试风暴
追平的目标是短期让消费速率高于生产速率,手段有四种:
| 策略 | 前提 | 代价 |
|---|---|---|
| 提高批量与并发 | 单条处理不重、下游扛得住 | 下游被打爆的风险 |
| 临时跳过非关键逻辑 | 有可重建的数据 | 需要事后补算 |
| 先落库再离线加工 | 有临时表或对象存储 | 多一套补偿链路 |
| 只追关键 topic,其余延后 | 业务可分级 | 需要明确优先级 |
重试风暴的根因是没有上限的重投,三条措施缺一不可:退避 → 次数上限 → 死信队列。
# Spring Kafka:有限重试 + 死信主题
spring:
kafka:
listener:
type: single
producer:
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
# 代码侧
# DefaultErrorHandler + FixedBackOff(1000L, 3) + DeadLetterPublishingRecoverer
// 有限重试 + 退避 + DLQ,重试耗尽后不再回退位点
var recoverer = new DeadLetterPublishingRecoverer(template,
(rec, ex) -> new TopicPartition("order.DLT", rec.partition()));
var errorHandler = new DefaultErrorHandler(recoverer,
new ExponentialBackOffWithMaxRetries(4));
errorHandler.addNotRetryableExceptions(IllegalArgumentException.class);
RocketMQ 的重试是内建的:失败进 %RETRY%<group>,达到默认 16 次后进 %DLQ%<group>。DLQ 必须有人看,否则只是把堆积换了地方。
8. 排错与常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 加了消费者,lag 不降 | 消费者数已等于分区数 | 先加分区(注意顺序性)或优化单条耗时 |
| 消费 TPS 忽高忽低并伴随 rebalance | max.poll.interval.ms 小于处理耗时 |
调大该值或减小 max-poll-records |
| 位点反复回退 | 异常重投、手动提交时机错误 | 明确 ack 时机,加重试上限与 DLQ |
| 消费线程池满、请求排队 | 下游慢导致线程不释放 | 加超时、熔断、隔离线程池 |
| 追平后数据库 CPU 打满 | 批量写没有限速 | 追平时按下游容量设速率上限 |
| RocketMQ 消费进度不更新 | 消费者未注册、订阅关系不一致 | consumerConnection 核对订阅 |
常见坑:
| 坑 | 表现 | 正确做法 |
|---|---|---|
| 盲目加消费者 | 资源翻倍、lag 不变 | 先看分区数与每消费者分到的分区 |
| 把线程池满当成网络问题 | 日志全是超时 | 看线程栈与连接池指标 |
| 追平时打爆下游 | 数据库连接耗尽、慢查询暴涨 | 限速 + 分批 + 观察下游 P99 |
| 用 lag 绝对值告警 | 批量任务误报 | 用持续时长 + 速率差 |
| 忽略毒消息 | 同一分区永久卡住 | 单条失败上限后进 DLQ 并继续 |
| 扩容分区后假设顺序不变 | 数据错乱 | 保序场景不改分区数 |
9. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 偶发瞬时堆积 | 观察 + 提高缓冲上限 | 扩容的收益抵不上运维成本 |
| 消费逻辑本身很重(大文件、外部 API) | 拆分异步、改为任务化 | 加消费者只是把慢摊开 |
| 下游是单一数据库 | 先优化 SQL/索引或加读写分离 | 消费端扩容会直接压垮它 |
| 需要严格顺序 | 单分区单消费者,优化单条耗时 | 并行度与顺序性不可兼得 |
| 消息可重建 | 直接丢弃后重放 | 追平不如重放干净 |
10. 自测
- 总 lag 很大但每个分区的
LAG都很小,可能是什么情况? - 生产 TPS 平稳、消费 TPS 掉到接近零,扩容消费者为什么可能无效?
max.poll.interval.ms设小了,会以什么方式制造"越消费越多"的假象?- 消费者数已经等于分区数,还有哪些手段能提升消费能力?
- 加分区为什么会破坏按 key 的顺序性?
- 追平阶段最容易被忽略的风险是什么,怎么防?