vault backup: 2026-06-08 23:08:57
This commit is contained in:
@@ -51,24 +51,91 @@ ConsumeQueue 是 CommitLog 和 Consumer 之间的桥梁。每个 Topic 的每个
|
||||
| 字段 | 大小 | 说明 |
|
||||
|------|------|------|
|
||||
| CommitLog Offset | 8 字节 | 消息在 CommitLog 中的物理偏移 |
|
||||
| Size | 4 字节 | 消息长度 |
|
||||
| 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:通过 MessageKey 的哈希值定位到槽位(Slot),每个槽位指向一个链表,链表节点存储消息的 CommitLog Offset 和时间戳。
|
||||
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 实例。
|
||||
RocketMQ 使用 `MappedFile` 抽象封装了 mmap(内存映射文件)操作。每个 CommitLog / ConsumeQueue / IndexFile 文件都对应一个 MappedFile 实例。多个 MappedFile 由 `MappedFileQueue` 统一管理,形成一个逻辑上连续的大文件。
|
||||
|
||||
mmap 的核心优势:将磁盘文件映射到进程虚拟内存空间,写入时直接操作内存,由操作系统内核负责异步刷盘。相比 `write()` 系统调用,mmap 减少了一次内核态到用户态的数据拷贝,写入性能更高。
|
||||
**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 写入逻辑
|
||||
@@ -98,12 +165,18 @@ func (cl *CommitLog) PutMessage(msg *Message) error {
|
||||
|
||||
### 6. 同步/异步刷盘与主从同步
|
||||
|
||||
**刷盘策略**决定了消息写入 MappedFile(内存)后何时真正持久化到磁盘:
|
||||
**刷盘策略**决定了消息写入 MappedFile(内存)后何时真正持久化到磁盘。RocketMQ 内部有两个核心刷盘实现类:
|
||||
|
||||
- **同步刷盘(SYNC_FLUSH)**:写入后立即调用 `fsync`,数据安全性高,但吞吐较低。适合对可靠性要求极高的场景(如金融交易)。
|
||||
- **异步刷盘(ASYNC_FLUSH)**:由后台 FlushCommitLogService 线程定时刷盘,吞吐高但宕机时可能丢失少量消息。适合大多数业务场景。
|
||||
| 刷盘策略 | 实现类 | 行为 | 适用场景 |
|
||||
|----------|--------|------|----------|
|
||||
| 同步刷盘(SYNC_FLUSH) | `GroupCommitService` | 写入后阻塞等待 `fsync` 完成才返回 ACK | 金融交易等对可靠性要求极高的场景 |
|
||||
| 异步刷盘(ASYNC_FLUSH) | `FlushCommitLogService` | 后台线程定时(默认 500ms)刷盘,写入 Page Cache 即返回 | 大多数业务场景,吞吐优先 |
|
||||
|
||||
**主从同步**方面,RocketMQ 4.x 引入了基于 Raft 协议的 **DLedger** 模式替代传统的 Master-Slave 异步复制。DLedger 要求消息写入多数节点后才算成功,从根本上解决了主从异步复制时 Master 宕机丢数据的问题。
|
||||
> [!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
|
||||
@@ -119,6 +192,19 @@ graph LR
|
||||
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 存储引擎设计]]
|
||||
|
||||
Reference in New Issue
Block a user