记一次生产RocketMQ消息积压消费慢的排查与解决

一、故障发现

1.1 问题现象

五一假期期间,生产环境出现订单消息消费严重积压的情况。通过RocketMQ监控仪表板发现,qnh-order-create-topic主题的消息消费速度异常缓慢,最新消息的业务订单创建时间order_create_time2026-05-04 23:16:00,而实际MQ消费时间create_time2026-05-06 10:48:37消息积压时间长达两天

1.2 监控数据

从RocketMQ仪表板可以看到关键数据:

  • 消息积压量 :消费者组qnh-order-create-consumer-group的差值(积压量)达到数万条
  • 消费进度:4个队列的消费者位点明显滞后于代理者位点
  • qnh-order-create-topic :订单创建Topic 在本地消息表中查询到有积压二十多万条消息消费状态为待消费状态
  • 消费速度:SLS日志搜索区间30秒内仅消费63条消息,远低于业务需求

MQ所有主题监控:

qnh-order-create-topic主题:

qnh-order-create-topic 主题队列配置:

qnh-order-create-topic 队列状态监控:

qnh-order-create-topic 消费进度监控:

二、环境架构分析

2.1 部署架构

生产环境部署情况:

  • 服务器:两台阿里云8核16G机器(01和02)
  • 应用部署 :两台机器均部署了mtx业务系统,通过nginx负载均衡
  • RocketMQ:01机器部署了RocketMQ 4.9的namesrv和broker(单节点)

2.2 环境信息

  • RocketMQ版本:4.9
  • 监控工具:rocketmq-dashboard

2.3 业务流程

订单消息的完整流转路径:

复制代码
第三方订单平台(美团/京东/饿了么)→ 牵牛花接口 → mtx业务应用接收消息接口 → 线存入本地消息sync_notify_msg表 → 发送RocketMQ → 消费者 → 订单order_info表

核心业务逻辑:消费者从MQ拉取消息后,消息内容只含有订单号和一些基础数据,订单详细信息,需要调用第三方牵牛花接口获取订单详情,然后插入或更新到数据库。

三、根因分析

3.1 架构层面瓶颈

1. Topic队列数不足

监控显示qnh-order-create-topic的读写队列数为4,RocketMQ中同一消费组对Topic的有效并行度上限约等于队列数。2台机器时每台约分到2个队列,严重限制了消费并行度。

2. 消费线程配置过低

MQ集群配置文件rocketmq-cluster.yml中:

  • consume-thread-min: 1
  • consume-thread-max: 3

整体吞吐与节假日高峰完全不匹配。

大促活动业务方也没通知技术部门,合理流程是大促前需要通知技术部门进行准备的。

3.2 应用层面瓶颈

1. 同步HTTP调用耗时

每条消息处理都需要同步调用美团order/listrefundList接口获取订单详情,这是业务必需操作,但网络IO耗时较长,成为主要瓶颈。

2. N+1查询问题

handleInsertScenario方法中,对每个订单都单独调用getWarehouseIdAndName(shop.getId())查询仓库信息,存在严重的N+1查询问题。

3. 过度日志输出

高频INFO日志输出全量body、完整requestBody、加密前参数字符串等,增加了IO开销。

3.3 框架层面问题

RocketMQAutoConfiguration.createConsumer中每条消息循环内都调用findMessageListener,存在不必要的开销。

四、解决方案

4.1 运维层面优化(P0)

1. 扩大Topic队列数重点

qnh-order-create-topic的读写队列从4调整到16,提高集群并行度。

2. 调整消费线程数

rocketmq-cluster.yml中修改配置:

yaml 复制代码
qngOrderSync:
  consume-thread-min: 8
  consume-thread-max: 16

3. 资源监控

关注01机器(Broker+业务+Job同机)的堆内存、GC、磁盘IO,必要时错峰/限流Job任务。

4.2 代码层面优化(P1)

1. 解决N+1查询

handleInsertScenario中:

  • 收集所有涉及的shop.getId()
  • 批量调用iCenterWarehouseService获取CenterWarehouseRespDTO
  • 构建Map<Long, CenterWarehouseRespDTO>,循环内O(1)赋值

