2806 字
约 9 分钟
16
S17-X10 · 消息积压:从现象到根因到处理

告警响的时候,你手上只有三个事实:队列里有堆积、下游在报错、业务方在问什么时候恢复。真正要回答的是三个问题——积压是持续的还是瞬时的、是生产变快还是消费变慢、以及先动手还是先定位。顺序错了,扩容会把下游数据库一起带走。


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. 自测

  1. 总 lag 很大但每个分区的 LAG 都很小,可能是什么情况?
  2. 生产 TPS 平稳、消费 TPS 掉到接近零,扩容消费者为什么可能无效?
  3. max.poll.interval.ms 设小了,会以什么方式制造"越消费越多"的假象?
  4. 消费者数已经等于分区数,还有哪些手段能提升消费能力?
  5. 加分区为什么会破坏按 key 的顺序性?
  6. 追平阶段最容易被忽略的风险是什么,怎么防?
S17-X10 · 消息积压:从现象到根因到处理
https://www.clxhxhhr.top/posts/5825/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。