---
tags: [MQ, RocketMQ, 存储引擎, CommitLog]
create time: 2026-05-24 19:52
---
# RocketMQ CommitLog 三层存储模型
## 概述
RocketMQ 采用 CommitLog + ConsumeQueue + IndexFile 三层存储架构,将所有 Topic 的消息统一追加写入同一个 CommitLog 文件,再通过异步构建的索引文件实现高效消费和查询。这种设计牺牲了一定的读取局部性,但换来了极致的写入吞吐和灵活的消息查询能力。
## 正文
### 1. 三层存储模型总览
RocketMQ 的存储核心是三个文件的协作:
- **CommitLog**:消息的物理存储,所有 Topic 的消息统一追加写入。
- **ConsumeQueue**:消费队列索引,每个 Topic-Queue 维护一个轻量级索引文件,存储消息在 CommitLog 中的位置。
- **IndexFile**:哈希索引,支持按 MessageKey 或时间范围查询消息。
```mermaid
graph TD
Producer["Producer 发送消息"] --> CommitLog["CommitLog: 顺序追加写入"]
CommitLog -->|"异步构建索引"| CQ["ConsumeQueue: 按 Topic-Queue 组织"]
CommitLog -->|"异步构建索引"| Index["IndexFile: 按 MessageKey 哈希"]
CQ -->|"顺序读取索引"| Consumer["Consumer 消费消息"]
Index -->|"按 Key 查询"| Query["消息回溯/查询"]
style CommitLog fill:#4A90D9,color:#fff
style CQ fill:#F5A623,color:#fff
style Index fill:#6EC1E0,color:#fff
```
### 2. CommitLog:万物皆追加
CommitLog 是 RocketMQ 存储的灵魂。所有 Topic、所有 Queue 的消息,不分青红皂白,全部顺序追加到同一个文件(或一组分片文件)中。
每个 CommitLog 文件默认 1GB,写满后自动切换到下一个文件。单条消息的存储格式包括:消息长度、魔数(MagicCode)、CRC 校验、Body、Properties 等字段。
> [!question] 为什么 RocketMQ 要把所有 Topic 写到同一个 CommitLog,而不是像 Kafka 那样按 Partition 分开存储?
>
> 核心原因是**磁盘顺序写**。机械硬盘的顺序写性能接近 SSD,但随机写性能极差。Kafka 按 Partition 分文件,当 Topic 数量很多时(几千甚至上万),每个 Partition 都要维护独立的文件句柄,大量小文件同时写入会导致磁盘随机 I/O 激增。RocketMQ 把所有消息塞进同一个 CommitLog,不管有多少 Topic,写入永远是**单一文件的顺序追加**,在 Topic 数量爆炸的场景下优势明显。
>
> 代价是什么?Consumer 读取时需要先查 ConsumeQueue 拿到 offset,再去 CommitLog 随机读——多了一次寻址。但读操作通常由 Page Cache 命中,实际影响不大。
### 3. ConsumeQueue:消费的桥梁
ConsumeQueue 是 CommitLog 和 Consumer 之间的桥梁。每个 Topic 的每个 Queue 对应一个 ConsumeQueue 文件,每条记录固定 20 字节:
| 字段 | 大小 | 说明 |
|------|------|------|
| CommitLog Offset | 8 字节 | 消息在 CommitLog 中的物理偏移 |
| Size | 4 字节 | 消息在 CommitLog 中的存储大小(含消息头 + 消息体) |
| Tag HashCode | 8 字节 | 消息 Tag 的哈希值,用于过滤 |
Consumer 拉取消息时,先读 ConsumeQueue 获取 offset 列表,再根据 offset 去 CommitLog 读取完整消息。由于 ConsumeQueue 的条目是定长的,可以直接通过下标计算偏移量进行随机读取,效率很高。
Tag 过滤也是在 ConsumeQueue 层完成的:Consumer 携带订阅的 Tag 哈希,Broker 遍历 ConsumeQueue 条目时先比对 Tag HashCode,不匹配的直接跳过,避免了读取 CommitLog 的开销。
> [!question] ConsumeQueue 只存了 Tag 的哈希值,如果两个不同的 Tag 碰撞到了同一个 HashCode,会发生什么?
>
> 这确实是一个**哈希碰撞**的场景。ConsumeQueue 的 Tag 过滤只是一个**粗过滤(Bloom Filter 思想)**:不匹配的一定跳过,匹配的还需要到 CommitLog 中读取完整消息再做精确的 Tag 字符串比对。因此碰撞不会导致消息丢失,只会少量增加无效的 CommitLog 读取,实际上概率极低。
**ConsumeQueue 文件结构**:每个 ConsumeQueue 文件由一个 **32 字节的固定文件头** 和后续的定长条目组成。文件头记录了该队列的元信息:
| 字段 | 大小 | 说明 |
|------|------|------|
| MinPhysicOffset | 8 字节 | 当前 CQ 引用的最小 CommitLog 物理偏移 |
| MinLogicOffset | 8 字节 | 当前 CQ 的最小逻辑偏移(用于计算条目下标) |
| IndexCount | 4 字节 | 当前已写入的条目总数 |
每个 ConsumeQueue 文件默认存储约 30 万个条目(约 5.72 MB),写满后自动滚动到下一个文件。
**消费进度(ConsumerOffset)**:Consumer 的消费进度并不是存在 ConsumeQueue 里,而是由 Broker 端的 `ConsumerOffsetManager` 单独管理,持久化到 `$HOME/store/config/consumerOffset.json`。进度的 key 是 `Topic@ConsumerGroup@QueueId`,value 是已消费到的 ConsumeQueue 逻辑偏移量。这与 Kafka 将 offset 存到 `__consumer_offsets` Topic 的设计形成了有趣的对比。
### 4. IndexFile:按 Key 查消息
IndexFile 是可选的哈希索引文件,结构类似 HashMap,每个文件默认大小为 400MB,由三部分组成:
| 区域 | 大小 | 说明 |
|------|------|------|
| IndexHeader | 40 字节 | 文件元信息:beginTimestamp、endTimestamp、beginPhyOffset、endPhyOffset、hashSlotCount、indexCount |
| SlotTable | 500万 × 4 字节 | 每个槽位存储该哈希桶中**最新一条**索引条目的编号(逻辑下标) |
| IndexArea | 2000万 × 20 字节 | 索引条目区,每条 20 字节:keyHash(4B) + phyOffset(8B) + timeDiff(4B) + slotValue(4B) |
查找流程:对 MessageKey 取哈希 → 在 SlotTable 中定位槽位 → 拿到该槽位最新条目的编号 → 在 IndexArea 中沿链表(`slotValue` 指向前一条同哈希条目)遍历比对 → 找到匹配的 CommitLog 偏移量。
```mermaid
graph LR
Key["MessageKey"] -->|"hash % 5000000"| Slot["SlotTable 槽位"]
Slot -->|"最新条目编号"| Entry3["IndexArea 条目 3"]
Entry3 -->|"slotValue"| Entry1["IndexArea 条目 1"]
Entry1 -->|"slotValue = 0"| End["链表结束"]
style Key fill:#4A90D9,color:#fff
style Slot fill:#F5A623,color:#fff
style Entry3 fill:#6EC1E0,color:#fff
style Entry1 fill:#6EC1E0,color:#fff
```
> [!question] SlotTable 每个槽位只存一个编号,如果同一个哈希桶有多条消息,怎么找到它们?
>
> 这就是链表的设计:每个 IndexArea 条目的 `slotValue` 字段存的是**前一条同哈希消息的条目编号**。新消息写入时,先读取当前槽位的编号作为前驱,写入新条目后把新条目的编号更新到槽位。这是一个**头插法链表**,查找时从最新往最旧遍历。
这使得 RocketMQ 支持按 MessageKey 精确查询消息,也支持按时间范围回溯消息——在排查问题、重放历史消息时非常有用。
> [!tip] 实用建议
> IndexFile 是可选的。如果业务不需要按 Key 查询消息(大多数纯消费场景不需要),可以关闭索引构建以减少 I/O 开销。
### 5. MappedFile 与 mmap
RocketMQ 使用 `MappedFile` 抽象封装了 mmap(内存映射文件)操作。每个 CommitLog / ConsumeQueue / IndexFile 文件都对应一个 MappedFile 实例。多个 MappedFile 由 `MappedFileQueue` 统一管理,形成一个逻辑上连续的大文件。
**mmap 的核心优势**:将磁盘文件映射到进程虚拟内存空间后,写入操作直接操作 Page Cache 中的内存页,由操作系统内核负责异步刷盘。相比 `write()` 系统调用需要将用户空间缓冲区的数据 **CPU 拷贝**到内核空间,mmap 的写入直接落到了 Page Cache,省去了这次拷贝——写入性能更高。
**MappedFileQueue 管理机制**:
```mermaid
graph LR
MFQ["MappedFileQueue"] --> MF1["MappedFile 0
0000000000"]
MFQ --> MF2["MappedFile 1
0000000001"]
MFQ --> MF3["MappedFile 2 (Active)
0000000002"]
MF1 -->|"写满,只读"| RO["不可写入"]
MF2 -->|"写满,只读"| RO
MF3 -->|"当前写入"| WR["可追加写入"]
style MFQ fill:#4A90D9,color:#fff
style MF3 fill:#2ECC71,color:#fff
style MF1 fill:#999,color:#fff
style MF2 fill:#999,color:#fff
```
MappedFileQueue 维护所有 MappedFile 的有序列表,`GetLastMappedFile()` 始终返回当前活跃的(正在写入的)文件。当活跃文件写满时,自动创建新的 MappedFile 并追加到队列尾部。
> [!tip] 内存锁定(mlock)
> 在生产环境中,建议通过 `mlockall()` 将进程的内存页锁定,防止操作系统在内存紧张时将 Page Cache 换出到 swap。一旦 mmap 映射的内存页被 swap 到磁盘,读写操作会触发严重的缺页中断,导致性能断崖式下降。RocketMQ 提供了 `lockInMmap` 配置项来启用此特性。
```go
// 简化版 CommitLog 写入逻辑
func (cl *CommitLog) PutMessage(msg *Message) error {
// 1. 序列化消息为字节数组
data := serialize(msg)
// 2. 在 MappedFile 当前写入位置追加数据(内存映射写入)
mappedFile := cl.mappedFileQueue.GetLastMappedFile()
offset := mappedFile.GetCurrentWritePosition()
mappedFile.AppendMessage(data)
// 3. 根据刷盘策略决定何时落盘
if cl.flushPolicy == SyncFlush {
mappedFile.Flush() // 同步刷盘:阻塞等待数据写入磁盘
}
// 异步刷盘则由后台线程定时执行,不阻塞写入
// 4. 异步构建 ConsumeQueue 和 IndexFile 索引
cl.reputService.BuildIndex(msg, offset)
return nil
}
```
这段代码体现了 CommitLog 写入的核心流程:序列化 -> 追加到 MappedFile -> 刷盘 -> 异步建索引。整个过程是单文件顺序写,没有锁竞争,吞吐极高。
### 6. 同步/异步刷盘与主从同步
**刷盘策略**决定了消息写入 MappedFile(内存)后何时真正持久化到磁盘。RocketMQ 内部有两个核心刷盘实现类:
| 刷盘策略 | 实现类 | 行为 | 适用场景 |
|----------|--------|------|----------|
| 同步刷盘(SYNC_FLUSH) | `GroupCommitService` | 写入后阻塞等待 `fsync` 完成才返回 ACK | 金融交易等对可靠性要求极高的场景 |
| 异步刷盘(ASYNC_FLUSH) | `FlushCommitLogService` | 后台线程定时(默认 500ms)刷盘,写入 Page Cache 即返回 | 大多数业务场景,吞吐优先 |
> [!question] 同步刷盘时,`GroupCommitService` 是如何做到"批量高效"的?
>
> `GroupCommitService` 内部使用了**批处理**机制:当多个 Producer 线程几乎同时写入时,它们会被放入一个请求批次。刷盘线程对整个批次执行一次 `fsync`,然后唤醒所有等待的 Producer 线程。这样 N 个并发写入只需要一次磁盘 I/O,显著降低了同步刷盘的性能损耗。
**主从同步**方面,RocketMQ 4.4+ 引入了基于 Raft 协议的 **DLedger** 模式替代传统的 Master-Slave 异步复制。DLedger 要求消息写入多数节点后才算成功,从根本上解决了主从异步复制时 Master 宕机丢数据的问题。RocketMQ 5.0 进一步演进为 **Controller 模式**,将 DLedger 的 Raft 能力集成到 Broker 内部,不再需要独立部署 DLedger 组件,运维更加简洁。
```mermaid
graph LR
Producer["Producer"] -->|"发送消息"| Master["Master Broker"]
Master -->|"同步/异步刷盘"| Disk1["磁盘"]
Master -->|"DLedger Raft 复制"| Slave1["Follower 1"]
Master -->|"DLedger Raft 复制"| Slave2["Follower 2"]
Slave1 -->|"本地刷盘"| Disk2["磁盘"]
Slave2 -->|"本地刷盘"| Disk3["磁盘"]
style Master fill:#4A90D9,color:#fff
style Slave1 fill:#6EC1E0,color:#fff
style Slave2 fill:#6EC1E0,color:#fff
```
### 7. 文件过期清理机制
RocketMQ 的存储文件不会无限增长,有独立的清理线程 `CleanCommitLogService` 定期扫描并删除过期文件。清理触发条件有两个(满足任一即清理):
- **按时间**:文件最后修改时间距今超过 `fileReservedTime`(默认 72 小时)
- **按磁盘空间**:磁盘使用率超过 `diskMaxUsedSpaceRatio`(默认 75%),从最旧文件开始强制删除
清理时的一个关键约束:**不能删除被 ConsumeQueue 引用的 CommitLog 文件**。CleanCommitLogService 会先检查所有 ConsumeQueue 的最小引用偏移量(`MinPhysicOffset`),只有 CommitLog 文件的尾部偏移量小于该值时才能安全删除。ConsumeQueue 和 IndexFile 的清理则由 `CleanConsumeQueueService` 和 `CleanIndexFileService` 以类似逻辑跟进。
> [!question] 如果一个消费者长期不消费,导致 ConsumeQueue 的 MinPhysicOffset 一直很小,旧的 CommitLog 文件无法删除,磁盘满了怎么办?
>
> 这就是为什么 RocketMQ 会监控消费积压情况并发出告警。极端情况下,`diskMaxUsedSpaceRatio` 触发的强制清理机制会丢弃消费者尚未消费的消息来保全系统——宁可丢消息也不能让 Broker 挂掉。
## 关联笔记
- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]]
- [[07-主流MQ对比/24-RocketMQ|RocketMQ]]
- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]]
- [[04-存储引擎/9-Kafka-存储设计|Kafka 存储设计]]