CommitLog:抄起一个文件就往里写
RocketMQ 的存储模型够直白——所有消息,无论属于哪个 Topic,先不废话,统统顺序追加到 CommitLog 这一个物理文件里。你想想,普通磁盘顺序写的速度跟随机写完全不是一个物种。顺序写就是接力赛,磁头沿着轨道一路狂奔;随机写就成了无头苍蝇,寻道时间拖死你。RocketMQ 把消息写得像流水账,IO 瓶颈直接拉高一个数量级。消费端不能每次都扫 CommitLog 吧?所以后台有线程异步构建 ConsumeQueue,按 Topic 和队列维度建索引,存的是消息在 CommitLog 里的物理偏移。这就好比你在图书馆还书,管理员不分类,先垒成一摞,每晚再往书架上补检索卡——对于写得飞快的场景,太实用了。
这个设计带来一个意外收获:多 Topic 并发写入时不会产生零碎的小文件 IO,磁盘压力反而比“一个 Topic 一个文件”的方案小得多。早期我拿 Kafka 对比,Kafka 按 partition 分目录,当 Topic 数量暴涨到几千个,文件句柄和随机写惩罚会陡增。RocketMQ 的“万法归一”粗暴却有效。

零拷贝和 mmap:把数据流截弯取直
如果只是顺序写,算不上杀手锏。RocketMQ 真正狠的地方是读写路径上砌了两个底层的加速通道。第一个是 mmap(内存映射)。CommitLog 文件被直接映射到进程的虚拟地址空间,往里写消息就像写内存数组一样,省掉了 User Buffer 到 Page Cache 的拷贝。但是——敲黑板——mmap 映射区域如果超过 4GB,32 位下直接挂,64 位下虚拟地址空间虽大,可映射太多大文件也容易触发 VMA(Virtual Memory Area)膨胀,内核管理开销激增。曾经有个集群,单 CommitLog 设到 2GB,8 个文件轮转,结果跑了三天内存被映射占了 16G 虚拟空间,物理内存还傻乎乎分出去不少,最后 OOM Killer 跳出来干掉 Broker。
第二个是消费路径上的 零拷贝传输。老派做法:从磁盘读到内核 Page Cache,再 copy 到用户态 Buffer,再 copy 到 Socket Buffer——三次拷贝。RocketMQ 直接在 Broker 输出时调用
sendfile()(Java 的 FileChannel.transferTo() 底层就是它),数据从 Page Cache 直接扔进 Socket Buffer,内核态内部倒腾,连用户态的门都不进。这招让消费时的 CPU 占用下降得肉眼可见,网络吞吐却能飙高 30% 以上。实际压测数据:在 24 核、128GB 内存、万兆网卡的物理机上,采用异步刷盘模式,512 字节小消息,单 broker 的写入 TPS 稳定在 12.3 万,消费 TPS 能到 18.6 万,平均延迟 <1ms。而换成传统“read+write”模式的 MQ(比如 ActiveMQ 经典版),同样硬件,单节点扛到 3 万 TPS 就开始喘,延迟抖动到两位数。差距就是细节抠出来的。

三个亲手踩过的坑,别等线上炸了才醒悟

刚才提过。映射文件总大小一定不能超过 JVM 堆外内存预算。有个公式可以参考:映射总大小 ≤ 系统可用物理内存 × 0.4 / 文件个数。虽然 mmap 只是消耗虚拟地址空间,但 OS 会预留适量的物理页框,如果映射太大,又正好刷盘繁忙,脏页写回不及时,物理内存瞬间撑爆。解决方案:CommitLog 文件大小设为 1GB,并且 broker.conf 里设置
commitLogSize=1073741824,减少同时映射的文件数。另外开起 transientStorePoolEnable=true,借助堆外内存池暂写,再把数据 commit 到 Page Cache,能减弱直接 mmap 写入带来的 GC 压力——代价是多消耗一份堆外内存,自己算好性价比。坑 2:异步刷盘下的“假持久”
RocketMQ 默认是
flushDiskType=ASYNC_FLUSH,刷盘线程每 500ms 调一次 force()。这 500ms 的窗口里,消息躺在 Page Cache,万一宕机,丢了就丢了。金融场景必须同步刷盘?也得分情况。我就见过某支付系统,订单支付消息同步刷盘,TPS 立刻从 10 万掉到 5000,差点把下游数据同步链压垮。最终方案:关键链路用同步刷盘,但只对延迟不敏感的批处理业务开启;核心实时链路改成“主从异步复制 + 消费端重试对账”——主 broker 异步刷盘,从 broker 同步拉取,从节点定期检查中断位点补推,业务端再做幂等。虽然不能保证 100% 不丢,但加上小时级离线对账,可靠性能满足 99.99% 需求。坑 3:Consumer 负载均衡的 Rebalance 风暴
客户端每隔 20s 向 namesrv 拉路由,一旦某 broker 下线,所有 consumer 几乎同时检测到,同时发起 rebalance,队列分配震荡,消息积压加剧。别指望默认的
AllocateMessageQueueAveragely 策略能救你。生产环境我习惯用 AllocateMessageQueueAveragelyByCircle 均匀分布,同时把 rocketmq.client.rebalance.waitInterval 调大,比如 30s,引入随机 jitter(1-10s),让 consumer 重新分配时散开。另外,broker 端加 maxOffsetDelta 限制拉取速度和 flowControl 反压,能缓冲流量尖刺。这些玩意儿,文档上轻描淡写,源码里摔一跤才知道。说真的,RocketMQ 的存储设计称得上“工程美学”——简单,但处处是权衡。零拷贝、mmap 听着酷,背后空间换时间、一致性换吞吐,全是取舍。下次再聊聊消息事务的那点坑。