2. 日志降级

  • 不再默认打印全量body、完整requestBody
  • 改为每条处理一条摘要,仅包含msgIdorderId耗时ms
  • 必要时仅DEBUG保留敏感或大字段

3. 添加队列观测日志

在生产者和消费者中增加queueId日志,便于测试环境核对路由:

生产者代码片段(示例,已脱敏):

java 复制代码
// 实际Topic和Broker名称已脱敏
String topic = "your-order-topic";
String brokerName = "broker-a";
Integer queueId = null;
if (sendResult.getMessageQueue() != null) {
    queueId = sendResult.getMessageQueue().getQueueId();
    brokerName = sendResult.getMessageQueue().getBrokerName();
}
log.info("发送MQ结果成功,queueId={}, broker={}", queueId, brokerName);

消费者代码片段(示例,已脱敏):

java 复制代码
// 实际Topic名称已脱敏
String targetTopic = "your-order-topic";
if (targetTopic.equals(config.getTopic())) {
    log.info("消费消息 topic={}, queueId={}, broker={}, msgId={}",
            config.getTopic(), message.getQueueId(), "broker-x", // Broker名称已脱敏
            message.getMsgId());
}

4.3 可选优化(P2)

1. 调整pullBatchSize

RocketMQAutoConfiguration创建DefaultMQPushConsumer处设置更大的pull批次,但当前consume-message-batch-max-size: 1,主要瓶颈仍在HTTP调用,预期收益有限。

2. Listener缓存

缓存MessageListenerConcurrent的listener引用,减少循环内的查找开销。

五、顺序语义分析与处理

5.1 现状分析

当前实现既不保证同一订单进同一队列,也不保证队列内严格FIFO业务顺序:

  • 发送端 :延迟发送走DefaultMQProducer.send(Message),由客户端默认队列选择策略(轮询)挑队列
  • 消费端CONCURRENTLY并发消费+多线程,两条消息即使在同一队列也可能被不同线程并行处理

5.2 乱序处理策略

采用策略A:业务侧顺序无关

消费代码processOrdersUnderLock中:

  • getOrderByIds后无记录则走handleInsertScenario(新增)
  • 有记录则走handleUpdateScenario(更新)

即使出现"更新先于创建"的情况:

  1. 先消费的更新消息,库中尚无订单 → 走新增路径
  2. 后续创建消息到达时,库中已存在订单 → 走更新路径

通过"库内是否存在订单"这一事实自动纠正消费顺序,不必依赖MQ全局顺序

但为了后续消息能落在同一个队列,我们这里还是写了一个消息顺序到同一个队列的方法。

java 复制代码
    /**
     * 与顺序发送一致的哈希选队列策略:idx = |hash(shardingKey)| % mqs.size()。
     * <p>
     * 在<strong>当前 Topic 路由稳定</strong>(队列数、Broker 拓扑一段时间内不变)时,同一 shardingKey 会稳定落到同一队列。
     * 若将读写队列从例如 4 扩到 16,取模底数变化,同一订单号映射到的 queueId 可能变化,属预期现象;
     * 扩展完成后在新队列数下会重新稳定,不要求跨「扩缩容前后」保持同一 queueId。
     */
    private static final MessageQueueSelector ORDER_HASH_SELECTOR = (mqs, m, arg) -> {
        String key = String.valueOf(arg);
        int idx = Math.abs(key.hashCode()) % mqs.size();
        return mqs.get(idx);
    };

延迟消息+根据订单ID取模队列数 发送消息

