Files
cs-note/hhs/MQ/04-存储引擎/10-RocketMQ-CommitLog.md
T
2026-06-08 23:08:57 +08:00

214 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<br/>0000000000"]
MFQ --> MF2["MappedFile 1<br/>0000000001"]
MFQ --> MF3["MappedFile 2 (Active)<br/>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 存储设计]]