标签: CommitLog

  • 深入 RocketMQ 陷阱排查:海量消费重试引发的 CommitLog 随机读风暴与 PageCache 抖动实战

    核心结论:RocketMQ 的混合存储模型在海量消费重试与冷数据积压场景下,会打破 CommitLog 的顺序读写假象。当随机读导致 OS PageCache 命中率暴跌时,频繁的 Major Page Fault 会打满磁盘 IO,直接引发 [PCBUSY_CLEAN_QUEUE] Broker busy 拒绝服务。破局点在于开启 transientStorePoolEnable 实现读写内存隔离,调优 accessMessageInMemoryMaxRatio 强制冷读下沉至 Slave,并在 OS 层优化 PageCache 淘汰策略。

    故障现场:Broker 陷入假死与 PCBUSY 风暴

    某次排查过程中,生产环境核心 RocketMQ 集群(版本 4.9.4,部署于 CentOS 7,内核 3.10.0-1160.el7)突发大面积消息发送超时。客户端日志被 MQBrokerException: CODE: 2 DESC: [PCBUSY_CLEAN_QUEUE]broker busy 刷屏。

    登录 Broker 节点,系统 Load Average 飙升至 40+,但 CPU 使用率不到 30%。直觉告诉我,IO 瓶颈出现了。

    拉起 iostat 查看底层设备状态:

    iostat -dx 1
    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme0n1           0.00     0.00 4850.00  120.00 75800.00 12400.00    35.45    25.80    5.15    4.90   15.25   0.20 100.00
    

    磁盘 %util 长期处于 100%,且读操作 r/s 远高于写操作 w/s。 通过 pidstat -d -p 1 确认,高频发起的读 IO 全部来自 RocketMQ 进程。但这极不合理:RocketMQ 的核心优势就是 CommitLog 顺序写与 PageCache 带来的无盘读,为什么会出现如此密集的物理磁盘读取?

    查看 RocketMQ store.log 发现内存命中率暴跌:

    [PAGECACHERT] TotalPut 120543, PutOK 120500, PutRT > 1s 43
    [PAGECACHERT] PageCache hit ratio: 35.2%
    

    为什么海量重试会导致 CommitLog 退化为随机读?

    RocketMQ 的所有 Topic 数据混合写入同一个 CommitLog,这保证了极致的写入吞吐。消费时,通过 ConsumeQueue 进行逻辑定位,再回查 CommitLog 读取实际消息。

    正常情况下,生产者追加最新消息,消费者紧跟其后拉取,读取的 Offset 都在 OS PageCache 的热数据区间内。但当业务出现大面积异常,触发海量消费重试时,存储引擎的灾难就开始了:

    1. 重试队列的跳跃性读取:RocketMQ 的重试消息会被放入 %RETRY%ConsumerGroup Topic,并按延迟级别转入 SCHEDULE_TOPIC_XXXX。这些延迟消息在到期后,需要从历史 CommitLog 文件中被捞出并投递。

    2. 冷热数据交替污染 PageCache:大量延迟到期的重试消息分布在几小时甚至几天前的 CommitLog 中(冷数据)。消费者同时拉取实时热消息与重试冷消息。

    3. OS 层面的驱逐:Linux 内核通过 LRU 算法(Active/Inactive List)管理 PageCache。当 Broker 通过 mmap 读取冷数据时,触发缺页中断(Major Page Fault),内核被迫从磁盘读取数据到内存,并把热数据挤出 PageCache。

    4. 锁争用雪崩:RocketMQ 写入消息时需要获取 putMessageLock。当 PageCache 处于高压抖动状态时,mmap 内存映射区的写入或 fsync 刷盘耗时突增。一旦持锁时间超过 osPageCacheBusyTimeOutMills(默认 1000ms),Broker 就会立刻触发快速失败,抛出 PCBUSY_CLEAN_QUEUE。

    深度排查与调优实战

    为了彻底解决这一设计层面的物理瓶颈,我们需要从 Broker 配置与 OS 内核两端进行防御性加固。

    1. 开启 TransientStorePool 读写隔离

    默认情况下,RocketMQ 的读写都操作同一个 MappedByteBuffer(即 PageCache)。在随机读风暴下,写操作极易被阻塞。对于采用异步刷盘(ASYNC_FLUSH)的集群,必须开启 transientStorePoolEnable。

    修改 broker.conf:

    # 开启堆外内存池
    transientStorePoolEnable=true
    # 异步刷盘模式
    flushDiskType=ASYNC_FLUSH
    

    底层原理:开启后,RocketMQ 预分配一块 DirectByteBuffer 池。消息写入时直接进入 DirectByteBuffer(纯内存操作,无锁无缺页中断),然后由后台线程异步 commit 到 MappedByteBuffer 并 flush 到磁盘。消费时的读取仍走 MappedByteBuffer。这样彻底解耦了写入链路与 PageCache,即使发生缺页风暴,也不会阻塞 putMessage。

    2. 强制冷数据读取下沉至 Slave

    既然无法避免业务重试带来的冷读,就要把冷读的爆炸半径控制在从节点。通过调优内存水位阈值,强制拉取冷数据请求重定向给 Slave。

    # 允许从 Slave 读取数据
    slaveReadEnable=true
    # 当物理内存中消息容量占比超过 30% 时,认为读取的是冷数据,重定向至 Slave
    accessMessageInMemoryMaxRatio=30
    

    判定逻辑:在 DefaultMessageStore#getMessage 中,Broker 会计算当前读取的 MaxOffset 与被拉取 Offset 的差值。如果差值对应的物理文件大小超过了系统可用物理内存的 30%(即预估该数据不在 PageCache 中),就会给客户端返回 PullResult.SUGGEST_PULL_FROM_SLAVE。客户端下一次 Pull 会自动路由到同 Broker Name 的 Slave 节点,从而保护 Master 的 IO。

    3. Linux 内核 PageCache 防御性参数调优

    默认的 Linux 内核参数对高吞吐的数据库/中间件极不友好,必须调整脏页回写与内存分配策略,防止内核在缺页中断时发生长时间停顿。

    编辑 /etc/sysctl.conf:

    # 提高后台异步刷盘触发时机,平滑 IO 突刺
    vm.dirty_background_ratio = 5
    # 降低同步刷盘阈值,防止一次性刷入海量脏页卡死系统
    vm.dirty_ratio = 15
    # 增加保留空闲内存,防止发生直接内存回收(Direct Reclaim)导致的严重卡顿
    vm.min_free_kbytes = 1048576
    # 倾向于回收文件缓存而不是 swap anonymous pages
    vm.swappiness = 1
    # 禁用 Zone Reclaim 模式,防止 NUMA 架构下无谓的内存回收
    vm.zone_reclaim_mode = 0
    

    执行 sysctl -p 生效。通过提高 min_free_kbytes 到 1GB,可以给内核缓冲更多时间进行 kswapd 异步回收,避免触发代价极其高昂的同步回收。

    常见问题 (FAQ)

    Q1:开启 transientStorePoolEnable 会导致数据丢失吗? 会增加一定的风险。普通异步刷盘时,数据只要写入 PageCache,即便 JVM 崩溃(进程死亡),只要 OS 不宕机数据依然存在。但开启 transientStorePoolEnable 后,最新数据暂存在 DirectByteBuffer(JVM 进程空间),一旦 Broker 进程 OOM 或被 kill -9 强杀,未 commit 到 PageCache 的数据将会丢失。因此它仅适用于容忍极端情况下微量数据丢失且追求极致吞吐的业务。

    Q2:出现 [TIMEOUT_CLEAN_QUEUE] broker busy 和 [PCBUSY_CLEAN_QUEUE] 有什么区别? 两者都是由于 Broker 处理慢触发了自我保护。TIMEOUT_CLEAN_QUEUE 表示请求在 Broker 的 SendThreadPool 队列中排队等待处理的时间超过了设定的阈值(默认 200ms)。而 PCBUSY 则是指实际执行 CommitLog.putMessage 时,获取互斥锁的耗时超过了 1000ms(往往是底层 IO 卡顿导致)。前者是队列积压,后者是底层引擎阻塞。

    Q3:RocketMQ 5.x 的 Tiered Storage(分级存储)能解决这个问题吗? 能从根本上解决。RocketMQ 5.x 引入了冷热数据分离的架构。当数据超过一定时间后,会自动上传到 S3 或低成本 HDD 集群。客户端拉取冷数据(如延时重试消息)时,Broker 直接通过底层分级存储接口提供服务,不再读取本地 CommitLog,彻底切断了冷数据对热点 PageCache 的污染路径。

  • 深入 RocketMQ 陷阱排查:CommitLog mmap 锁竞争引发的 PageCache 抖动与 Producer 假死实战

    生产环境 RocketMQ 节点频繁出现 Producer 发送超时(RT > 3s)。核心原因是高并发场景下 PageCache 脏页回写引发 mmap 内存锁竞争,导致 CommitLog 异步刷盘退化为同步阻塞。解决方案:开启 transientStorePoolEnable=true 引入 DirectByteBuffer 读写分离,并下调 OS vm.dirty_background_ratio 至 5%,抹平内核 pdflush 抖动。

    近期在主导一个千万级 QPS 核心链路的可用性治理时,遇到了一个极为隐蔽的 RocketMQ 抖动问题。集群版本为 4.9.4,部署在 64C 256G 的物理机上,底层使用 SSD 阵列,Broker 配置为 ASYNC_FLUSH(异步刷盘)加 ASYNC_MASTER。

    监控大盘显示,大部分时间 Producer 写入耗时在 2ms 以内,但在业务高峰期,99 线会毫无规律地飙升到 3000ms 以上,甚至直接触发客户端超时异常 RemotingTooMuchRequestException。

    现场取证与监控排查

    排查初期,先看机器负载。发生抖动时,CPU 使用率不到 30%,内存充足,但 iostat -x 1 捕捉到了异常:磁盘 util% 瞬间打满 100%,await 飙升至几百毫秒。

    查看 Broker 的 store.log 和 broker.log,发现了大量如下报错:

    2023-XX-XX XX:XX:XX WARN [Broker-XX] - [NOTIFYME]page cache is busy, CPUBusyFlag=false, OSPageCacheBusyFlag=true, lock time(ms)=1250
    

    对应的,由于 PageCache 繁忙,RocketMQ 的快速失败机制被触发,导致向 Producer 返回系统繁忙的错误。使用 jstack 抓取当时的 Broker 线程栈,发现大量 SendMessageThread 被阻塞在 CommitLog.putMessage 方法内部的 putMessageLock 上。

    // 阻塞堆栈片段
    "SendMessageThread-1" prio=10 tid=0x00007f... runnable
        at sun.nio.ch.FileDispatcherImpl.write0(Native Method)
        at sun.nio.ch.FileDispatcherImpl.write(FileDispatcherImpl.java:60)
        at sun.nio.ch.IOUtil.writeFromNativeBuffer(IOUtil.java:93)
        ...
        at org.apache.rocketmq.store.CommitLog.putMessage(CommitLog.java:683)
    

    为什么 ASYNC_FLUSH 模式下依然会阻塞 Producer 线程?

    很多开发者的直觉是:既然配置了异步刷盘(flushDiskType = ASYNC_FLUSH),消息写到内存(PageCache)就会立刻返回,磁盘 I/O 抖动怎么会反向阻塞网络线程?

    要解释这个问题,必须深入 Linux 内核的 mmap 机制以及 RocketMQ 的写入模型。

    RocketMQ 的 CommitLog 默认通过 MappedByteBuffer (基于 Linux mmap 系统调用) 进行文件映射。Producer 写入消息时,本质上是往内存映射地址执行 memcpy。 在正常情况下,写 PageCache 的速度极快(微秒级)。但内核中存在两个关键的脏页回写参数:

    1. vm.dirty_background_ratio:默认 10%。当系统脏页比例达到此值,内核唤醒 pdflush (或 flush 线程) 异步将脏页刷盘。

    2. vm.dirty_ratio:默认 20%。当系统脏页比例达到此值,内核会强制阻塞所有发起写操作的用户线程,进行同步刷盘。

    当瞬间写入吞吐过高,底层 SSD 处于 GC 卡顿或 I/O 队列排队时,脏页积压一旦触达 vm.dirty_ratio 阈值,内核就会对当前的 mmap 写入操作施加阻塞。

    在 RocketMQ 4.9.4 的源码 CommitLog#isOSPageCacheBusy() 中,有一个看门狗机制:

    public boolean isOSPageCacheBusy() {
        // beginTimeInLock 记录了获取自旋锁或 ReentrantLock 的开始时间
        long begin = this.beginTimeInLock;
        // 默认 osPageCacheBusyTimeOutMills 为 1000ms
        long diff = this.systemClock.now() - begin;
        return diff < 10000000 && diff > this.defaultMessageStore.getMessageStoreConfig().getOsPageCacheBusyTimeOutMills();
    }
    

    当系统内核因脏页同步刷盘阻塞了某个正在持有 putMessageLock 的线程超过 1 秒,其他排队等待这把锁的 Producer 请求就会被判定为 page cache is busy 并快速失败。

    架构级调优:启用瞬态存储池 (TransientStorePool)

    要彻底根治这个问题,就必须把消息的“写入”和“PageCache分配/刷盘”在物理内存层面隔离开来。RocketMQ 提供了 transientStorePoolEnable 机制,这也是解决高并发下 PageCache 抖动的终极杀器。

    修改 broker.conf:

    flushDiskType=ASYNC_FLUSH
    transientStorePoolEnable=true
    # 瞬态池大小配置,按需调整(默认 5 个 CommitLog 文件的容量,即 5G)
    transientStorePoolSize=5
    

    底层原理解析: 开启后,RocketMQ 启动时会通过 posix_memalign 调用(Java 层为 ByteBuffer.allocateDirect 并利用 JNA 锁定内存 mlock)向系统申请一块堆外直接内存(DirectByteBuffer)作为瞬态池。

    此时消息的写入流转变为:

    1. Producer 写入 (极速且稳定):业务线程直接将数据拷贝到 DirectByteBuffer(完全在用户态,绕过 PageCache,绝对不会触发内核的同步刷盘阻塞),随后立即返回成功。

    2. Commit (异步):CommitRealTimeService 线程异步将 DirectByteBuffer 中的数据写入 FileChannel (即进入 OS PageCache)。

    3. Flush (异步):FlushRealTimeService 线程再异步将 PageCache 强制 fsync 到磁盘。

    引入这层真正的内存缓冲后,即便底层磁盘发生 3-5 秒的严重卡顿,只要 DirectByteBuffer 没写满,上游 Producer 线程依然可以保持微秒级的响应,实现了真正的系统级削峰填谷。

    操作系统内核参数的防御性加固

    除了架构层面的隔离,操作系统层面的调优也是必须的。默认的脏页回写策略过于激进,容易造成“平时不刷盘,一刷盘就卡死”的突刺现象。

    编辑 /etc/sysctl.conf:

    # 降低后台异步刷盘触发阈值,让内核更频繁、平缓地刷盘 (默认10)
    vm.dirty_background_ratio = 5
    
    # 适当调高同步阻塞刷盘阈值,给瞬时高峰留出更大缓冲空间 (默认20)
    vm.dirty_ratio = 40
    
    # 缩短脏页过期时间,单位百分之一秒,1000 即 10 秒 (默认3000)
    vm.dirty_expire_centisecs = 1000
    
    # 禁用 NUMA 架构下的内存交叉分配,防止 kswapd 频繁回收抖动
    vm.zone_reclaim_mode = 0
    

    执行 sysctl -p 立即生效。配合 transientStorePoolEnable=true 后,集群 99 线尖刺完全消失,大促压测期间 QPS 单机突破 8 万依然如丝般顺滑。

    常见问题 (FAQ)

    Q1: 开启 transientStorePoolEnable=true 后,Broker 宕机会不会丢消息? 会。这是典型的 CAP 权衡。停留在 DirectByteBuffer 里的消息(还未进入 PageCache)在 Broker 进程崩溃(OOM 或被 kill -9)时会丢失;而如果只是写入了 PageCache 但未刷盘,进程崩溃不会丢,只有物理机断电才会丢。此方案适用于允许极少量消息丢失以换取极致延迟和吞吐的业务场景(如日志、非核心流水)。若涉及金融级交易链路,请老老实实关闭此配置,使用 SYNC_FLUSH 并搭配高性能 NVMe SSD。

    Q2: 为什么调整了 vm.dirty_ratio 还是偶尔报 page cache is busy? 必须首先排查底层磁盘的 IOPS 是否已经达到硬件瓶颈(或云盘的限流阈值)。如果物理盘的写入速度长线远低于集群的消息生产速度,调整内存参数只不过是延缓了系统死亡的时间。利用 iostat 确认底层 I/O 是偶尔的 latency spike 还是持续的 utilization 100%。

    Q3: 顺序消息场景下,触发 PageCache 繁忙会导致什么严重后果? 如果是普通消息,快速失败后 Producer 客户端会自动重试其他 Broker;但在严格顺序消息场景下(MessageQueue 选择是固定的),一旦该 Broker 发生内存锁阻塞,Producer 针对该队列的重试大概率依然落在同一个 Broker 上,导致整条顺序链路在数秒内处于完全停滞状态,引发上游业务线程池被打满挂起。

    Q4: 云原生容器化部署时,如何配置这些内核参数? 如果 RocketMQ 跑在 K8s 中,vm.dirty_ratio 等属于内核级 sysctl 参数,不能在普通的 Pod 级别直接设置。需要开启 Pod Security Policies (或对应的安全准入控制),允许 unsafe sysctls,并在 Pod Spec 的 securityContext.sysctls 中显式声明。若安全策略不允许,只能在宿主机 Node 层面统一配置。