java 复制代码
    /**
     * 延迟 + 顺序哈希选队列:同时设置 {@link Message#setDelayTimeLevel(int)} 与 {@link DefaultMQProducer#send(Message, MessageQueueSelector, Object)},
     * 适用于 RocketMQ 4.9 需「延迟级别 + 按业务键固定队列」的场景。
     *
     * @param moduleName   模块名(对应 yml 中的 producer 所属模块)
     * @param topic        Topic
     * @param tag          Tag
     * @param messageBody  消息体
     * @param messageKey   消息 Key(检索用,可与 shardingKey 不同)
     * @param levelEnum    延迟级别枚举
     * @param shardingKey  选队列键(如同一牵牛花 order_id),相同键落到同一队列
     */
    public SendResult sendDelayOrderMessage(String moduleName, String topic, String tag, String messageBody, String messageKey,
                                            RocketMQDelayLevelEnum levelEnum, String shardingKey) {
        try {
            DefaultMQProducer producer = autoConfiguration.getProducer(moduleName);
            if (producer == null) {
                throw new IllegalStateException("RocketMQProducer not found for module=" + moduleName);
            }
            Message msg = new Message(topic, tag, messageBody.getBytes(StandardCharsets.UTF_8));
            if (messageKey != null) {
                msg.setKeys(messageKey);
            }
            msg.setDelayTimeLevel(levelEnum.getLevel());
            MqTraceIdSupport.copyMdcToUserProperties(msg);
            return producer.send(msg, ORDER_HASH_SELECTOR, shardingKey);
        } catch (Exception e) {
            log.error("RocketMQ发送延迟顺序消息失败, topic={}, tag={}, shardKey={}, level={}", topic, tag, shardingKey,
                    levelEnum != null ? levelEnum.getLevel() : null, e);
            throw new RuntimeException(e);
        }
    }

5.3 扩容注意事项

  • 队列数宜只增不减:扩容后短暂rebalance属正常。
  • 历史消息位置固定:扩容前已写入Broker的积压消息永久绑定在原QueueId,不会"搬家"。
  • 新消息路由策略 :扩容后新发送的消息路由取决于生产者的队列选择策略。若使用默认轮询策略,消息会均匀分配到所有队列(包括新增的);若使用基于Key的哈希策略,则消息仍只路由到哈希值对应的队列子集,可能无法立即利用所有新队列。需要评估生产者配置以确保扩容效果。
  • 顺序无关性:由于采用业务幂等设计,新旧消息混合消费不会影响最终一致性。

六、验证指标

6.1 监控指标

  • Dashboard:各队列Diff(lag)是否持续下降;两台客户端Last Time是否接近
  • 应用日志:单条处理总耗时及HTTP/DB分段耗时
  • 数据库:批量插入与批量仓库查询是否减少慢查询

6.2 改造效果

改造前后对比:

  • 改造前 :30秒内消费63条消息

  • 改造后 :30秒内消费482条消息

消费速度提升约7.6倍,有效解决了消息积压问题。

七、总结与经验

7.1 关键收获

  1. 队列数是并行度的天花板:RocketMQ中消费并行度上限约等于队列数,需根据业务峰值合理配置
  2. 消费线程数需要调优:默认配置往往无法满足高并发场景,需根据服务器资源动态调整。
  3. N+1查询是大敌:批量查询+Map缓存是解决N+1问题的标准模式。
  4. 日志需分级控制:生产环境应避免输出大量调试信息,改为摘要日志。
  5. 业务幂等比MQ顺序更可靠:通过数据库状态判断比依赖MQ顺序更健壮。

7.2 后续规划

  1. 持续监控消费速度和积压情况。
  2. 根据业务增长评估是否需要进一步扩容。
  3. 考虑引入APM工具进行全链路性能监控。
  4. 评估是否需要实现Hash发送+顺序消费(当前阶段不实施)。
相关推荐
数据库小学妹1 小时前
国产数据库技术成熟度实测:从Oracle兼容到高可用,四个维度评估能不能上生产
数据库·经验分享·oracle·性能优化·dba
JdSnE27zv2 小时前
数据库性能优化三:程序操作优化
数据库·sql·性能优化
AC赳赳老秦3 小时前
OpenClaw任务复盘自动化:统计每日完成工作、遗留问题,优化工作节奏
java·大数据·linux·运维·服务器·数据库·openclaw
AOwhisky3 小时前
学习自测(MySQL系列第一期、第二期)
linux·运维·数据库·学习·mysql·云计算
我叫张小白。3 小时前
Redis BitMap实现用户签到功能
数据库·redis·缓存·fastapi
大数据魔法师3 小时前
MongoDB(九) - MongoDB分片集安装与配置
数据库·mongodb
念何架构之路3 小时前
存储层技术MySQL
数据库·mysql
cfm_29143 小时前
Redis高并发多级缓存介绍 + JDHotkey热点探测了解
数据库·redis·缓存
yun呐3 小时前
mysql数据库误删恢复
数据库·mysql·adb