RocketMQ 的存储设计,我第一次看的时候觉得复杂,后来发现其实就一个核心:把磁盘当内存用外加用到极致的读写分离技术。
全局顺序写、内存映射、零拷贝、异步刷盘,这些词你可能都听过,但凑在一起为什么就快了?今天我们就从存储的核心设计出发,一层一层拆清楚。
从一个问题开始
假设你现在设计一个消息队列,最直觉的做法是什么?
大概率是:来一条消息,写到对应 Topic 的文件里。Topic A 的消息写 fileA,Topic B 的消息写 fileB,消费的时候直接读对应文件。
这个思路没问题,但有个致命缺陷 —— 随机写。
想象一下,同时有几十个 Topic、几百个 Queue 在并发写入,磁盘的磁头要不停地在不同位置之间跳来跳去。机械硬盘的随机写和顺序写性能差距能有几百倍,就算是 SSD 也差距明显。你的系统再怎么优化,卡在磁盘 IO 这里就完了。
RocketMQ 的解法
RocketMQ 的解法其实有点“粗暴”,但它粗暴得很正确:我管你什么 Topic、什么 Queue,所有消息先别分家,统统追加写进同一个文件。这个文件叫 CommitLog。
你可以把这句话当成整套存储的总纲。因为在磁盘面前,最怕的不是“数据多”,而是“到处写”。一旦写入变成随机写,再精妙的并发、再漂亮的架构,最后都会被 IO 按在地上摩擦。
所以这一章你只需要抓住三个硬结论,后面的细节都是围着它转的。
第一,写入路径只认 CommitLog:先把消息变成可恢复的顺序日志。
CommitLog 是“唯一真相源”。消息体永远只落在这里;ConsumeQueue、IndexFile 这些都只是索引派生物,坏了可以重建,落后一点也不影响 CommitLog 的正确性。
第二,读写分离做到极致:写线程只进行极致的追加,读线程只查索引,刷盘/建索引都后台化。
写入主路径干的事情很克制:把消息按协议序列化,追加到 CommitLog,推进必要位点,然后尽快返回。它不会在这条线程上同时去维护每个 Topic/Queue 的分散文件,因为那样顺序写马上就会退化成随机写。
读路径则走另一条路:消费者通常先读 ConsumeQueue / IndexFile 这种“轻量索引”,拿到 CommitLog 的物理 offset,再回去 CommitLog 精准取消息体。
至于消费者可见性边界怎么推进?RocketMQ 把它们拆成一组后台服务:刷盘服务负责把“等磁盘”从写线程身上挪走;索引构建线程负责把 CommitLog 解析成 ConsumeQueue / IndexFile。
这也引出一个很反直觉的取舍:RocketMQ 写入追求的是“快而稳定地写成日志”,而不是“写完立刻就能被消费”——消费能不能立刻跟上,取决于后台索引构建追得有多快。主链路的结论只有一句:写入不等磁盘、不等索引,不在主路径做会拖慢顺序追加的事。
第三,它不是“自研一套文件缓存”:性能底座押注 OS 的 PageCache。
RocketMQ 写 CommitLog 很多时候看起来像“写文件”,但更贴近事实的描述是:写入是在写一段映射内存,背后落在 PageCache 里,甚至是在堆外内存中。刷盘线程做的事情也很朴素:把 PageCache 里已经写过的那部分刷到磁盘,推进可靠边界。
读的时候也是同理:只要命中 PageCache,读起来就跟读内存差不多;不命中再落到真实磁盘 IO。它的策略不是造一个“Broker 内部缓存系统”,而是把 OS 这套成熟的缓存与回写机制吃到极致。
把这三点串起来,你就能在脑子里“跑”一遍 RocketMQ 的存储链路:
生产者发来一条消息 → Broker 只做一件事:顺序追加写进 CommitLog(通常先落在 PageCache,甚至是堆外内存)→ 按刷盘策略返回 ACK(异步刷盘就是先回,再后台刷;同步刷盘就是等刷到)→ 后台线程分发 CommitLog 重构到 ConsumeQueue / IndexFile → 消费者拉取时先查索引定位,再回 CommitLog 拿到消息体(大概率 PageCache 命中)。
所以下一节我们先把这个“唯一真相源”拆透:CommitLog 到底怎么把磁盘顺序写性能拉满。
CommitLog:把磁盘顺序写拉满
CommitLog 是 RocketMQ 存储的唯一写入入口。所有 Topic、所有 Queue 的消息,到了 Broker 只做一件事:顺序追加到这里。
这个设计的驱动力只有一个:消除写入路径上一切不必要的等待、切换和数据搬运。下面四层技术,每一层都在往这个方向使劲。
一、顺序写:磁盘性能的天花板
随机写有多贵?机械盘磁头寻道一次耗时几毫秒,大量随机写就是不停跳来跳去;SSD 上顺序写能跑到几个 GB/s,随机写可能只有几十 MB/s,差距轻松十倍以上。CommitLog 用 append-only 写法把所有消息串成一条顺序流,彻底规避随机写。文件默认 1GB,写满自动滚动,到期删除,不产生碎片。
但"顺序写"有个前提:并发写入必须串行化。多个生产者同时发消息,Broker 这边如果各写各的,顺序写马上退化成随机写。RocketMQ 的做法是在当前可写 MappedFile 上维护一个写指针,通过自旋锁/可重入锁(按照配置来定)保证同一时刻只有一个线程推进指针并写入那段区间。对外高并发,对内串行追加,把顺序写的好处吃满。
二、mmap + 零拷贝:写入路径的内存化
传统 write() 写文件,数据要经历两次拷贝:用户缓冲区 → 内核缓冲区 → 磁盘,还涉及多次 syscall 切换,每次写都在等磁盘转速。
RocketMQ 用 mmap(MappedByteBuffer)把 CommitLog 文件映射成一段虚拟内存。写入时直接操作这块内存页(对应 PageCache),省掉了用户缓冲区到内核缓冲区的额外拷贝,也不需要等磁盘响应,感知上就是内存写速度。这就是写入侧零拷贝的本质:mmap 本身就是机制,省拷贝是结果。
消费侧是另一条路:消费者读消息时,RocketMQ 通过 FileChannel.transferTo() 把数据从 PageCache 直接传输到网络栈,避免了"内核 → 用户态 → 内核网络栈"的多余拷贝(可以理解为 sendfile 语义)。写少一次拷贝,读少一次拷贝,高吞吐下节省的都是真实 CPU 和延迟。
还有一个细节值得注意:文件刚映射时,对应的物理页还没建立,首次写会触发缺页中断,在高吞吐下会带来不可预期的抖动。RocketMQ 在创建新 MappedFile 后会做预热(写入一遍假数据 + madvise 预读),把物理页提前 fault 进来,让后续写入路径干净、无中断。
三、刷盘策略:把"等磁盘"从写线程挪走
mmap 写入的是 PageCache,PageCache 是内存,机器断电数据就丢了。理解刷盘,先记住三个进度位点:
- wrotePosition:消息已写入内存映射区域(PageCache),应用视角的"写入完成",但不等于落盘
- committedPosition:开启堆外内存池时,代表数据已从堆外池提交到映射文件;不开启时与 wrotePosition 近似相同
- flushedPosition:真正刷到磁盘的进度,断电后还能找回来的边界
两种刷盘模式,本质是"谁来等磁盘、等多久":
异步刷盘:写线程推进 wrotePosition 后立即返回 ACK,后台刷盘线程每隔一段时间(默认 500ms)批量推进 flushedPosition。写入延迟极低,吞吐高,断电最多丢 500ms 窗口内的消息。
同步刷盘:写线程必须等 flushedPosition 推进到覆盖当前消息,才能返回 ACK。可靠性最强,但每次写都要等一次磁盘 IO,吞吐下降,延迟更抖。磁盘偶发抖动会直接反映到生产者 RT,刷盘跟不上时写线程排队等待,容易雪崩。
四、TransientStorePool:内存层面的读写分离
前面三层已经让写入主路径不等磁盘、不等刷盘。但还有个隐患:开启 mmap 后,生产者写 MappedByteBuffer、消费者和索引构建线程读同一段映射区域,读写都打在同一个 PageCache 上。高并发下,写侧不断弄脏页、读侧不断预读命中,两边争抢同一批页框和内核锁,PageCache 竞争会非常激烈,写入吞吐和读取延迟会互相拖累。
RocketMQ 的解法是 TransientStorePool——在内存层面再做一次读写分离:
- 提前分配一批堆外内存(Direct Buffer)组成可复用的内存池,并用
mlock锁住,避免被置换到磁盘交换区 - 写入时消息先序列化到堆外内存,而不是直接写
MappedByteBuffer,写入性能接近内存操作,确保了高速的写入响应 - 后台提交线程(CommitRealTimeService)定期批量把堆外数据提交到 FileChannel(即 PageCache)
- 消费侧仍然走 mmap 读 PageCache,和写入主路径彻底分开
核心价值在于:写路径先在堆外内存里完成,读路径仍走 PageCache,两者不再在同一条热点内存上激烈竞争。提交线程按节奏批量推进 committedPosition,把对 PageCache 的写入压力摊平。
这样四层叠下来,CommitLog 的高性能不是靠某一个技巧,而是每一层都在消除同一件事:写入路径上不必要的等待、切换和拷贝。
- 顺序写消除了磁盘随机 IO
- mmap + 零拷贝消除了用户态等磁盘、多余的内核拷贝,以及消费路径的数据搬运
- 刷盘策略把磁盘等待从写线程挪到了后台
- TransientStorePool 用堆外内存池把写入从 PageCache 热点上剥离,实现内存层面的读写分离
把这四点串起来,你就能理解为什么 RocketMQ 能在高并发、大吞吐下依然保持稳定的写入延迟。
消费怎么办?ConsumeQueue 的设计逻辑
CommitLog 是一条无差别的顺序流——所有消息不分 Topic、不分 Queue,统统按写入时间追加进去。这个选择对写入来说是最优解:一条顺序流,没有随机写,磁盘性能拉满。
但消费者不这么看问题。他只关心"Topic A 接下来有什么消息",CommitLog 里的其他消息跟他没关系。如果每次消费都要全量扫描 CommitLog,那性能上是灾难。
RocketMQ 的解法是在 CommitLog 之上建一个轻量的消费路由层——ConsumeQueue,专门解决"按 Topic 快速定位消息"这件事。
一、20 字节的索引,把定位做到极致
每个 Topic 的每个 Queue,各自对应一个 ConsumeQueue 文件,里面存的不是消息体,而是每条消息在 CommitLog 里的"地址",每个条目固定 20 字节:
[CommitLog 物理偏移量 8B] [消息长度 4B] [Tag 哈希值 8B]
定长是关键。第 N 条消息在哪,用 N × 20 直接算出字节偏移量,O(1) 定位,不需要任何遍历,和数组随机访问一样快。
这个设计还延伸到了文件命名上。ConsumeQueue 的每个文件名本身就是该文件第一个条目的字节偏移量,比如:
consumequeue/{topic}/{queueId}/00000000000000000000 ← 第 0 条起
consumequeue/{topic}/{queueId}/00000000006000000000 ← 第 300000 条起(300000 × 20B)
给定消费进度 N,算出字节偏移量 N × 20,对文件大小取整,就能直接定位出是哪个文件、文件内第几个字节,文件名就是最快的索引——连目录扫描都不需要。
有了这个索引,消费路径就变得非常清晰:先读 ConsumeQueue,按消费进度拿到 CommitLog 的物理偏移量和消息长度 → 再拿偏移量直接去 CommitLog 取完整消息体。两次 IO,但都是精准定位,没有任何扫描。而且这两次大概率不会真的碰磁盘:ConsumeQueue 条目小(20B),热数据几乎常驻 PageCache;CommitLog 里最近写入的消息体也大概率还在 PageCache 里。所以多数情况下,消费路径走的是内存速度,不是磁盘速度。
二、后台异步构建:不能让索引拖累写入
ConsumeQueue 不是在写 CommitLog 的同一条线程里同步构建的。为什么?如果写入线程还要同时维护每个 topic/queue 的分散文件,写入模式就从"一个 CommitLog 顺序追加"变成"多个 ConsumeQueue 文件并发写",随机写问题立刻回来,CommitLog 的顺序写优势就白费了。
所以 RocketMQ 让写入线程只管 CommitLog,单独起一个后台构建线程,从 CommitLog 依次解析每条消息,分别顺序追加到对应的 ConsumeQueue 文件里。值得一提的是:ConsumeQueue 的写入本身也是顺序追加,不是随机写,构建代价可控。
这个选择带来一个需要记住的副作用:写入进度和索引进度是两条不同的线。CommitLog 写入完成,意味着消息已经落入顺序日志;但 ConsumeQueue 构建线程还没追上,消费者就看不到这条消息。这就是为什么生产者拿到 ACK,消费者不一定立刻能拉到——不是 Bug,是两条异步进度追赶的正常状态,通常延迟极短,但不是同步保证。
三、Tag 哈希:20 字节里藏着的过滤设计
再回头看这 20 字节——最后 8 字节存的是 Tag 的哈希值,不是什么备用字段,而是专门为"按 Tag 过滤"设计的。
消费者按 Tag 订阅时,不需要先去 CommitLog 取出完整消息体再比对。直接在 ConsumeQueue 层预筛:哈希对不上的条目直接跳过,只对哈希可能命中的少数条目,才去 CommitLog 取消息体做精确判断。
用 20 字节的索引,尽量少触碰大消息体——这句话既是 ConsumeQueue 结构设计的总结,也是整套过滤逻辑的底层思路。
还有一点值得顺便提一下:ConsumeQueue 是按 topic + queueId 分文件的,这天然带来了读并行度——多个消费者可以并发读取不同队列,互不干扰,把消费端的吞吐也吃满。就好像写路径是单线高速公路,而读路径是多车道分流。
再多一层:IndexFile
ConsumeQueue 解决了"按 Topic 消费"的问题,但还有另一类需求:比如我有一个订单号 ORDER-20240101-001,想查这条消息在哪、什么时候写入的、内容是什么?
这时候 ConsumeQueue 是帮不上忙的——它是按 Topic 组织的,没有按业务 Key 检索的能力。这就是 IndexFile 存在的理由。
一、为什么是哈希槽 + 冲突链,而不是 B+ 树?
IndexFile 是一个按消息 Key 构建的哈希索引,结构是经典的哈希槽 + 链表解决冲突。
你可能会问:数据库都用 B+ 树,RocketMQ 为什么选哈希?因为 IndexFile 的定位和数据库完全不同:写入必须轻,不能干扰主链路;查询是"运维/排障/追踪"偏多,不需要范围查询和排序;B+ 树的插入和再平衡代价比哈希高,在这个场景里是多余的开销。
哈希槽 + 链表刚好契合:结构简单,写入是 append,冲突处理可控,实现复杂度低。不是因为简单就将就,而是因为场景就这么简单,用复杂结构反而是错的。
二、索引项存什么,怎么回到 CommitLog?
每个索引项包含四个字段:key 的哈希值、commitLogOffset(消息在 CommitLog 里的物理偏移量)、时间戳/时间差(用于按时间范围筛选)、prevIndex(冲突链的前向指针,把同一个哈希槽里的多个条目串成链表)。
有了这四个字段,查询流程就是:算出 key 的哈希 → 定位哈希槽,拿到链表头 → 沿链往前走,逐项比对 key 哈希和时间范围 → 命中就拿到 commitLogOffset,回 CommitLog 取消息体。
IndexFile 不存消息内容,只存"回到 CommitLog 的路"。 数据本体永远只在 CommitLog,IndexFile 的职责是带你找到门牌号。
三、异步构建 + 按时间滚动:主路径之外的"松弛"
IndexFile 的构建和 ConsumeQueue 一样,是后台线程跟着 CommitLog 的写入进度异步推进的。写入线程只管把消息追加进 CommitLog,后台线程再从 CommitLog 解析 key、offset、时间戳,写入 IndexFile。
和 ConsumeQueue 不同的是,IndexFile 落后的代价更低。ConsumeQueue 落后,消费者会拉不到消息,影响正常链路;IndexFile 落后,影响的只是"按 key 查历史消息"这类辅助场景。所以它可以更"松":主路径极致快,辅助能力允许最终一致,这是 RocketMQ 一贯的取舍思路。
还有一个细节值得注意:IndexFile 不是无限增长的,而是按时间窗口滚动——写满或超过时间范围就滚动新文件。文件名本身就是该文件第一条消息的写入时间戳:
index/20240101120000000
index/20240101150000000
这个命名直接服务于时间范围查询:按 key 查消息时通常会带上时间范围,Broker 看一眼文件名就能过滤掉不在范围内的 IndexFile,不需要打开文件就完成了第一层筛选。过期数据和索引一起按文件粒度清理,不会无限膨胀,重建时也更可控。
把 IndexFile 和 ConsumeQueue 放在一起看,你会发现 RocketMQ 索引层的设计有一个一致的原则:索引只存"地址",不存数据本体;数据永远只在 CommitLog。 所有索引都是 CommitLog 的派生物——坏了可以重建,落后了可以追,系统只需要保住 CommitLog,就能自我修复。
完整的读写流程
前面几节讲了写入、索引、刷盘各自的设计,把它们串起来,RocketMQ 的存储链路是这样的:
写入:生产者发消息 → Broker 顺序追加到 CommitLog(通常先落 PageCache,甚至是堆外内存)→ 按刷盘策略返回 ACK → 后台线程异步构建 ConsumeQueue 和 IndexFile → 刷盘线程推进可靠边界。
消费:消费者拉消息 → 读 ConsumeQueue 拿到 CommitLog 偏移量 → 按偏移量直接去 CommitLog 取消息体(大概率 PageCache 命中,走内存速度)→ 返回消息体。
写的时候全力追加,消费的时候精准定位,两条路完全解耦,互不干扰。
这条链路有三个问题值得想清楚:
一、ACK 到底承诺了什么?
生产者拿到 ACK,不代表消息已经落盘,也不代表消费者立刻能拉到。它承诺的是:这条消息已经进入 CommitLog 的顺序日志(至少在内存映射区域),Broker 会按刷盘策略在后续把它持久化。同步刷盘的 ACK 更强:flushedPosition 已经覆盖这条消息,断电也不会丢。但代价是生产者要等磁盘,延迟更抖。
二、索引落后了怎么办?
ConsumeQueue / IndexFile 是后台异步构建的,写入进度和索引进度是两条不同的线。索引落后时,消费者可见范围会滞后(ConsumeQueue 落后)、按 key 查消息短期查不到(IndexFile 落后)。这两种情况都不影响 CommitLog 本身的正确性,只影响"读的便利性"——你不会丢消息,只是会晚一点看到,或暂时查不到。
三、出了问题,以谁为准?
永远是 CommitLog。Broker 重启时做两件事:校验 CommitLog 的最后一段,找到最后一条完整消息(截断半条消息);然后重建或校准索引,ConsumeQueue 和 IndexFile 都可以从 CommitLog 重放生成。
索引都是派生物,坏了不心疼;只要 CommitLog 还在,系统就能自我修复。
为什么这么快?最后总结一下
归根结底就三点:
写快:所有消息顺序追加进 CommitLog,磁盘性能拉满。mmap + 零拷贝,写入路径几乎没有多余拷贝。刷盘、建索引全部后台化,生产者完全不等。
读快:ConsumeQueue 定长 20 字节,O(1) 定位。PageCache 把热数据留在内存,消费大概率不碰磁盘。
架构干净:CommitLog 是唯一真相源,所有索引都是派生出来的。坏了可以重建,落后了可以追,职责单一,既好维护又容易扩展。
快,不是因为做了很多事,而是因为在主路径上刻意"不做很多事"。
- 写入主路径只干一件事:顺序追加 CommitLog,其它(索引、刷盘)尽量后台化
- 读取主路径只干两件事:ConsumeQueue 精准定位 + CommitLog 按偏移读取,尽量命中 PageCache
- 所有复杂性都围绕一个目标:保证 CommitLog 可恢复,让索引可以派生、可以落后、可以重建
你把这套心智模型抓住,再回头看 RocketMQ 存储相关的源码、参数、故障现象,会顺很多。