Files
cs-note/hhs/MQ/04-存储引擎/8-MQ-存储引擎设计.md
T
2026-06-08 23:08:57 +08:00

236 lines
11 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]
create time: 2026-05-24 19:52
---
# MQ 存储引擎设计
## 概述
存储引擎是 MQ 性能的基石。消息从生产者发出到被消费者拉取,中间经历的"写入—持久化—读取"全链路,每一步都与存储设计息息相关。本文从磁盘 I/O 模型出发,依次讲解顺序写、零拷贝、Page Cache、批量刷盘、Append-Only 日志等核心原理,帮你建立对高性能消息存储的系统认知。
## 正文
### 为什么存储引擎是 MQ 的性能基石
MQ 的本质是一个"写多读多"的中转站:生产者不断写入消息,消费者不断拉取消息。如果底层存储扛不住写入吞吐,上层的协议优化、集群扩展全是空谈。可以说,**存储引擎的设计直接决定了 MQ 的性能上限**。
> [!question] 思考
> 如果让你从零设计一个 MQ,你会选择把消息存在哪里?关系数据库?Redis?还是直接写文件?
> [!tip] 回答:直接写文件(Append-Only Log)
>
> **关系数据库**(如 MySQL):B+ 树索引面向随机读写优化,每条消息写入都要走索引更新,高并发下页分裂和随机 I/O 是严重瓶颈;消息过期清理需要 VACUUM,产生碎片。写入吞吐通常在万级 TPS,远不及文件顺序写。
>
> **Redis**:全量消息放内存不现实(MQ 消息量通常 TB 级),持久化方案(RDB 丢数据、AOF 本质又是写文件且重写有性能抖动),容量受限,消息堆积易 OOM。适合做热缓存,不适合做核心存储。
>
> **直接写文件**(Kafka、RocketMQ 的选择):顺序写磁头几乎不寻道,HDD 可达 600MB/s;配合零拷贝(`sendfile`)和 Page Cache,热数据读取几乎零磁盘 I/O;稀疏索引 + 偏移量定位,索引开销极低;过期 Segment 直接截断,无碎片。
>
> 一句话:MQ 的读写模式是"追加写 + 偏移量读",和 Append-Only Log 文件天然同构,任何额外的抽象层(B+ 树、内存数据结构)都是在为不需要的能力买单。
### 磁盘顺序写 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)技术
传统网络传输一条消息(`read` + `write`),数据需要经历 **4 次拷贝**和 **2 次上下文切换**:
1. 磁盘 → 内核缓冲区(DMA 拷贝)—— `read` 系统调用触发
2. 内核缓冲区 → 用户空间缓冲区(CPU 拷贝)
3. 用户空间缓冲区 → Socket 缓冲区(CPU 拷贝)—— `write` 系统调用触发
4. Socket 缓冲区 → 网卡(DMA 拷贝)
其中 2 次 CPU 拷贝是纯浪费——应用层根本没有修改数据,只是"搬运"。
`sendfile` 系统调用可以让数据直接从内核缓冲区传到网卡,跳过用户空间的两次 CPU 拷贝,只需要 **2 次 DMA 拷贝 + 1 次上下文切换**。**Kafka 正是利用 sendfile 实现了消费者拉取数据时的零拷贝**——消费者读取消息时,数据从磁盘经 Page Cache 直接到网卡,几乎不消耗 CPU 资源。
```mermaid
graph LR
subgraph "传统拷贝 read+write"
D1["磁盘"] -->|"DMA 1"| K1["内核缓冲区"]
K1 -->|"CPU 拷贝"| U1["用户空间"]
U1 -->|"CPU 拷贝"| S1["Socket 缓冲区"]
S1 -->|"DMA 2"| N1["网卡"]
end
subgraph "零拷贝 sendfile"
D2["磁盘"] -->|"DMA 1"| K2["内核缓冲区"]
K2 -->|"DMA 2"| 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)顺序读取。配合**稀疏索引**(仅记录部分消息的 offset→物理文件位置映射),查找时先定位到稀疏索引区间,再在区间内顺序扫描,维护成本几乎可以忽略。
3. **删除成本低**:过期日志直接截断文件头,不需要像数据库那样做 VACUUM 或标记删除。
### 文件分段(Segment)策略
Append-Only 日志文件不可能无限增长——否则文件越大,索引查找越慢,过期清理也无法"截断"。实际做法是将日志切分为多个固定大小的 **Segment 文件**:
```mermaid
graph LR
A["Segment 000000.log"] -->|"写满"| B["Segment 000001.log"]
B -->|"写满"| C["Segment 000002.log"]
C -->|"写满"| D["Segment 000003.log (Active)"]
style D fill:#2ECC71,color:#fff
```
每个 Segment 对应一个**稀疏索引文件**(如 `000000.index`),记录部分 offset 到物理位置的映射。查找某条消息时:
1. 二分查找定位到目标 Segment
2. 在该 Segment 的稀索引中找到最近的索引项
3. 从该位置开始顺序扫描
> [!question] 思考
> Segment 文件的大小应该如何设定?太小会导致文件数量爆炸,太大会让过期清理的粒度变粗。Kafka 默认 1GB,这个数字是基于什么考虑的?
### 存储写入流程总览
```mermaid
graph TD
P["Producer 发送消息"] --> B["Broker 接收"]
B --> M["消息序列化写入内存缓冲区"]
M --> BQ{"是否达到批量阈值?"}
BQ -->|"否"| M
BQ -->|"是"| SEG["追加写入当前 Active Segment"]
SEG --> FULL{"Segment 写满?"}
FULL -->|"是"| NEW["创建新 Segment + 稀疏索引"]
FULL -->|"否"| F{"刷盘策略?"}
NEW --> 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
import (
"encoding/binary"
"os"
"sync"
)
// 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 设计与实现]]