18-MQ消息积压问题排查:消费卡顿、堆积、消费速度优化
作者:黒漂技术佬 系列:RocketMQ 核心原理与无人售货柜项目实战
一、什么是消息积压?
消息积压(Message Backlog),简单说就是生产速度大于消费速度,消息在 Broker 的磁盘上越堆越多,像水管一头猛灌水、另一头却拧小了阀门一样。
RocketMQ Dashboard 上有一个关键指标叫 Diff Total,它表示"已生产但未被消费的消息数量"。正常情况下 Diff 应该在几百甚至几十以内,但如果数字开始飙升——1000、5000、10000——你的系统正在发出 SOS 信号。
说个真实场景:某天凌晨我们对 500 台售货柜批量下发固件升级指令,每条指令都通过 MQ 投递。原本正常的消费速率是每秒 200 条,但这批升级指令一次性灌了 5000 条进来,消费者来不及处理,Diff 直接从 10 冲到了 4800+。运维群瞬间炸锅。
二、积压的危害
危害一:消息延迟增大。 消息在队列里排队,前面的不消费完,后面的就轮不到。原本秒级的出货指令变成了分钟级,用户扫码付款后等了 2 分钟也不见出货,差评随之而来。
危害二:磁盘空间告急。 RocketMQ 的消息默认存储在 store/commitlog 目录下。积压消息就是堆积在磁盘上的文件,如果不设过期清理,磁盘很快被打满。Broker 磁盘满了会直接拒绝写入,整个消息链路瘫痪。
危害三:Broker 内存压力。 虽然消息主要存在磁盘上,但消费队列索引(ConsumeQueue)是加载到内存的。积压量太大的时候,索引膨胀导致 OOM。
三、积压原因排查思路
排查积压问题,记住一个口诀:"谁慢了、谁不够、谁坏了"。
3.1 谁慢了——消费者处理慢
最常见的原因。消费者拉取消息后,处理逻辑太耗时。典型场景:
- 消费时查了一次慢 SQL(无索引全表扫描)
- 调用了一个外部 HTTP 接口,对方 3 秒才返回
- 消费逻辑里做了大对象的序列化/反序列化
排查方法:看消费者日志,打印每条消息的处理耗时。
@Override
public void onMessage(OrderMessageDTO msg) {
long start = System.currentTimeMillis();
// 业务逻辑
processOrder(msg);
long cost = System.currentTimeMillis() - start;
if (cost > 500) {
log.warn("消息处理耗时过长:{}ms, orderId={}", cost, msg.getOrderId());
}
}
3.2 谁不够——消费者实例不足
RocketMQ 的一个消费者组可以起多个实例,但一个 MessageQueue 同一时刻只能被一个消费者实例消费。如果你有 8 个队列,但只起了 4 个消费者实例,那 4 个队列就闲置了——浪费了一半的消费能力。
消费者实例数 > 队列数 = 浪费(多出来的实例分不到队列,空转)。消费者实例数 < 队列数 = 能力没吃满。最佳实践:消费者实例数 = 队列数。
3.3 谁坏了——消费失败重试恶性循环
消费一条消息失败 → RocketMQ 自动重试 → 又失败 → 又重试……重试消息和正常消息抢占消费资源,导致整体消费速率断崖式下跌。
重试消息的特征是 RECONSUME_LATER,可以在 Dashboard 的"重试队列"Tab 中看到。如果重试队列堆积严重,说明有某类消息总是消费不成功,需要重点排查类型。
四、排查工具和方法
4.1 RocketMQ Dashboard
这是最直观的工具。打开 Dashboard,看三个地方:
- Consumer → 消费组 → Diff Total:积压量,超过 5000 需要关注
- Consumer → 消费组 → Consume TPS:当前消费速率,对比历史常值判断是否下降
- Topic → 消息轨迹:抽查几条积压消息,看生产时间和消费时间差
4.2 服务器命令行排查
# 查看消费者组的消费进度
mqadmin consumerProgress -n namesrv:9876 -g inventory-lock-group
# 输出示例:
# 队列ID 偏移量 消费者偏移量 差值
# 0 152341 152100 241
# 1 151089 150880 209
# ...
# 如果"差值"持续增长,说明消费跟不上生产
4.3 日志分析
# 统计消费者每分钟处理的消息数
grep "orderId" consumer.log | awk '{print $1}' | cut -d: -f1-2 \
| sort | uniq -c | tail -20
# 统计耗时超过1秒的消息占比
grep "cost" consumer.log | awk -F'cost=' '{print $2}' \
| awk -F'[^0-9]' '{if($1>1000)print}' | wc -l
五、消费速度优化方案
方案一:增加消费者实例
最直接的扩容手段。前提是当前实例数 < 队列数。队列数在创建 Topic 时就固定了(比如 8 个),后续可以通过 mqadmin updateTopic 扩容。
方案二:增大消费线程数
// 在 application.yml 中配置
rocketmq:
consumer:
consumeThreadMin: 20 # 最小消费线程,默认20
consumeThreadMax: 64 # 最大消费线程,默认64
内部原理:RocketMQ 客户端用一个线程池来消费消息,线程数不足时消息在客户端积压。增大 consumeThreadMax 可以提升并行处理能力。
方案三:批量消费
@RocketMQMessageListener(
topic = "OrderTopic",
consumerGroup = "order-group",
consumeMessageBatchMaxSize = 32 // 一次拉取32条,批量处理
)
public class BatchOrderConsumer
implements RocketMQListener<List<OrderMessageDTO>> {
@Override
public void onMessage(List<OrderMessageDTO> messages) {
// 批量处理:一次DB批量插入代替32次单条插入
orderService.batchProcess(messages);
}
}
批量消费的优势在于减少了网络开销和拉取次数。原本每条消息一次 RPC 拉取,现在 32 条一次拉取,效率提升不是 32 倍也至少是 10 倍以上。
方案四:异步处理(消费线程只接收,业务线程池处理)
@RocketMQMessageListener(
topic = "OrderTopic",
consumerGroup = "order-group",
consumeThreadMax = 5 // 消费线程少,只负责接收
)
public class AsyncOrderConsumer implements RocketMQListener<OrderMessageDTO> {
// 业务线程池,核心50线程,最大200
private ExecutorService bizExecutor = new ThreadPoolExecutor(
50, 200, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
@Override
public void onMessage(OrderMessageDTO msg) {
// 消费线程立刻返回,不做重活
bizExecutor.submit(() -> {
doBusinessLogic(msg);
});
}
}
这里的关键是 CallerRunsPolicy:如果业务线程池也满了,就让消费线程自己执行,形成天然的背压——消费线程被阻塞,自动降低拉取频率,避免内存被打炸。
方案五:优化消费逻辑
- 慢 SQL 加索引:
EXPLAIN分析执行计划,消灭全表扫描 - 冗余字段:Order 表加
device_status字段,避免消费时 JOIN 设备表 - 加缓存:设备信息、商品信息这类"变不频繁"的数据用 Caffeine 本地缓存
- 去掉不必要的外部 API 调用:出货指令状态改为"先发指令、再异步确认"
六、紧急积压处理方案
当 Diff 已经破万、告警疯狂刷屏时,需要紧急止血:
方案一:临时扩容队列数 + 消费者
# 先把Topic的队列数从8扩到32
mqadmin updateTopic -n namesrv:9876 -t OrderTopic -r 32 -w 32
# 再把消费者从8个实例扩到32个(K8s扩容或启动更多节点)
扩容后新产生的消息分发到更多队列,被更多消费者并行处理。但注意:已积压的消息还在老队列里,需要配合方案二。
方案二:消息转发(最有效的紧急手段)
# 1. 创建一个新Topic,队列数更多(比如64)
mqadmin updateTopic -n namesrv:9876 -t OrderTopic-Fast -r 64 -w 64
# 2. 把原Topic的积压消息转发到新Topic
# (写一个临时程序,consume原Topic的消息,produce到新Topic)
新 Topic 有 64 个队列,配合 64 个消费者实例,消费速度可以提升 4-8 倍。等积压消化后,再把消费者切回原 Topic,关闭临时程序。
方案三:降级非核心消费
紧急情况下,暂时关闭非核心消费组(比如归档服务、统计服务),把全部消费资源集中到核心链路(订单、库存、出货)。
七、售货柜高峰期积压实战
某次我们的售货柜在早高峰时段(7:30-8:30)出现积压,排查后发现:
- 消费线程
consumeThreadMax=20,但 8 个队列×20 线程的处理能力只有 500 TPS - 而生产端瞬时峰值达到了 800 TPS(多台售货柜同时上报心跳+出货状态)
- 大部分消费者的时间浪费在"查商品详情→查设备信息→更新"的串行操作上
优化措施:
- 商品信息、设备信息预加载到 Caffeine 缓存,消费时直接 get
- 出货状态更新改为批量写入(每 100ms 合并一批)
consumeThreadMax调至 40
优化后,消费 TPS 从 500 提升到 1800,Diff 从峰值 12000 降到了稳定 200 以内。
消息积压是任何 MQ 系统都会遇到的问题,关键不在于避免(大流量来临时不可避免),而在于监控到位、排查有路、优化有效、紧急有预案。有了这四板斧,积压就不再是让人失眠的事故。