vault backup: 2026-05-24 20:51:06
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
---
|
||||
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 字节 | 消息长度 |
|
||||
| Tag HashCode | 8 字节 | 消息 Tag 的哈希值,用于过滤 |
|
||||
|
||||
Consumer 拉取消息时,先读 ConsumeQueue 获取 offset 列表,再根据 offset 去 CommitLog 读取完整消息。由于 ConsumeQueue 的条目是定长的,可以直接通过下标计算偏移量进行随机读取,效率很高。
|
||||
|
||||
Tag 过滤也是在 ConsumeQueue 层完成的:Consumer 携带订阅的 Tag 哈希,Broker 遍历 ConsumeQueue 条目时先比对 Tag HashCode,不匹配的直接跳过,避免了读取 CommitLog 的开销。
|
||||
|
||||
### 4. IndexFile:按 Key 查消息
|
||||
|
||||
IndexFile 是可选的哈希索引文件,结构类似 HashMap:通过 MessageKey 的哈希值定位到槽位(Slot),每个槽位指向一个链表,链表节点存储消息的 CommitLog Offset 和时间戳。
|
||||
|
||||
这使得 RocketMQ 支持按 MessageKey 精确查询消息,也支持按时间范围回溯消息——在排查问题、重放历史消息时非常有用。
|
||||
|
||||
### 5. MappedFile 与 mmap
|
||||
|
||||
RocketMQ 使用 `MappedFile` 抽象封装了 mmap(内存映射文件)操作。每个 CommitLog / ConsumeQueue / IndexFile 文件都对应一个 MappedFile 实例。
|
||||
|
||||
mmap 的核心优势:将磁盘文件映射到进程虚拟内存空间,写入时直接操作内存,由操作系统内核负责异步刷盘。相比 `write()` 系统调用,mmap 减少了一次内核态到用户态的数据拷贝,写入性能更高。
|
||||
|
||||
```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(内存)后何时真正持久化到磁盘:
|
||||
|
||||
- **同步刷盘(SYNC_FLUSH)**:写入后立即调用 `fsync`,数据安全性高,但吞吐较低。适合对可靠性要求极高的场景(如金融交易)。
|
||||
- **异步刷盘(ASYNC_FLUSH)**:由后台 FlushCommitLogService 线程定时刷盘,吞吐高但宕机时可能丢失少量消息。适合大多数业务场景。
|
||||
|
||||
**主从同步**方面,RocketMQ 4.x 引入了基于 Raft 协议的 **DLedger** 模式替代传统的 Master-Slave 异步复制。DLedger 要求消息写入多数节点后才算成功,从根本上解决了主从异步复制时 Master 宕机丢数据的问题。
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]]
|
||||
- [[07-主流MQ对比/24-RocketMQ|RocketMQ]]
|
||||
- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]]
|
||||
- [[04-存储引擎/9-Kafka-存储设计|Kafka 存储设计]]
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
tags: [MQ, RabbitMQ, 存储引擎, Erlang]
|
||||
create time: 2026-05-24 19:52
|
||||
---
|
||||
|
||||
# RabbitMQ 消息存储
|
||||
|
||||
## 概述
|
||||
|
||||
RabbitMQ 基于 Erlang/OTP 平台构建,消息存储依赖 Erlang 进程模型和 Mnesia 分布式数据库。本文深入分析 RabbitMQ 的消息持久化机制、队列类型演进(经典队列、仲裁队列、流式队列)、内存管理策略,以及消息从写入到删除的完整生命周期。
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 存储架构:Erlang 进程 + Mnesia
|
||||
|
||||
RabbitMQ 的每个 Queue 本质上是一个 Erlang 进程(gen_server),拥有独立的 mailbox 和状态。这种设计天然隔离了不同队列的故障,但也意味着每个队列的资源消耗(内存、调度时间)受到 Erlang 虚拟机(BEAM)的约束。
|
||||
|
||||
持久化元数据(Exchange 定义、Binding 关系、用户权限等)存储在 **Mnesia** 分布式数据库中。Mnesia 是 Erlang 原生的数据库,支持事务和分布式复制,但它的设计目标是存储小量元数据,而非大规模消息数据。实际的消息体存储由各队列进程自行管理。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Producer["Producer"] -->|"AMQP 发布"| Exchange["Exchange"]
|
||||
Exchange -->|"路由规则"| Binding["Binding"]
|
||||
Binding --> Queue1["Queue 进程 1"]
|
||||
Binding --> Queue2["Queue 进程 2"]
|
||||
Queue1 -->|"持久化写入"| MsgStore["消息存储"]
|
||||
Queue1 -->|"内存缓存"| Memory["进程内存"]
|
||||
MsgStore --> Disk["磁盘文件"]
|
||||
Queue1 --> Consumer["Consumer"]
|
||||
|
||||
style Exchange fill:#4A90D9,color:#fff
|
||||
style Queue1 fill:#F5A623,color:#fff
|
||||
style Queue2 fill:#F5A623,color:#fff
|
||||
style MsgStore fill:#6EC1E0,color:#fff
|
||||
```
|
||||
|
||||
### 2. 消息持久化机制
|
||||
|
||||
RabbitMQ 中消息要真正持久化,需要同时满足两个条件:
|
||||
|
||||
- **Queue 声明为 durable**:队列的元数据在 Broker 重启后保留。
|
||||
- **Message 的 delivery mode 设为 2(persistent)**:消息体写入磁盘。
|
||||
|
||||
> [!question] durable 和 persistent 有什么区别?
|
||||
>
|
||||
> `durable` 是队列的属性,控制的是"队列本身在重启后是否还存在";`persistent` 是消息的属性,控制的是"这条消息是否需要写入磁盘"。两者必须同时设置才能实现真正的持久化。如果只有 durable 没有 persistent,队列重启后还在,但里面的消息会丢失;反过来则队列重启后直接消失,消息也就无从谈起了。
|
||||
|
||||
消息写入磁盘的时机并非立即的。RabbitMQ 使用一种"惰性"写入策略:消息先写入内存,当满足一定条件(如内存压力达到阈值、或显式 flush)时才批量刷入磁盘。这意味着在极端情况下(如 Broker 突然崩溃),少量消息可能丢失。
|
||||
|
||||
### 3. Queue 类型演进
|
||||
|
||||
RabbitMQ 3.8+ 引入了三种队列类型,各自面向不同的可靠性与性能需求:
|
||||
|
||||
**经典队列(Classic Queue)**是 RabbitMQ 最早的队列实现。消息存储在 Erlang Mnesia 表中(实际上从 3.x 开始使用自定义的文件存储引擎),支持可选持久化。单 Master 架构,不支持复制。在消息堆积时性能急剧下降,因为队列进程需要维护一个按 offset 索引的磁盘文件结构,大量消息会导致 GC 压力和 I/O 放大。
|
||||
|
||||
**仲裁队列(Quorum Queue)**基于 Raft 共识协议,每个队列在多个节点上维护副本(奇数个,默认 3 个)。消息写入需要多数节点确认,天然保证了数据安全。底层使用 WAL(Write-Ahead Log)顺序写盘,性能在大量消息堆积时保持稳定。
|
||||
|
||||
**流式队列(Stream)**是 3.9 引入的新类型,借鉴了 Kafka 的设计思想。消息以 append-only log 形式存储,支持多消费者独立读取(非竞争消费),消息不会被消费后删除,支持回溯重放。适合事件驱动、审计日志等场景。
|
||||
|
||||
| 特性 | Classic Queue | Quorum Queue | Stream |
|
||||
|------|:---:|:---:|:---:|
|
||||
| 复制 | 不支持 | Raft 多副本 | 可选 |
|
||||
| 持久化 | 可选 | 必须 | 必须 |
|
||||
| 消费后删除 | 是 | 是 | 否 |
|
||||
| 消息回溯 | 不支持 | 不支持 | 支持 |
|
||||
| 适合场景 | 简单队列 | 高可靠 | 事件流 |
|
||||
|
||||
> [!question] 经典队列在大量消息堆积时性能为什么会急剧下降?
|
||||
>
|
||||
> 经典队列的存储引擎在消息堆积时面临两个瓶颈:一是 Erlang 进程的 mailbox 膨胀导致调度延迟增大,GC 暂停时间变长;二是磁盘存储使用了按消息 ID 索引的文件结构,大量随机 I/O 使得写入性能断崖式下跌。仲裁队列通过 WAL 顺序写解决了 I/O 问题,通过 Raft 副本机制分散了单节点压力,从而在堆积场景下保持稳定。
|
||||
|
||||
### 4. Lazy Queue:磁盘优先策略
|
||||
|
||||
Lazy Queue 是经典队列的一种特殊模式,将消息尽可能早地写入磁盘,只在消费者需要时才加载到内存。适合消息堆积量大但消费速度跟不上的场景。
|
||||
|
||||
配置方式:声明队列时设置 `x-queue-mode: lazy`。在 Quorum Queue 中,由于 WAL 本身就是顺序写磁盘的,不需要额外的 Lazy 模式。
|
||||
|
||||
### 5. 内存管理:告警、流控与信用机制
|
||||
|
||||
RabbitMQ 的内存管理是一个三层防护体系:
|
||||
|
||||
**内存告警(Memory Alarm)**:当节点内存使用超过阈值(默认物理内存的 40%),Broker 触发内存告警,阻塞所有生产者的消息发布,直到内存回落到安全水位。
|
||||
|
||||
**流控(Flow Control)**:当某个 Erlang 进程(如队列进程)的消息积压超过一定阈值,会主动向 TCP 连接进程发送 `pause` 信号,让生产者的 TCP 接收窗口降为 0,从而在协议层面实现背压。
|
||||
|
||||
**信用机制(Credit Flow)**:这是 Erlang 进程间的流控机制。每个进程维护一个 credit 计数器,消息传递消耗 credit,消费端处理完毕后归还 credit。当 credit 归零时,发送端阻塞,防止过快地往下游进程灌消息。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Producer["Producer"] -->|"发送消息"| Conn["连接进程"]
|
||||
Conn -->|"credit 检查"| Queue["队列进程"]
|
||||
Queue -->|"内存检测"| MemCheck{"内存超阈值?"}
|
||||
MemCheck -->|"是"| Alarm["触发内存告警"]
|
||||
Alarm -->|"阻塞生产"| Producer
|
||||
MemCheck -->|"否"| Process["正常处理"]
|
||||
Process --> Consumer["Consumer"]
|
||||
|
||||
style Alarm fill:#D0021B,color:#fff
|
||||
style Queue fill:#F5A623,color:#fff
|
||||
```
|
||||
|
||||
### 6. 消息的生命周期:写入与删除
|
||||
|
||||
消息从进入 RabbitMQ 到最终消失,经历以下阶段:
|
||||
|
||||
1. **写入内存**:消息到达队列进程后,先存入进程内存(Erlang ETS 表或进程状态)。
|
||||
2. **写入磁盘**(如果 persistent):当内存压力达到一定阈值,或消息被标记为 persistent 时,消息体被写入磁盘文件。
|
||||
3. **投递消费者**:消息从内存中读取并发送给消费者。
|
||||
4. **等待 ACK**:消息在未收到 ACK 前不会被删除。
|
||||
5. **收到 ACK 后删除**:消息从内存和磁盘中移除。对于持久化消息,磁盘文件中的标记被清除,空间在文件 compaction 时回收。
|
||||
|
||||
### 7. Go 代码示例
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
conn, _ := amqp.Dial("amqp://guest:guest@localhost:5672/")
|
||||
defer conn.Close()
|
||||
|
||||
ch, _ := conn.Channel()
|
||||
defer ch.Close()
|
||||
|
||||
// 声明持久化队列:durable = true, autoDelete = false, exclusive = false
|
||||
ch.QueueDeclare("order_events", true, false, false, false, amqp.Table{
|
||||
"x-queue-type": "quorum", // 使用仲裁队列,替代默认的经典队列
|
||||
})
|
||||
|
||||
// 发布持久化消息:DeliveryMode = Persistent
|
||||
ch.Publish("", "order_events", false, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent, // 消息持久化
|
||||
Body: []byte(`{"order_id":"12345","status":"created"}`),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
上面的代码展示了两个关键点:一是 `QueueDeclare` 设置 `durable: true` 并指定 `x-queue-type: quorum` 使用仲裁队列;二是 `Publishing` 设置 `DeliveryMode: amqp.Persistent` 确保消息持久化。两者缺一不可。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[07-主流MQ对比/23-RabbitMQ|RabbitMQ]]
|
||||
- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]]
|
||||
- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]]
|
||||
- [[03-协议与标准/5-AMQP-协议|AMQP 协议]]
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
tags: [MQ]
|
||||
create time: 2026-05-24 19:52
|
||||
---
|
||||
|
||||
# MQ 存储引擎设计
|
||||
|
||||
## 概述
|
||||
|
||||
存储引擎是 MQ 性能的基石。消息从生产者发出到被消费者拉取,中间经历的"写入—持久化—读取"全链路,每一步都与存储设计息息相关。本文从磁盘 I/O 模型出发,依次讲解顺序写、零拷贝、Page Cache、批量刷盘、Append-Only 日志等核心原理,帮你建立对高性能消息存储的系统认知。
|
||||
|
||||
## 正文
|
||||
|
||||
### 为什么存储引擎是 MQ 的性能基石
|
||||
|
||||
MQ 的本质是一个"写多读多"的中转站:生产者不断写入消息,消费者不断拉取消息。如果底层存储扛不住写入吞吐,上层的协议优化、集群扩展全是空谈。可以说,**存储引擎的设计直接决定了 MQ 的性能上限**。
|
||||
|
||||
> [!question] 思考
|
||||
> 如果让你从零设计一个 MQ,你会选择把消息存在哪里?关系数据库?Redis?还是直接写文件?
|
||||
|
||||
### 磁盘顺序写 vs 随机写
|
||||
|
||||
传统认知里,磁盘(尤其是机械硬盘 HDD)是性能瓶颈。但这里有个关键区分:**顺序写**和**随机写**的性能差距是数量级的。
|
||||
|
||||
- **随机写**:磁头需要反复寻道,HDD 的 IOPS 通常只有 100-200 次/秒。
|
||||
- **顺序写**:磁头几乎不需要移动,HDD 顺序写吞吐可达 600MB/s 以上,甚至媲美 SATA SSD 的顺序写性能。
|
||||
|
||||
这就是为什么 Kafka、RocketMQ 等高性能 MQ 都选择了**顺序写盘**的策略——即使是廉价的 HDD 也能获得极高的写入吞吐。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["Producer"] -->|"顺序追加写入"| B["磁盘文件"]
|
||||
B -->|"Consumer 按偏移量顺序读取"| C["Consumer"]
|
||||
|
||||
style A fill:#4A90D9,color:#fff
|
||||
style B fill:#F5A623,color:#fff
|
||||
style C fill:#6EC1E0,color:#fff
|
||||
```
|
||||
|
||||
### 零拷贝(Zero-Copy)技术
|
||||
|
||||
传统网络传输一条消息,数据需要经历 4 次拷贝和 4 次上下文切换:
|
||||
|
||||
1. 磁盘 → 内核缓冲区(DMA 拷贝)
|
||||
2. 内核缓冲区 → 用户空间缓冲区(CPU 拷贝)
|
||||
3. 用户空间缓冲区 → Socket 缓冲区(CPU 拷贝)
|
||||
4. Socket 缓冲区 → 网卡(DMA 拷贝)
|
||||
|
||||
`sendfile` 系统调用可以让数据直接从内核缓冲区传到网卡,跳过用户空间的两次拷贝,只需要 2 次 DMA 拷贝 + 1 次上下文切换。**Kafka 正是利用 sendfile 实现了消费者拉取数据时的零拷贝**,这让消费者读取消息几乎不消耗 CPU 资源。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "传统拷贝"
|
||||
D1["磁盘"] -->|"DMA"| K1["内核缓冲区"]
|
||||
K1 -->|"CPU"| U1["用户空间"]
|
||||
U1 -->|"CPU"| S1["Socket 缓冲区"]
|
||||
S1 -->|"DMA"| N1["网卡"]
|
||||
end
|
||||
|
||||
subgraph "零拷贝 sendfile"
|
||||
D2["磁盘"] -->|"DMA"| K2["内核缓冲区"]
|
||||
K2 -->|"DMA"| N2["网卡"]
|
||||
end
|
||||
|
||||
style D1 fill:#F5A623,color:#fff
|
||||
style D2 fill:#F5A623,color:#fff
|
||||
```
|
||||
|
||||
### Page Cache 与内存映射(mmap)
|
||||
|
||||
操作系统内核会自动将最近访问的磁盘数据缓存在 **Page Cache** 中。MQ 的写入和读取天然契合 Page Cache 的工作模式:
|
||||
|
||||
- **写入时**:消息先写入 Page Cache,由操作系统异步刷盘,应用层返回极快。
|
||||
- **读取时**:如果消费者读取的是刚写入的数据(大多数场景),直接从 Page Cache 返回,无需访问磁盘。
|
||||
|
||||
**mmap(内存映射)** 则更进一步:将磁盘文件直接映射到进程的虚拟地址空间,读写文件就像读写内存一样,省去了 `read/write` 系统调用的开销。RocketMQ 的 MappedFile 就是基于 mmap 实现的。
|
||||
|
||||
> [!question] 思考
|
||||
> Page Cache 加速了读写,但如果 Broker 进程崩溃,Page Cache 中还没刷盘的数据会丢失吗?这对"异步刷盘"策略意味着什么?
|
||||
|
||||
### 批量写入与刷盘策略
|
||||
|
||||
MQ 通常不会每条消息都触发一次磁盘 I/O,而是将多条消息在内存中攒批后再统一写入,这就是**批量写入**。刷盘策略则决定了数据何时真正落盘:
|
||||
|
||||
| 策略 | 机制 | 可靠性 | 性能 |
|
||||
|------|------|--------|------|
|
||||
| **同步刷盘** | 每批消息写入后,调用 `fsync` 等待数据落盘才返回 | 高:数据不丢 | 较低:受磁盘 I/O 限制 |
|
||||
| **异步刷盘** | 消息写入 Page Cache 即返回,后台线程定时刷盘 | 较低:宕机可能丢最后几条 | 高:写入延迟极低 |
|
||||
|
||||
> [!question] 思考
|
||||
> 异步刷盘宕机可能丢数据,为什么很多 MQ 还是默认异步刷盘?
|
||||
|
||||
答案在于**实际场景的权衡**:大多数业务场景可以容忍极少量消息丢失(配合重试机制),但无法容忍高延迟。而且异步刷盘配合主从同步(消息复制到其他节点后再返回),可以在性能和可靠性之间找到平衡。只有金融级场景才需要同步刷盘。
|
||||
|
||||
### 日志追加(Append-Only)设计
|
||||
|
||||
为什么 MQ 不用数据库(如 MySQL)存储消息?核心原因有三:
|
||||
|
||||
1. **写入模式不匹配**:数据库面向"随机读写"优化,B+ 树索引在高并发写入下会成为瓶颈;而 MQ 是纯粹的"顺序追加 + 顺序读取",Append-Only 日志天然适配。
|
||||
2. **无索引开销**:消息不需要按内容检索,只需要按偏移量(offset)顺序读取,省去了索引维护的代价。
|
||||
3. **删除成本低**:过期日志直接截断文件头,不需要像数据库那样做 VACUUM 或标记删除。
|
||||
|
||||
### 存储写入流程总览
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
P["Producer 发送消息"] --> B["Broker 接收"]
|
||||
B --> M["消息序列化写入内存缓冲区"]
|
||||
M --> BQ{"是否达到批量阈值?"}
|
||||
BQ -->|"否"| M
|
||||
BQ -->|"是"| FS["追加写入磁盘日志文件"]
|
||||
FS --> F{"刷盘策略?"}
|
||||
F -->|"同步"| FSYNC["fsync 确保落盘"]
|
||||
F -->|"异步"| PC["写入 Page Cache 后返回"]
|
||||
PC --> BG["后台线程定时 fsync"]
|
||||
FSYNC --> ACK["返回 ACK 给 Producer"]
|
||||
BG --> ACK
|
||||
|
||||
style P fill:#4A90D9,color:#fff
|
||||
style B fill:#6EC1E0,color:#fff
|
||||
style ACK fill:#2ECC71,color:#fff
|
||||
```
|
||||
|
||||
### Go 伪代码:一个简单的 Append-Only Log 存储
|
||||
|
||||
下面的代码展示了一个最简的 Append-Only Log 的核心逻辑——顺序追加写入和按偏移量读取。
|
||||
|
||||
```go
|
||||
// AppendLog 是一个简化版的顺序写日志存储
|
||||
type AppendLog struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
offset int64 // 当前写入位置
|
||||
}
|
||||
|
||||
// NewAppendLog 打开或创建日志文件
|
||||
func NewAppendLog(path string) (*AppendLog, error) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 获取当前文件大小作为起始偏移量
|
||||
stat, _ := f.Stat()
|
||||
return &AppendLog{file: f, offset: stat.Size()}, nil
|
||||
}
|
||||
|
||||
// Append 顺序追加一条消息,返回该消息在文件中的偏移量
|
||||
func (a *AppendLog) Append(data []byte) (int64, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
// 写入长度前缀 + 数据(Length-Prefix 编码,方便读取时知道边界)
|
||||
buf := make([]byte, 4+len(data))
|
||||
binary.BigEndian.PutUint32(buf[:4], uint32(len(data)))
|
||||
copy(buf[4:], data)
|
||||
|
||||
n, err := a.file.Write(buf) // 顺序追加,OS 自动利用 Page Cache
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pos := a.offset
|
||||
a.offset += int64(n)
|
||||
return pos, nil // 返回偏移量,消费者后续按此读取
|
||||
}
|
||||
|
||||
// ReadAt 从指定偏移量读取消息(消费者按 offset 拉取)
|
||||
func (a *AppendLog) ReadAt(offset int64) ([]byte, error) {
|
||||
// 读取 4 字节长度头
|
||||
header := make([]byte, 4)
|
||||
if _, err := a.file.ReadAt(header, offset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
length := binary.BigEndian.Uint32(header)
|
||||
|
||||
// 读取消息体
|
||||
data := make([]byte, length)
|
||||
if _, err := a.file.ReadAt(data, offset+4); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
```
|
||||
|
||||
**核心要点**:写入只做追加(`O_APPEND`),不修改已有数据;读取通过偏移量直接定位,无需索引。这就是 Kafka、RocketMQ 存储引擎的最简原型。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[04-存储引擎/9-Kafka-存储设计|Kafka 存储设计]]
|
||||
- [[04-存储引擎/10-RocketMQ-CommitLog|RocketMQ CommitLog]]
|
||||
- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]]
|
||||
- [[10-监控与运维/40-MQ-性能调优|MQ 性能调优]]
|
||||
- [[12-架构与实战/50-MQ-设计与实现|MQ 设计与实现]]
|
||||
@@ -0,0 +1,236 @@
|
||||
---
|
||||
tags: [MQ]
|
||||
create time: 2026-05-24 19:52
|
||||
---
|
||||
|
||||
# Kafka 存储设计
|
||||
|
||||
## 概述
|
||||
|
||||
Kafka 的高吞吐能力不仅来自"顺序写盘"和"零拷贝"等通用优化,更源于其精心设计的存储结构:Partition Log 的分段存储、稀疏索引、日志压缩,以及内部定时任务调度所用的时间轮。本文深入 Kafka 存储层的每一个关键设计,帮你理解"为什么 Kafka 这么快"。
|
||||
|
||||
## 正文
|
||||
|
||||
### Partition Log 的整体结构
|
||||
|
||||
Kafka 的存储层次可以概括为:**Topic → Partition → Segment**。每个 Partition 是一个有序的、不可变的消息序列,底层由多个 Segment 文件组成。
|
||||
|
||||
一个 Partition 目录下的典型文件结构:
|
||||
|
||||
```
|
||||
partition-0/
|
||||
├── 00000000000000000000.log # 第一个 Segment 的消息数据
|
||||
├── 00000000000000000000.index # 稀疏偏移索引
|
||||
├── 00000000000000000000.timeindex # 时间戳索引
|
||||
├── 00000000000000523840.log # 第二个 Segment(起始 offset = 523840)
|
||||
├── 00000000000000523840.index
|
||||
├── 00000000000000523840.timeindex
|
||||
└── ...
|
||||
```
|
||||
|
||||
文件名是该 Segment 的 **base offset**(起始偏移量)。当 Segment 达到配置的大小或时间阈值时,Kafka 会"滚动"出一个新的 Segment。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
T["Topic: orders"] --> P0["Partition 0"]
|
||||
T --> P1["Partition 1"]
|
||||
T --> P2["Partition 2"]
|
||||
|
||||
P0 --> S1["Segment 0\nbase offset = 0"]
|
||||
P0 --> S2["Segment 1\nbase offset = 523840"]
|
||||
P0 --> S3["Segment 2\nbase offset = 1048576"]
|
||||
|
||||
S1 --> L1["00000.log"]
|
||||
S1 --> I1["00000.index"]
|
||||
S1 --> TI1["00000.timeindex"]
|
||||
|
||||
S2 --> L2["523840.log"]
|
||||
S2 --> I2["523840.index"]
|
||||
S2 --> TI2["523840.timeindex"]
|
||||
|
||||
style T fill:#4A90D9,color:#fff
|
||||
style P0 fill:#6EC1E0,color:#fff
|
||||
style P1 fill:#6EC1E0,color:#fff
|
||||
style P2 fill:#6EC1E0,color:#fff
|
||||
```
|
||||
|
||||
> [!question] 思考
|
||||
> 为什么不把整个 Partition 写成一个巨大的日志文件,而要拆分成多个 Segment?
|
||||
|
||||
### 分段存储策略
|
||||
|
||||
Segment 滚动的触发条件有两个(满足任一即滚动):
|
||||
|
||||
- **按大小**:默认 1GB(`log.segment.bytes`)
|
||||
- **按时间**:默认 7 天(`log.roll.ms` / `log.roll.hours`)
|
||||
|
||||
分段的好处是多方面的:
|
||||
|
||||
1. **快速定位**:消费者只需根据 offset 找到对应 Segment,再在小文件内查找,而不是在一个 TB 级大文件中扫描。
|
||||
2. **高效清理**:过期数据直接删除整个 Segment 文件,无需逐条标记删除或做 compaction。
|
||||
3. **并行 I/O**:不同 Segment 可以被不同的消费者线程并发读取。
|
||||
|
||||
### 稀疏索引(Sparse Index)
|
||||
|
||||
Kafka 不是每条消息都建索引——那会带来巨大的存储和维护开销。它采用**稀疏索引**:每隔一定字节(默认 4KB,由 `log.index.interval.bytes` 控制)才记录一条索引项,格式为 `offset → 文件物理位置`。
|
||||
|
||||
当消费者需要查找某个 offset 的消息时,流程如下:
|
||||
|
||||
1. 在 `.index` 文件中用**二分查找**找到不大于目标 offset 的最近索引项。
|
||||
2. 从该索引项指向的物理位置开始,在 `.log` 文件中**顺序扫描**直到找到目标 offset。
|
||||
|
||||
因为索引本身是有序的,二分查找效率为 O(log N);而顺序扫描的范围通常只有几条消息(4KB 内),开销极小。这种设计用极少的索引空间换来了接近 O(1) 的查找性能。
|
||||
|
||||
### 日志压缩(Log Compaction)
|
||||
|
||||
普通的消息保留策略是按时间或大小删除过期 Segment。但 Kafka 还提供了另一种策略——**Log Compaction**:保留每个 Key 的最后一条消息,删除之前的旧版本。
|
||||
|
||||
工作原理:
|
||||
|
||||
- Log Compaction 由 Cleaner 线程在后台执行。
|
||||
- Cleaner 为每个 Key 维护一个"最新 offset"映射,只保留最新消息。
|
||||
- 清理后的 Segment 中,每个 Key 只有一条记录。
|
||||
- Consumer 从头消费时,仍然能拿到每个 Key 的最新状态。
|
||||
|
||||
**典型场景**:变更数据捕获(CDC)、状态快照同步。比如数据库的一张用户表,每次更新都发一条消息到 Kafka(Key = userId),下游系统通过 Log Compaction 拿到的就是每个用户的最新状态。
|
||||
|
||||
> [!question] 思考
|
||||
> Log Compaction 保证的是"每个 Key 至少保留最新一条",但如果有两个 Key 相同的消息几乎同时到达,Compaction 会保留哪条?
|
||||
|
||||
### 时间轮(Timing Wheel)
|
||||
|
||||
Kafka 内部有大量的定时任务:延迟消息、会话过期、日志清理等。如果每个定时任务都开一个 goroutine(或 Java 的 Timer),任务数多了之后,调度开销会非常大。
|
||||
|
||||
Kafka 使用**层级时间轮(Hierarchical Timing Wheel)**来高效管理定时任务:
|
||||
|
||||
- 时间轮是一个环形数组,每个槽位(slot)代表一个时间区间。
|
||||
- 新任务根据到期时间插入对应槽位。
|
||||
- 时钟每推进一个 tick,处理当前槽位的所有任务。
|
||||
- 当低层时间轮溢出时,任务会被"滴答"到上层时间轮,等待合适时机再降下来。
|
||||
|
||||
时间轮的插入和删除都是 O(1) 操作,远优于优先队列的 O(log N)。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
TW["时间轮"] --> L1["第一层: 1ms 精度\n20 个槽位"]
|
||||
TW --> L2["第二层: 20ms 精度\n20 个槽位"]
|
||||
TW --> L3["第三层: 400ms 精度\n20 个槽位"]
|
||||
|
||||
L1 --> SLOT1["slot 0: 任务 A"]
|
||||
L1 --> SLOT2["slot 5: 任务 B"]
|
||||
L2 --> SLOT3["slot 3: 任务 C\n溢出后降级到第一层"]
|
||||
|
||||
style TW fill:#4A90D9,color:#fff
|
||||
style L1 fill:#6EC1E0,color:#fff
|
||||
style L2 fill:#F5A623,color:#fff
|
||||
style L3 fill:#D0021B,color:#fff
|
||||
```
|
||||
|
||||
### 页缓存与 Sendfile:读写路径中的内核级优化
|
||||
|
||||
Kafka 的读写路径深度依赖 Linux 内核的两个能力:
|
||||
|
||||
**写入路径**:Producer 发来的消息写入 Page Cache(内存),由内核异步刷盘。应用层不调用 `fsync`,写入延迟极低。多个 Partition 的写入共享 Page Cache,操作系统会自动管理缓存淘汰。
|
||||
|
||||
**读取路径**:Consumer 拉取消息时,Kafka 通过 `sendfile` 系统调用直接将 Page Cache 中的数据传输到网卡,**数据完全不经过用户空间**。这意味着:
|
||||
|
||||
- 没有用户态/内核态的上下文切换
|
||||
- 没有内存拷贝
|
||||
- 大量 Consumer 并发拉取时,CPU 开销几乎不增长
|
||||
|
||||
这就是为什么 Kafka 在普通硬件上就能达到百万级 TPS 的核心秘密——它把操作系统的缓存和网络能力用到了极致。
|
||||
|
||||
> [!question] 思考
|
||||
> 如果 Kafka Broker 的内存足够大,Page Cache 能缓存大量数据。但如果消费者需要回溯到很早的消息(不在 Page Cache 中),会发生什么?性能会下降多少?
|
||||
|
||||
### Go 代码:简化版 Segment 文件的读写
|
||||
|
||||
下面展示一个简化版的 Segment 文件实现,包含日志文件和稀疏索引的写入与查找。
|
||||
|
||||
```go
|
||||
type Segment struct {
|
||||
baseOffset int64
|
||||
logFile *os.File
|
||||
indexFile *os.File
|
||||
index []indexEntry // 内存中的稀疏索引
|
||||
}
|
||||
|
||||
type indexEntry struct {
|
||||
offset int64 // 消息的逻辑偏移量
|
||||
position int32 // 消息在 .log 文件中的物理位置
|
||||
}
|
||||
|
||||
// Write 追加一条消息到 Segment
|
||||
func (s *Segment) Write(offset int64, data []byte) error {
|
||||
// 记录当前写入位置作为物理偏移
|
||||
stat, _ := s.logFile.Stat()
|
||||
pos := stat.Size()
|
||||
|
||||
// Length-Prefix 编码写入日志文件
|
||||
header := make([]byte, 4+len(data))
|
||||
binary.BigEndian.PutUint32(header[:4], uint32(len(data)))
|
||||
copy(header[4:], data)
|
||||
if _, err := s.logFile.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 每隔一定消息数写入一条索引(稀疏索引,不是每条都写)
|
||||
if len(s.index) == 0 || offset-s.index[len(s.index)-1].offset >= 4 {
|
||||
entry := indexEntry{offset: offset, position: int32(pos)}
|
||||
s.index = append(s.index, entry)
|
||||
// 同步写入 .index 文件(简化:每条 12 字节 = 8 offset + 4 position)
|
||||
buf := make([]byte, 12)
|
||||
binary.BigEndian.PutUint64(buf[:8], uint64(offset))
|
||||
binary.BigEndian.PutUint32(buf[8:], uint32(pos))
|
||||
s.indexFile.Write(buf)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read 根据 offset 查找并读取消息
|
||||
func (s *Segment) Read(offset int64) ([]byte, error) {
|
||||
// 第一步:二分查找稀疏索引,找到最近的索引项
|
||||
idx := sort.Search(len(s.index), func(i int) bool {
|
||||
return s.index[i].offset > offset
|
||||
}) - 1
|
||||
|
||||
var startPos int32
|
||||
if idx >= 0 {
|
||||
startPos = s.index[idx].position // 从索引指向的位置开始
|
||||
}
|
||||
|
||||
// 第二步:从 startPos 开始顺序扫描 .log 文件
|
||||
pos := int64(startPos)
|
||||
for {
|
||||
header := make([]byte, 4)
|
||||
if _, err := s.logFile.ReadAt(header, pos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
length := binary.BigEndian.Uint32(header)
|
||||
data := make([]byte, length)
|
||||
s.logFile.ReadAt(data, pos+4)
|
||||
|
||||
// 简化:假设 offset 是连续递增的
|
||||
currentOffset := s.baseOffset + (pos / int64(4+length))
|
||||
if currentOffset == offset {
|
||||
return data, nil
|
||||
}
|
||||
pos += int64(4 + length)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**核心要点**:写入时不是每条消息都建索引(稀疏索引),读取时先二分查找索引再顺序扫描——用极小的索引空间和极少的扫描范围实现高效定位。
|
||||
|
||||
> [!question] 思考
|
||||
> Kafka 的 Segment 为什么要同时维护 .log 和 .index 两个文件?只用 .log 行不行?
|
||||
|
||||
答案是:**理论上行,但实践中代价太大**。如果只用 .log,消费者查找某个 offset 的消息时,必须从 Segment 头部开始顺序扫描,时间复杂度为 O(N)。在消息量巨大的场景下(单 Segment 可达 1GB、数百万条消息),这会严重拖慢消费速度。稀疏索引将查找降为 O(log N) 的二分 + 少量顺序扫描,代价只是每个 Segment 多几 KB 的索引文件,性价比极高。此外,`.timeindex` 文件支持按时间戳查找消息(用于"从某时刻开始消费"的场景),仅靠 `.log` 文件无法高效实现。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]]
|
||||
- [[04-存储引擎/10-RocketMQ-CommitLog|RocketMQ CommitLog]]
|
||||
- [[07-主流MQ对比/22-Kafka|Kafka]]
|
||||
- [[10-监控与运维/40-MQ-性能调优|MQ 性能调优]]
|
||||
- [[12-架构与实战/50-MQ-设计与实现|MQ 设计与实现]]
|
||||
Reference in New Issue
Block a user