排查过程中,某高并发压测场景下的 RocketMQ 集群(v4.9.4)频繁爆出 [TIMEOUT_CLEAN_QUEUE]broker busy,发送延迟 P99 从 5ms 突增至 2000ms+。核心原因是 Linux PageCache 脏页回写与 mmap 缺页中断(Page Fault)阻塞了 Broker 写线程。结论先行:通过开启 RocketMQ 的 warmMapedFileEnable=true 和 transientStorePoolEnable=true,配合下调 OS 内核的 vm.dirty_background_ratio,可彻底斩断内核级阻塞,将 P99 稳定压制在 10ms 以内。
故障现场与指标观测
某次大促前夕的全链路压测中,单 Broker 节点 QPS 压到 4w 时,客户端开始出现大量的 MQBrokerException: broker busy 与 RemotingTooMuchRequestException 报错。
查看 Broker 端 store.log 与 broker.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
调出监控看板:
-
CPU Load:平时 4-5 左右,故障发生瞬间 Load Average 飙升至 40+。
-
磁盘 IO:
iostat -xdm 1显示await偶尔飙高,但util%只有 50% 左右,磁盘并未彻底被打满。 -
内存指标:
free -m显示buff/cache占用接近 85%,物理空闲内存(free)极少。
此时通过 strace -p 抓取底层系统调用,发现部分写操作耗时极其离谱,甚至超过 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 内核视角下,它随时可能被阻塞,原因有二:
-
缺页中断(Minor/Major Page Fault): 当 Broker 滚动创建新的 1GB CommitLog 并执行
mmap时,Linux 采用的是“延迟分配”策略。仅仅是建立了虚拟内存地址映射,并未分配实际物理页。当写线程第一次往这个地址put数据时,会触发内核缺页中断,内核需要去寻找空闲物理页并建立页表。如果此时系统物理内存紧张,内核触发直接回收(Direct Reclaim),写线程就会被死死卡住。 -
PageCache 脏页回写阻塞: 当脏页积累到内核阈值(
vm.dirty_ratio,默认 20%)时,Linux 会挂起所有尝试生成新脏页的用户进程,强行同步刷盘。此时你的ByteBuffer.put()会直接退化为同步阻塞写。
深度解析:CommitLog Mmap 与 读写分离预热机制
为了规避上述内核级别的阻塞,RocketMQ 提供了几项极为核心的防御性存储机制。
1. 强制预热与内存锁定(warmMapedFileEnable)
配置 warmMapedFileEnable=true 后,Broker 在创建新的 1GB MappedFile 时,会提前在后台线程中将其填满 0,强行触发所有的缺页中断,真正分配物理内存。
不仅如此,RocketMQ 还会调用 JNA 执行 mlock 和 madvise:
// 核心源码示意 (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 线程。