标签: PageCache

  • 深入 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 生产环境 P99 抖动排查实战:PageCache 剧烈回收引发的 Broker Busy 与 Mmap 预热机制解析

    排查过程中,某高并发压测场景下的 RocketMQ 集群(v4.9.4)频繁爆出 [TIMEOUT_CLEAN_QUEUE]broker busy,发送延迟 P99 从 5ms 突增至 2000ms+。核心原因是 Linux PageCache 脏页回写与 mmap 缺页中断(Page Fault)阻塞了 Broker 写线程。结论先行:通过开启 RocketMQ 的 warmMapedFileEnable=truetransientStorePoolEnable=true,配合下调 OS 内核的 vm.dirty_background_ratio,可彻底斩断内核级阻塞,将 P99 稳定压制在 10ms 以内。

    故障现场与指标观测

    某次大促前夕的全链路压测中,单 Broker 节点 QPS 压到 4w 时,客户端开始出现大量的 MQBrokerException: broker busyRemotingTooMuchRequestException 报错。

    查看 Broker 端 store.logbroker.log,满屏如下报错:

    202X-XX-XX XX:XX:XX WARN [SendMessageThread_1] - [TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: 205ms, size of queue: 853
    202X-XX-XX XX:XX:XX WARN [SendMessageThread_2] - OS page cache busy, osPageCacheBusyTimeOutMills=1000
    

    调出监控看板:

    1. CPU Load:平时 4-5 左右,故障发生瞬间 Load Average 飙升至 40+。

    2. 磁盘 IOiostat -xdm 1 显示 await 偶尔飙高,但 util% 只有 50% 左右,磁盘并未彻底被打满。

    3. 内存指标free -m 显示 buff/cache 占用接近 85%,物理空闲内存(free)极少。

    此时通过 strace -p -T -e trace=mmap,munmap,write,pwrite64 抓取底层系统调用,发现部分写操作耗时极其离谱,甚至超过 1 秒。这就引出了一个经典的架构错觉:我都全异步了,为什么还会卡?

    为什么异步刷盘(ASYNC_FLUSH)依然会阻塞写线程?

    很多开发人员认为,只要 RocketMQ 配置了 flushDiskType=ASYNC_FLUSH,消息只要写到内存(PageCache)就算成功,磁盘 IO 慢绝不会影响发送延迟。这是一个极其致命的认知盲区。

    RocketMQ 的 CommitLog 默认采取 1GB 固定大小,通过 mmap(Memory Mapped Files)将物理文件映射到用户态的虚拟内存中。Broker 处理写请求的核心路径是: SendMessageProcessor -> CommitLog.putMessage() -> MappedFile.appendMessagesInner() -> ByteBuffer.put(data)

    问题就出在这个 ByteBuffer.put() 上。这虽然是内存操作,但在 Linux 内核视角下,它随时可能被阻塞,原因有二:

    1. 缺页中断(Minor/Major Page Fault): 当 Broker 滚动创建新的 1GB CommitLog 并执行 mmap 时,Linux 采用的是“延迟分配”策略。仅仅是建立了虚拟内存地址映射,并未分配实际物理页。当写线程第一次往这个地址 put 数据时,会触发内核缺页中断,内核需要去寻找空闲物理页并建立页表。如果此时系统物理内存紧张,内核触发直接回收(Direct Reclaim),写线程就会被死死卡住。

    2. PageCache 脏页回写阻塞: 当脏页积累到内核阈值(vm.dirty_ratio,默认 20%)时,Linux 会挂起所有尝试生成新脏页的用户进程,强行同步刷盘。此时你的 ByteBuffer.put() 会直接退化为同步阻塞写。

    深度解析:CommitLog Mmap 与 读写分离预热机制

    为了规避上述内核级别的阻塞,RocketMQ 提供了几项极为核心的防御性存储机制。

    1. 强制预热与内存锁定(warmMapedFileEnable)

    配置 warmMapedFileEnable=true 后,Broker 在创建新的 1GB MappedFile 时,会提前在后台线程中将其填满 0,强行触发所有的缺页中断,真正分配物理内存。 不仅如此,RocketMQ 还会调用 JNA 执行 mlockmadvise

    // 核心源码示意 (MappedFile.java)
    LibC.INSTANCE.mlock(pointer, 1024 * 1024 * 1024);
    LibC.INSTANCE.madvise(pointer, 1024 * 1024 * 1024, LibC.MADV_WILLNEED);
    

    mlock 直接告诉内核:“这 1GB 内存你给我锁死在 RAM 里,绝对不允许 Swap 出去!”。这就彻底消除了写消息时发生 Page Fault 的可能性。

    2. 堆外内存写池(transientStorePoolEnable)

    这是应对 PageCache 毛刺的终极武器(仅限异步刷盘有效)。 开启后,RocketMQ 会预先向 OS 申请一块 DirectByteBuffer 内存池(不受 JVM GC 影响,也暂时不进 PageCache)。 写数据路径变为:写请求 -> DirectByteBuffer -> 立即返回客户端成功。 后台 CommitRealTimeService 线程定期将 DirectByteBuffer 的数据写入 FileChannel(进入 PageCache),再由 FlushRealTimeService 线程异步刷盘。 这是一种极致的读写分离策略,彻底将“接收消息的写线程”与“PageCache 分配/刷盘”解耦。

    极客实战:RocketMQ 存储与内核参数双向调优

    解决此类抖动问题,绝不能只改应用配置,必须深入 OS 层联动调优。以下是我在生产环境经过验证的黄金配置标准。

    RocketMQ 核心配置 (broker.conf)

    # 强制使用异步刷盘
    flushDiskType=ASYNC_FLUSH
    # 开启堆外内存池缓冲,彻底解耦写请求与PageCache抖动
    transientStorePoolEnable=true
    # 开启Mmap预热与内存锁定,消除运行时缺页中断
    warmMapedFileEnable=true
    # 优化PageCache锁超时机制(如果发生抖动,快速失败,依赖重试)
    osPageCacheBusyTimeOutMills=1000
    

    Linux 内核 IO 参数调优 (/etc/sysctl.conf)

    光配 Broker 不够,必须改造内核的脏页回写策略:

    # 脏页占总内存的 5% 时,pdflush 后台线程开始异步刷盘(原默认10%)
    # 目的:提早刷盘,细水长流,避免积压
    vm.dirty_background_ratio = 5
    
    # 脏页占总内存的 40% 时,强制阻塞所有用户态写进程(原默认20%)
    # 目的:拉开与 background_ratio 的差距,给突发流量留足 Buffer
    vm.dirty_ratio = 40
    
    # 坚决不使用 Swap(避免mmap的内存被换出)
    vm.swappiness = 1
    
    # 预留给 OS 应急的物理内存(例如 128G 内存机器配 2G)
    # 目的:避免缺页中断时因无空闲内存触发直接回收(Direct Reclaim)引发系统停顿
    vm.min_free_kbytes = 2097152
    

    执行 sysctl -p 生效。经过这一套连招组合拳,压测 P99 稳如泰山,再也没有出现过 broker busy

    常见问题 (FAQ)

    Q1:开启 transientStorePoolEnable=true 后,如果 Broker 进程直接 Crash(如 OOM Killer),数据会丢失吗? 会。这就是享受极致低延迟的代价。该模式下数据首先写入 DirectByteBuffer,这是用户态进程的堆外内存。如果进程被 kill -9 或者 Crash,这部分尚未 commit 到 OS PageCache 的数据将会丢失。如果你对数据一致性要求极度苛刻(如金融交易),只能忍受延迟,关闭此项并使用 SYNC_FLUSH

    Q2:为什么消费重试队列(%RETRY%)里的消息会导致明显的磁盘 IO 升高和 Broker 负载增加? RocketMQ 是基于 CommitLog 的混合存储。正常消费是顺序读写(刚写完的数据大概率还在 PageCache 中,命中率极高)。但重试队列消费的是过去某个时间点的冷数据。这就迫使 Broker 产生大量的随机 IO(读磁盘),导致 PageCache 污染,驱逐掉热数据,从而引发全局性能下降。应对策略通常是单独隔离重试服务,或使用 NVMe SSD 扛随机 IO。

    Q3:遇到 [TIMEOUT_CLEAN_QUEUE]broker busy,除了存储层问题,还有什么原因? 如果磁盘 IO 不高,PageCache 也没问题,你需要检查是不是 JVM 发生了长时间的 Stop-The-World (STW)。尤其是 G1 GC 配置不当,或是业务代码向 RocketMQ 发送超大消息(如几 MB 的报文),导致 Broker 在反序列化/网络传输时消耗大量 CPU 和内存资源,阻塞了 Netty 的 Worker 线程。