标签: 顺序消息

  • 深入 RocketMQ 顺序消息雪崩排查:无限重试引发的队列阻塞与 CommitLog PageCache 抖动惨案

    近期处理了一起由边缘业务引发的全局 RocketMQ 集群雪崩事故。故障现象非常典型:核心链路的 Producer 突然出现大量 [TIMEOUT_CLEAN_QUEUE]broker busysystem busy 报错,消息发送 P99 延迟从平时的 2ms 飙升到 3000ms 以上,最终触发限流降级,核心业务受损。

    直接抛出结论: 这不是集群容量不足的问题,而是一次典型的“业务代码低级失误 + 底层机制连锁反应”引发的惨案。某业务团队滥用 MessageListenerOrderly(顺序消费),且在 Listener 中未做全局异常捕获。一条“毒药消息”(Poison Pill)触发空指针异常,导致该 MessageQueue 无限重试并被死锁。 随着积压加剧,Consumer 触发冷读(Cold Read),疯狂从磁盘拉取历史数据,引发底层 PageCache 颠簸(Thrashing)。这直接导致 Broker 写 CommitLog 时发生严重的 Major Page Fault(缺页中断),写入线程被阻塞,集群为了自我保护触发了 BrokerFastFailure 机制,全盘拒绝了所有 Producer 的写入请求。

    解决这种问题,光靠扩容 Broker 是没用的,必须从业务消费逻辑兜底和 Broker 存储层防御两端同时下刀。

    故障现场与排查推演

    排查过程中,我们首先查阅了核心 Producer 的报错日志,满屏都是这个极其刺眼的异常:

    MQBrokerException: CODE: 2 DESC: [TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: 205ms, size of queue: 876
        at org.apache.rocketmq.client.impl.MQClientAPIImpl.processSendResponse(MQClientAPIImpl.java:682)
    

    看到 TIMEOUT_CLEAN_QUEUE,有经验的架构师脑子里应该立刻条件反射出它的触发机制:RocketMQ 的 BrokerFastFailure 后台线程会定时清理发送队列,如果发现请求在队列中等待处理的时间超过 200ms(默认值),就会直接丢弃该请求并返回 broker busy。

    为什么会等待超过 200ms?说明 Broker 处理写请求的线程池卡住了。 我立即登录主 Broker 节点,用 vmstat 1iostat -xz 1 扫了一眼,Load Average 飙到了 80+,CPU 使用率并不高,但 %wa (IO Wait) 高达 60%,磁盘 util 长时间顶在 100%。

    查看 Broker 的 store.log,果不其然,刷盘耗时严重超标:

    WARN flush disk log [CommitLog] cost: 450 ms
    WARN flush commit log cost: 455 ms
    

    RocketMQ 是基于 mmap 实现的高效顺序写,CommitLog 直接写入 PageCache,通常在微秒级。这种几百毫秒的延迟,说明 PageCache 被污染了,触发了严重的缺页中断,导致同步等待磁盘 I/O

    顺藤摸瓜,查看监控大盘的 Consumer Lag 指标,发现某非核心服务的滞后量达到了数百万条。 登录该业务的 Pod 抓取线程栈(jstack),发现大量的 ConsumeMessageThread 处于阻塞状态。

    愚蠢的 Root Cause

    翻看该业务的代码,血压直接飙升。他们为了保证所谓的“严格顺序”,使用了 MessageListenerOrderly,代码如下:

    consumer.registerMessageListener(new MessageListenerOrderly() {
        @Override
        public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
            // 没有任何 try-catch 兜底逻辑
            String payload = new String(msgs.get(0).getBody());
            processStrictly(payload); // 这里抛出了 NullPointerException
            return ConsumeOrderlyStatus.SUCCESS;
        }
    });
    

    为什么这在普通消费中不是致命问题,但在顺序消费中却是灾难?

    在普通并发消费(MessageListenerConcurrently)中,如果抛出异常或返回 RECONSUME_LATER,RocketMQ 会将消息发往 %RETRY%Group 的重试队列,并带有阶梯重试间隔,重试 16 次后进入死信队列(DLQ),当前队列会继续消费下一条消息。

    但在顺序消费(MessageListenerOrderly)中,底层逻辑是严格保序的。为了防止乱序,如果 Listener 抛出异常或返回 SUSPEND_CURRENT_QUEUE_A_MOMENT,RocketMQ 会认为这条消息没处理完,绝对不会跳过它。它会将当前 MessageQueue 挂起,默认等待 1 秒后,再次投递这条一模一样的消息,陷入死循环(无限重试)。

    在这个场景下:

    1. 队列被锁死:毒药消息无限重试,后续几万条正常消息全部被阻塞在该队列后面。

    2. K8S 重启风暴:业务方发现积压,习惯性地去删 Pod 重启。Pod 的频繁上下线导致 Consumer Group 疯狂触发 Rebalance。在顺序消费模式下,Rebalance 需要向 Broker 申请分布式锁,频繁的锁争抢进一步增加了 Broker 的 CPU 压力。

    3. 冷读触发雪崩:因为消息积压时间太长,这些数据早就从 OS PageCache 中淘汰。当积压的队列试图拉取消息时,触发了大量的磁盘随机读取(冷读)。这些大量的冷读数据挤占了宝贵的 PageCache,导致 CommitLog 写入时找不到空闲页,触发 Major Fault 落盘,最终阻塞了全局的发送请求。

    一段没有写 try-catch 的几十行边缘代码,直接干翻了整个大集群,这就是缺乏防御性编程意识的代价。

    修复与底层防御加固

    对于这种问题,必须实施双端改造。

    1. 业务侧:顺序消费的防御性兜底

    严禁在 MessageListenerOrderly 中裸奔。必须全局捕获异常,并设定自定义的最大重试次数(利用 Message 的 ReconsumeTimes 属性)。当重试超过阈值时,手工将其告警并写入本地死信表或旁路处理,强制返回 SUCCESS 让位给后续消息。

    public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
        MessageExt msg = msgs.get(0);
        try {
            process(msg);
            return ConsumeOrderlyStatus.SUCCESS;
        } catch (Exception e) {
            log.error("Consume orderly error, msgId: {}", msg.getMsgId(), e);
            // 防御性编程:判断重试次数,避免无限阻塞队列
            if (msg.getReconsumeTimes() >= 3) {
                moveToCustomDLQ(msg); // 降级处理
                return ConsumeOrderlyStatus.SUCCESS; // 强行放行
            }
            return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
        }
    }
    

    2. Broker 侧:隔离冷热读写,保护 PageCache

    即使业务再拉胯,基础设施也必须坚挺。调整 OS 和 Broker 配置以提升抗雪崩能力。

    • OS 层内核参数调优: 调整 vm.extra_free_kbytesvm.min_free_kbytes,强制内核保留一定的空闲内存用于应对突发的 IO 请求分配,避免 Page Reclaim 引发阻塞。 bash sysctl -w vm.zone_reclaim_mode=0 sysctl -w vm.swappiness=1

    • Broker 存储层调优: 强制开启预热和 mmap 内存锁定。 “`properties # 强制将 mmap 映射的内存锁定在物理内存中,避免被 Swap 出去 (mlockall) warmMapedFileEnable=true

      开启异步刷盘下额外的堆外内存池。

      写请求先写入 DirectByteBuffer,再异步 commit 到 PageCache。

      极大地缓冲了 PageCache 抖动对 Producer 写入请求的影响。

      transientStorePoolEnable=true “`

    • 开启冷热分离(RocketMQ 5.x 推荐,或 4.x SSD+HDD 架构): 如果磁盘条件允许,将 CommitLog 和 ConsumeQueue 部署在高性能 NVMe 上,或者利用 RocketMQ 的 Cold Data 机制,将长期积压的数据下沉,确保热点读取完全命中内存。

    排查清单 (同类问题速查)

    1. [TIMEOUT_CLEAN_QUEUE] broker busy 报错:意味着 Broker 处理写入请求的耗时超过 200ms。不要急于怀疑网络,第一优先级检查 Broker 磁盘 %wastore.log 中的 Flush Cost,大概率是 PageCache 抖动导致 mmap 写入缺页阻塞。

    2. 顺序消费死锁陷阱MessageListenerOrderly 不受最大重试 16 次的限制。Listener 抛出未捕获异常或返回 SUSPEND_CURRENT_QUEUE_A_MOMENT 会导致该队列无限重试。必须由业务层判断 ReconsumeTimes 进行主动放行。

    3. 冷读风暴污染内存:Consumer 拉取长时间积压的历史消息(冷读),会将磁盘文件重新加载到 PageCache,直接挤占 CommitLog 的内存页空间。可通过启用 transientStorePoolEnable=true 彻底解耦业务冷读对热点发送写入的直接冲击。

    4. K8S Rebalance 抖动:顺序消费依赖向 Broker 侧申请全局锁。Pod 的频繁起停会导致 Consumer 假死,引发长时间的 Rebalance 等待(锁续期与超时机制),表现为队列有堆积但没有消费速率。

  • RocketMQ 顺序消息队列“假死”:一个 NPE 引发的百万级积压与 ConsumeOrderly 死锁惨案

    某次核心交易链路报警,监控大盘上 RocketMQ 的 Consumer Lag 指标在短短十几分钟内飙升突破 200 万,业务侧反馈订单状态机完全停滞,P99 延迟直接变成一条横线(超时)。排查发现,问题根因极度低级:业务开发在处理顺序消息(Orderly)的消费逻辑时,漏抓了一个 NullPointerException。这个异常导致 RocketMQ 客户端为了保证严格的局部顺序,不断挂起当前队列并无限重试,彻底锁死了该 MessageQueue,后续百万级消息全部被堵死在单车道上。

    结论先行:与并发消费(Concurrent)将失败消息发往 Broker 端的 %RETRY% 队列不同,RocketMQ 的顺序消费在遇到异常时,默认会在 Consumer 本地客户端无限重试MaxReconsumeTimes 默认为 -1,即 Integer.MAX_VALUE)。 在 MessageListenerOrderly 中,绝对不能让未经捕获的异常抛出到框架层。务必严格使用 try-catch 包裹所有业务逻辑,并结合 msg.getReconsumeTimes() 实现阈值阻断与自定义死信队列(DLQ)降级。

    故障现场:200万Lag与“安静”的消费者

    排查过程中,第一反应是消费端挂了或者 Broker 存在毛刺。但看了下基础监控,Consumer 所在的 K8S Pod 的 CPU 和内存水位都很低,甚至可以说闲得发慌。

    执行 mqadmin consumerProgress 查看消费位点状态:

    # sh mqadmin consumerProgress -n x.x.x.x:9876 -g Order_Trade_Consumer_Group
    Topic             Broker Name  QID  Broker Offset  Consumer Offset  Client IP      Diff
    Trade_Order_Topic broker-a     0    150000         150000           10.0.x.x       0
    Trade_Order_Topic broker-a     1    152000         152000           10.0.x.x       0
    Trade_Order_Topic broker-a     2    3100500        100500           10.0.x.y       3000000  <-- 剧烈积压
    Trade_Order_Topic broker-a     3    149000         149000           10.0.x.y       0
    

    现象很明显:并不是整体消费能力不足,而是 broker-aQID=2 这一个队列卡死了。

    进到 10.0.x.y 这个 Pod 抓 jstack,发现大量 RocketMQ 的消费线程处于 TIMED_WAITING 状态:

    "ConsumeMessageThread_1" Id=85 RUNNABLE
        at java.lang.Thread.sleep(Native Method)
        at org.apache.rocketmq.client.impl.consumer.ConsumeMessageOrderlyService$ConsumeRequest.run(ConsumeMessageOrderlyService.java:470)
    

    再翻看业务日志,满屏都是同一个报错的死循环:

    java.lang.NullPointerException: user_id is null in payload
        at com.biz.order.listener.OrderStateMachineListener.consumeMessage(OrderStateMachineListener.java:45)
    

    业务代码极其奔放,直接在 consumeMessage 里抛出了 NPE,既没有 catch,也没有重试次数校验。

    底层原理解析:为什么并发消费没事,顺序消费就崩?

    很多开发习惯了 RocketMQ 的并发消费(Concurrent)模型。在并发模式下,如果 consumeMessage 抛出异常或返回 RECONSUME_LATER,RocketMQ 会将该消息重新发回 Broker 端的 %RETRY%ConsumerGroup 队列,并推进当前 MessageQueue 的消费位点。这样“毒消息”会被扔到一边,后续消息继续畅通无阻,最多重试 16 次后进入死信队列(DLQ)。

    但在顺序消费(Orderly)模型下,游戏规则变了。 顺序消费的核心语义是:前一条消息不消费成功,后一条消息绝对不能处理。

    为了保证局部有序,Consumer 在拉取到消息后,会向 Broker 申请锁(RebalanceImpl.lockMQPeriodically),锁定整个 MessageQueue,并生成一个 ProcessQueue。 当 MessageListenerOrderly 抛出异常,或者返回 SUSPEND_CURRENT_QUEUE_A_MOMENT 时,我们看看 RocketMQ 内核是怎么处理的:

    // 摘自 ConsumeMessageOrderlyService.java 核心逻辑
    public void processConsumeResult(
        final ConsumeOrderlyStatus status,
        final ConsumeOrderlyContext context,
        final ConsumeRequest consumeRequest) {
    
        // ... 前置省略
        case SUSPEND_CURRENT_QUEUE_A_MOMENT:
            // 检查重试次数
            if (checkReconsumeTimes(msgs)) {
                // 如果超过最大重试次数,才发往 DLQ 并推进位点
                consumeRequest.getProcessQueue().makeMessageToCosumeAgain(msgs);
                this.submitConsumeRequestLater(
                    consumeRequest.getProcessQueue(),
                    consumeRequest.getMessageQueue(),
                    context.getSuspendCurrentQueueTimeMillis());
                continueConsume = false;
            }
    }
    

    注意这里的 checkReconsumeTimes 逻辑。在并发消费中,默认最大重试次数是 16。但在顺序消费中,DefaultMQPushConsumer.maxReconsumeTimes 的默认值是 -1。 这意味着,只要业务抛出异常,客户端就会把当前 MessageQueue 挂起(默认 sleep 1秒),然后重新把这条消息拿出来再消费一次。无限循环,永不跳过。

    业务想要的是局部严格顺序,却没考虑过异常数据的降级处理。这就好比在单行道上,一辆车抛锚了,司机不仅不叫拖车,还坐在车里无限期尝试打火,导致后面的百万车流死死堵住。

    毁灭性后果与防御性修复

    这种积压是极其致命的。因为 MessageQueue 被无限重试的线程死死锁住,哪怕你重启 Consumer Pod,由于 Rebalance 机制,这批“毒消息”只会漂移到另一个 Pod 上,继续锁死那个 Pod 的消费线程。最终导致整个业务集群在处理特定 Shard Key 时彻底瘫痪。

    防御性编程不是挂在嘴边的废话,是不让你半夜爬起来擦屁股的救命稻草。 正确的顺序消息消费姿势,必须具备异常兜底主动降级能力:

    @Component
    public class RobustOrderlyListener implements MessageListenerOrderly {
    
        // 严禁无限重试,设定最大容忍次数
        private static final int MAX_RETRY_TIMES = 5;
    
        @Override
        public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
            // 顺序消费默认 batch 为 1
            MessageExt msg = msgs.get(0);
    
            try {
                // 核心业务逻辑
                processBizLogic(msg);
                return ConsumeOrderlyStatus.SUCCESS;
    
            } catch (Throwable t) {
                // 拦截所有未知的 Throwable,严禁抛出到框架层
                int currentRetry = msg.getReconsumeTimes();
                log.warn("顺序消息消费异常, msgId:{}, retry:{}", msg.getMsgId(), currentRetry, t);
    
                if (currentRetry >= MAX_RETRY_TIMES) {
                    log.error("顺序消息重试到达上限,触发熔断降级。写入死信表并跳过. msgId:{}", msg.getMsgId());
                    try {
                        // 必须自己实现死信存储逻辑(如写入 DB/Redis/专用重试Topic)
                        saveToCustomDeadLetter(msg, t);
                    } catch (Exception e) {
                        log.error("写入自定义死信队列失败,继续挂起队列", e);
                        // 仅在降级系统也崩溃时,才允许挂起当前队列
                        return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
                    }
                    // 强制返回 SUCCESS 推进位点,释放队列拥堵
                    return ConsumeOrderlyStatus.SUCCESS;
                }
    
                // 未到重试上限,挂起队列一会再试
                return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
            }
        }
    }
    

    排查清单(同类问题速查)

    1. 单队列卡死确认:使用 mqadmin consumerProgress 检查。如果 Diff 极高且集中在极少数 QID,而其他队列 Diff 为 0,100% 是局部卡死(顺序消息死锁或单分片数据倾斜严重)。

    2. 重试次数默认值陷阱:检查 Consumer 初始化代码。如果使用顺序消费且未显式设置 consumer.setMaxReconsumeTimes(次数),默认会进入 -1(无限重试)模式。强烈建议根据业务容忍度显式设置为 3~5 次。

    3. 消费者线程堆栈查验:执行 jstack | grep ConsumeMessageOrderlyService。如果大量线程长期处于 TIMED_WAITINGsleep 状态,说明业务逻辑正在疯狂触发 SUSPEND

    4. 毒消息清理:一旦发生雪崩,如果业务代码无法立即修复,可使用 mqadmin resetOffsetByTime 强制将卡死队列的消费位点往后拨动(会跳过中间数据,需业务确认可接受),先让后续积压消息流转,事后再通过日志捞回丢失数据。