MQ消息积压问题排查:消费卡顿、堆积、消费速度优化

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 秒才返回
  • 消费逻辑里做了大对象的序列化/反序列化

排查方法:看消费者日志,打印每条消息的处理耗时。

java 复制代码
@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 服务器命令行排查

bash 复制代码
# 查看消费者组的消费进度
mqadmin consumerProgress -n namesrv:9876 -g inventory-lock-group

# 输出示例:
# 队列ID    偏移量        消费者偏移量   差值
# 0         152341        152100        241
# 1         151089        150880        209
# ...
# 如果"差值"持续增长,说明消费跟不上生产

4.3 日志分析

bash 复制代码
# 统计消费者每分钟处理的消息数
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 扩容。

方案二:增大消费线程数

java 复制代码
// 在 application.yml 中配置
rocketmq:
  consumer:
    consumeThreadMin: 20    # 最小消费线程,默认20
    consumeThreadMax: 64    # 最大消费线程,默认64

内部原理:RocketMQ 客户端用一个线程池来消费消息,线程数不足时消息在客户端积压。增大 consumeThreadMax 可以提升并行处理能力。

方案三:批量消费

java 复制代码
@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 倍以上。

方案四:异步处理(消费线程只接收,业务线程池处理)

java 复制代码
@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 已经破万、告警疯狂刷屏时,需要紧急止血:

方案一:临时扩容队列数 + 消费者

bash 复制代码
# 先把Topic的队列数从8扩到32
mqadmin updateTopic -n namesrv:9876 -t OrderTopic -r 32 -w 32

# 再把消费者从8个实例扩到32个(K8s扩容或启动更多节点)

扩容后新产生的消息分发到更多队列,被更多消费者并行处理。但注意:已积压的消息还在老队列里,需要配合方案二。

方案二:消息转发(最有效的紧急手段)

bash 复制代码
# 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(多台售货柜同时上报心跳+出货状态)
  • 大部分消费者的时间浪费在"查商品详情→查设备信息→更新"的串行操作上

优化措施:

  1. 商品信息、设备信息预加载到 Caffeine 缓存,消费时直接 get
  2. 出货状态更新改为批量写入(每 100ms 合并一批)
  3. consumeThreadMax 调至 40

优化后,消费 TPS 从 500 提升到 1800,Diff 从峰值 12000 降到了稳定 200 以内。


消息积压是任何 MQ 系统都会遇到的问题,关键不在于避免(大流量来临时不可避免),而在于监控到位、排查有路、优化有效、紧急有预案。有了这四板斧,积压就不再是让人失眠的事故。

相关推荐
阿拉斯攀登1 小时前
无人售货机库存异步更新方案:解决高并发下单库存卡顿问题
后端
阿拉斯攀登1 小时前
无人售货机MQ消息丢失、重复消费、超时异常全套兜底方案
后端
做系统的大强1 小时前
我用中文从零写了一个操作系统(下篇):从45个BUG到165个——假持久化、USB地狱与OS自举
后端
程序员老赵1 小时前
Docker 部署 Rocky Linux:轻松搭建 RHEL 兼容企业级基础镜像平台
linux·后端·docker
用户6152612132101 小时前
Java主流框架与源码:Spring Framework
后端
yume_sibai2 小时前
03-Rust 函数式编程特性(闭包 + Iterator + Option/Result + 链式调用)
开发语言·后端·rust
吃饱了得干活2 小时前
Redis 不是死脑筋,它是一套“会进化”的存储系统
redis·后端
伩仁2 小时前
别再 HTTP 200 一把梭了:用 RFC 9457 Problem Details 给 FastAPI 错误响应"立规矩"
后端
MeetTanG2 小时前
Go 实战锦囊|errgroup:优雅地管理并发任务组
后端·go