vault backup: 2026-06-08 23:08:57

This commit is contained in:
hhs
2026-06-08 23:08:57 +08:00
parent d67831afe0
commit 51de196713
136 changed files with 26069 additions and 344 deletions
@@ -18,6 +18,16 @@ 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)是性能瓶颈。但这里有个关键区分:**顺序写**和**随机写**的性能差距是数量级的。
@@ -39,27 +49,29 @@ graph LR
### 零拷贝(Zero-Copy)技术
传统网络传输一条消息,数据需要经历 4 次拷贝和 4 次上下文切换:
传统网络传输一条消息(`read` + `write`),数据需要经历 **4 次拷贝**和 **2 次上下文切换**:
1. 磁盘 → 内核缓冲区(DMA 拷贝)
1. 磁盘 → 内核缓冲区(DMA 拷贝)—— `read` 系统调用触发
2. 内核缓冲区 → 用户空间缓冲区(CPU 拷贝)
3. 用户空间缓冲区 → Socket 缓冲区(CPU 拷贝)
3. 用户空间缓冲区 → Socket 缓冲区(CPU 拷贝)—— `write` 系统调用触发
4. Socket 缓冲区 → 网卡(DMA 拷贝)
`sendfile` 系统调用可以让数据直接从内核缓冲区传到网卡,跳过用户空间的两次拷贝,只需要 2 次 DMA 拷贝 + 1 次上下文切换。**Kafka 正是利用 sendfile 实现了消费者拉取数据时的零拷贝**,这让消费者读取消息几乎不消耗 CPU 资源。
其中 2 次 CPU 拷贝是纯浪费——应用层根本没有修改数据,只是"搬运"。
`sendfile` 系统调用可以让数据直接从内核缓冲区传到网卡,跳过用户空间的两次 CPU 拷贝,只需要 **2 次 DMA 拷贝 + 1 次上下文切换**。**Kafka 正是利用 sendfile 实现了消费者拉取数据时的零拷贝**——消费者读取消息时,数据从磁盘经 Page Cache 直接到网卡,几乎不消耗 CPU 资源。
```mermaid
graph LR
subgraph "传统拷贝"
D1["磁盘"] -->|"DMA"| K1["内核缓冲区"]
K1 -->|"CPU"| U1["用户空间"]
U1 -->|"CPU"| S1["Socket 缓冲区"]
S1 -->|"DMA"| N1["网卡"]
subgraph "传统拷贝 read+write"
D1["磁盘"] -->|"DMA 1"| K1["内核缓冲区"]
K1 -->|"CPU 拷贝"| U1["用户空间"]
U1 -->|"CPU 拷贝"| S1["Socket 缓冲区"]
S1 -->|"DMA 2"| N1["网卡"]
end
subgraph "零拷贝 sendfile"
D2["磁盘"] -->|"DMA"| K2["内核缓冲区"]
K2 -->|"DMA"| N2["网卡"]
D2["磁盘"] -->|"DMA 1"| K2["内核缓冲区"]
K2 -->|"DMA 2"| N2["网卡"]
end
style D1 fill:#F5A623,color:#fff
@@ -97,9 +109,31 @@ MQ 通常不会每条消息都触发一次磁盘 I/O,而是将多条消息在
为什么 MQ 不用数据库(如 MySQL)存储消息?核心原因有三:
1. **写入模式不匹配**:数据库面向"随机读写"优化,B+ 树索引在高并发写入下会成为瓶颈;而 MQ 是纯粹的"顺序追加 + 顺序读取",Append-Only 日志天然适配。
2. **无索引开销**:消息不需要按内容检索,只需要按偏移量(offset)顺序读取,省去了索引维护的代价。
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
@@ -108,8 +142,11 @@ graph TD
B --> M["消息序列化写入内存缓冲区"]
M --> BQ{"是否达到批量阈值?"}
BQ -->|"否"| M
BQ -->|"是"| FS["追加写入磁盘日志文件"]
FS --> F{"刷盘策略?"}
BQ -->|"是"| SEG["追加写入当前 Active Segment"]
SEG --> FULL{"Segment 写满?"}
FULL -->|"是"| NEW["创建新 Segment + 稀疏索引"]
FULL -->|"否"| F{"刷盘策略?"}
NEW --> F
F -->|"同步"| FSYNC["fsync 确保落盘"]
F -->|"异步"| PC["写入 Page Cache 后返回"]
PC --> BG["后台线程定时 fsync"]
@@ -126,6 +163,12 @@ graph TD
下面的代码展示了一个最简的 Append-Only Log 的核心逻辑——顺序追加写入和按偏移量读取。
```go
import (
"encoding/binary"
"os"
"sync"
)
// AppendLog 是一个简化版的顺序写日志存储
type AppendLog struct {
mu sync.Mutex
@@ -181,7 +224,7 @@ func (a *AppendLog) ReadAt(offset int64) ([]byte, error) {
}
```
**核心要点**:写入只做追加(`O_APPEND`),不修改已有数据;读取通过偏移量直接定位,无需索引。这就是 Kafka、RocketMQ 存储引擎的最简原型。
**核心要点**:写入只做追加(`O_APPEND`),不修改已有数据;读取通过偏移量直接定位,配合稀疏索引即可快速查找。这就是 Kafka、RocketMQ 存储引擎的最简原型。
## 关联笔记