从电子到比特,从应用到 NAND Flash,一次讲透磁盘 I/O 的底层真相。
注:文章中引用了 BookKeeper 的实现案例,部分代码示例为 C 语言。
一、全景图:一次 write() 的奇幻漂流
当你在应用程序里写下 write(fd, buf, 100) 时,这 100 字节要经历一段惊心动魄的旅程:
┌──────────────────────────────────────────────────────────────┐
│ 用户空间 (User Space) │
│ │
│ 应用程序: write(fd, buf, 100) │
│ "我就写个 100 字节,应该很简单吧?" │
└──────────────────────┬───────────────────────────────────────┘
│ 系统调用 (syscall)
▼
┌──────────────────────────────────────────────────────────────┐
│ 内核空间 (Kernel Space) │
│ │
│ VFS (虚拟文件系统层) │
│ "我来翻译一下,你要写的是哪个文件系统的哪个文件" │
│ │ │
│ ▼ │
│ Page Cache (页缓存) ←── Buffered I/O 走这里 │
│ "数据先存我这,磁盘的事我找人帮你搞" │ │
│ │ │ │
│ ▼ │ Direct I/O 绕过! │
│ 文件系统 (ext4 / xfs) │ │
│ "我负责把逻辑块映射到物理块" │ │
│ │ │ │
│ ▼ ▼ │
│ Block Layer (块设备层) │
│ "I/O 请求排队、合并、调度,统统归我管" │
│ │ │
└──────────────┼───────────────────────────────────────────────┘
│ NVMe 命令 / SATA 命令
▼
┌──────────────────────────────────────────────────────────────┐
│ 硬件层 │
│ │
│ NVMe 控制器 / SATA 控制器 │
│ "收到指令,往 NAND/磁盘碟片上写" │
│ │ │
│ ▼ │
│ SSD: FTL → NAND Flash 或 HDD: 磁头 → 碟片 │
│ "终于落盘了!" │
└──────────────────────────────────────────────────────────────┘
记住这张图,下面所有内容都是在不同层次上展开的。
二、两条路线:Buffered I/O vs Direct I/O
这是磁盘 I/O 中最核心的分叉路口。
2.1 Buffered I/O(默认路线)
int fd = open("data.log", O_WRONLY | O_CREAT, 0644);
write(fd, buf, 100); // 数据去了 Page Cache,还没到磁盘!
fsync(fd); // 现在才真正刷到磁盘
数据路径:
应用缓冲区 ──copy──→ Page Cache (内核内存) ──writeback──→ 磁盘
特点:
- 数据先写到内核管理的 Page Cache(一块内存区域),
write()就返回了 - 内核在"合适的时候"把脏页刷到磁盘(或者你主动调
fsync) - 对应用友好:不需要对齐,不需要管缓冲区大小,内核帮你搞定一切
- 代价:数据在内存中多了一份拷贝;缓存策略你无法控制
2.2 Direct I/O(硬核路线)
int fd = open("data.log", O_WRONLY | O_CREAT | O_DIRECT, 0644);
// ⚠️ 现在你要自己伺候磁盘了
数据路径:
应用缓冲区 ──DMA──→ 磁盘(绕过 Page Cache!)
特点:
- 数据直接从用户态内存通过 DMA 传输到磁盘控制器
- 不经过 Page Cache,零内核缓存拷贝
- 对应用苛刻:必须满足严格的对齐要求(见下文)
- 优势:应用完全掌控缓存策略,避免"双重缓存"
2.3 对比总结
|-------|----------------------|-------------------------|
| 维度 | Buffered I/O | Direct I/O (O_DIRECT) |
| 数据路径 | 应用 → Page Cache → 磁盘 | 应用 → 磁盘 |
| 内存拷贝 | 1次(用户态 → 内核态) | 0次(DMA 直传) |
| 对齐要求 | 无(内核兜底) | 严格(三重对齐) |
| 缓存控制 | 内核自动管理(你无权干预) | 应用自行管理(你说了算) |
| 写入语义 | write 返回 ≠ 数据落盘 | write 返回 ≈ 数据落盘 |
| 编程难度 | 简单 | 困难 |
| 典型使用者 | 普通应用、脚本 | 数据库(MySQL InnoDB)、分布式存储 |
三、Page Cache:内核的"好意"与"代价"
3.1 Page Cache 是什么?
Page Cache 是 Linux 内核用空闲内存 自动维护的一层磁盘数据缓存。它是 Buffered I/O 的核心。
物理内存布局:
┌───────────────────────────────────────────────────────┐
│ 内核代码和数据 │ 进程内存 │ Page Cache │ 空闲 │
│ (固定) │ (按需) │ (自动伸缩) │ │
└───────────────────────────────────────────────────────┘
↑
内核自动用空闲内存做缓存
内存紧张时自动回收
3.2 最小管理单位:4KB 的 Page
这是理解所有 I/O 行为的基石。
一个 Page = 4096 字节 = 4KB (在大多数 Linux 系统上)
Page Cache 的索引方式:
Key = (inode 编号, 文件内偏移的页号)
Value = 一个 4KB 的内存页
例如文件 inode=12345:
(12345, 第0页) → 对应文件 offset 0~4095
(12345, 第1页) → 对应文件 offset 4096~8191
(12345, 第2页) → 对应文件 offset 8192~12287
...
关键特性:内核不追踪页内哪些字节是脏的。
整个 Page 要么是 clean(和磁盘一致),要么是 dirty(被修改过、需要刷盘)。哪怕你只改了页内 1 个字节,刷盘时也要把整个 4KB 页写出去。
3.3 Page Cache 的文件隔离
不同文件的 Page Cache 页按 inode 严格隔离:
文件 A (inode=111):
Page Cache: {(111,0) → PageX, (111,1) → PageY}
文件 B (inode=222):
Page Cache: {(222,0) → PageZ, (222,1) → PageW}
→ 进程写文件 A 的 Page 永远不会影响文件 B 的 Page
→ 即使两个文件在同一目录、同一磁盘上
四、对齐(Alignment):磁盘世界的"交通规则"
4.1 什么是对齐?
对齐就是要求数据的地址/偏移/大小必须是某个值的整数倍。
4KB 对齐示例:
✅ 合法: 0, 4096, 8192, 12288, ... (都是 4096 的整数倍)
❌ 非法: 100, 1000, 4097, 5000, ... (不是 4096 的整数倍)
为什么需要对齐?因为硬件的最小读写单元不是 1 字节:
4.2 各层的"最小单元"
层次 最小操作单元 典型大小
──────────────────────────────────────────────────
CPU 内存访问 Cache Line 64 字节
Linux Page Cache Page 4KB
文件系统 (ext4/xfs) Block 4KB
NVMe 逻辑扇区 Logical Block (LBA) 512B 或 4KB
NVMe 物理扇区 Physical Sector 4KB
NAND Flash 写入 Page 4KB~16KB
NAND Flash 擦除 Block 256KB~4MB
HDD 扇区 Sector 512B
可以看到,4KB 是一个在多个层次上反复出现的魔法数字。
4.3 两种对齐:扇区对齐 vs 页对齐
|---------|---------------------------------------|------------------------------------|
| | 扇区对齐 (512B) | 页对齐 (4KB) |
| 对齐到 | 512 字节边界 | 4096 字节边界 |
| 目的 | 保证磁盘原子写入(torn write 保护) | 满足 Direct I/O / 匹配物理扇区 |
| 谁需要 | Journal WAL(保证断电恢复) | Direct I/O 应用(如 DirectEntryLogger) |
| 举例 | BookKeeper journalAlignmentSize=512 | BookKeeper Buffer.ALIGNMENT=4096 |
4.4 Direct I/O 的三重对齐要求
使用 O_DIRECT 时,以下三个条件必须同时满足 ,否则 write()/pwrite() 直接返回 EINVAL(无效参数):
┌─────────────────────────────────────────────────────────┐
│ Direct I/O 的三重对齐铁律 │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. 内存缓冲区地址 必须对齐 │
│ ❌ char *buf = malloc(4096); // 不保证对齐 │
│ ✅ char *buf = aligned_alloc(4096, 4096); // 对齐! │
│ ✅ posix_memalign(&buf, 4096, 4096); // 也行 │
│ │
│ 2. 文件偏移量 (offset) 必须对齐 │
│ ❌ pwrite(fd, buf, 4096, 1000); // 1000 没对齐! │
│ ✅ pwrite(fd, buf, 4096, 4096); // 4096 = 1×4096 │
│ │
│ 3. 读写长度 (length) 必须对齐 │
│ ❌ write(fd, buf, 100); // 100 没对齐! │
│ ✅ write(fd, buf, 4096); // 4096 = 1×4096 │
│ │
│ 三条有一条不满足 → errno = EINVAL → 写入失败! │
└─────────────────────────────────────────────────────────┘
对齐值怎么确定?
# 方法 1: 查看块设备的逻辑/物理扇区大小
cat /sys/block/nvme0n1/queue/logical_block_size # 通常 512
cat /sys/block/nvme0n1/queue/physical_block_size # 通常 4096
# 方法 2: 查看文件系统块大小
stat -f /path/to/dir | grep "Block size"
tune2fs -l /dev/nvme0n1p1 | grep "Block size" # ext4
# 方法 3: 查看 DIO 对齐要求
cat /sys/block/nvme0n1/queue/dma_alignment
cat /sys/block/nvme0n1/queue/minimum_io_size
cat /sys/block/nvme0n1/queue/optimal_io_size
# 方法 4: 查看完整的磁盘拓扑
lsblk -t /dev/nvme0n1
实际规则: 对齐值 = max(逻辑扇区大小, 文件系统要求)。虽然很多情况下 512B 就能通过,但生产环境统一用 4KB 是最安全的选择------因为有些机器 512 行,有些不行,4KB 通吃。
4.5 Buffered I/O 为什么不需要对齐?
因为 Page Cache 帮你兜底了:
你的 write(fd, buf, 100):
↓
Page Cache 内部操作:
1. 找到 offset 0 对应的 Page(如果没有就分配一个 4KB 的 Page)
2. 把你的 100 字节拷贝到这个 Page 的对应位置
3. 标记这个 Page 为 dirty
4. 返回 "写入成功"
刷盘时:
1. 以 Page (4KB) 为单位写到磁盘
2. 内核自己做好了对齐
→ 你完全不用操心对齐问题
五、write + fsync 的真实代价:写放大深度剖析
5.1 经典问题:写 100 字节,磁盘写了多少?
应用视角: write(fd, buf, 100) + fsync(fd)
"我就写了 100 字节啊"
实际磁盘: 至少 4KB 数据 + 4KB~16KB 元数据
"你以为你写了 100,其实我写了 8000+"
逐层拆解:
第 1 层: Page Cache → 磁盘 (数据)
─────────────────────────────
你的 100 字节在一个 4KB Page 里:
┌──────────────────────────────────────────────────┐
│ [你的100字节] │ 3996 字节的零 │
└──────────────────────────────────────────────────┘
↑ 整个 4KB Page 一起写到磁盘
数据写入量: 4KB
写入放大: 4096 / 100 = 40.96 倍
第 2 层: 文件系统日志 (Journaling) --- 以 ext4 为例
─────────────────────────────────────────────────
fsync 不光要写数据,还要更新文件系统的元数据:
- inode (文件大小、修改时间、块指针)
- 块分配位图 (bitmap)
- 文件系统日志 (jbd2 journal)
ext4 data=ordered (默认模式) 下的 fsync 流程:
1. 写数据块到最终位置: 4KB
2. 写 journal descriptor: 4KB (描述这次事务修改了什么)
3. 写 journal commit block: 4KB (标记事务完成)
4. 可能的 inode 更新: 4KB
总计: 数据 4KB + 元数据 4KB~16KB = 8KB~20KB
第 3 层: NVMe SSD 内部 (FTL 写放大)
─────────────────────────────────────
SSD 收到的 4KB 写入在内部可能导致:
- FTL 映射表更新
- GC 时的数据搬移
- 内部写入放大系数通常 1.1x~3x
最终 NAND Flash 上的真实写入量: 可能 10KB~60KB
所以,应用层的 100 字节 write + fsync,最终可能导致 NAND Flash 上写入 10KB~60KB 的数据。这就是"写放大"(Write Amplification)的完整链路。
5.2 连续小写 + fsync:惨烈的写放大现场
操作序列: Page Cache 状态 磁盘实际写入
─────────────────────────────────────────────────────────────────────
write(100B) @ offset 0 Page 0 dirty -
fsync() Page 0 clean 4KB + 元数据
write(100B) @ offset 100 Page 0 dirty (同一页!) -
fsync() Page 0 clean 4KB + 元数据 (又写一次!)
write(100B) @ offset 200 Page 0 dirty -
fsync() Page 0 clean 4KB + 元数据 (再写一次!)
...
前 40 次操作都在 Page 0 里 (100×40 = 4000 < 4096)
每次 fsync 都把同一个 4KB Page 完整地重新写到磁盘
═══════════════════════════════════════════════════════
40 次 write(100B) + fsync():
应用写入总量: 40 × 100 = 4,000 字节
磁盘数据写入: 40 × 4KB = 160KB (40 倍放大)
磁盘元数据写入: 40 × ~12KB = ~480KB (又是一大笔)
磁盘总写入: ~640KB (160 倍放大!)
═══════════════════════════════════════════════════════
这就是为什么"每写一点就 fsync"是性能杀手。 正确做法是 batch(批量攒一批再 fsync),也就是所谓的 Group Commit。
5.3 文件大小 vs 磁盘占用
# 写入 100 字节后
$ ls -l test_file
-rw-r--r-- 1 user user 100 ... ← 文件逻辑大小: 100 字节 (应用看到的)
$ ls -s test_file
4 test_file ← 磁盘实际占用: 4KB (至少一个块)
$ stat test_file
Size: 100 Blocks: 8 ← 8 个 512B 块 = 4KB
IO Block: 4096 ← 文件系统块大小
$ du -h test_file
4.0K test_file ← 磁盘占用确认: 4KB
文件的逻辑大小和物理占用是两回事。 哪怕文件只有 1 字节,磁盘上也至少占用一个文件系统块(通常 4KB)。
六、fsync 的真面目:比你想象的更重量级
6.1 fsync 做了什么?
fsync(fd); // 这一行背后的故事...
fsync(fd) 的完整执行过程:
1. 刷出该文件所有 dirty Page 到磁盘
└─ 把 Page Cache 中属于该 fd 的所有脏页写到块设备
2. 刷出文件系统元数据
└─ inode (大小、时间、块指针)
└─ 间接块 / extent tree
3. 提交文件系统日志事务 (ext4 jbd2)
└─ 写 journal descriptor block
└─ 写 journal commit block
└─ (这些也要写到磁盘)
4. 发送 FLUSH / FUA 命令到磁盘控制器
└─ 确保数据从磁盘控制器的 DRAM 缓存刷到 NAND/碟片
└─ NVMe: 发送 Flush 命令
└─ SATA: 发送 FLUSH CACHE (EXT) 命令
5. 等待磁盘确认完成
└─ 所有写入都持久化后,fsync 才返回
→ 只有 fsync 返回后,数据才真正"安全"了
→ 这就是为什么 fsync 很慢(通常 50μs~10ms)
6.2 write 返回 ≠ 数据安全
write() 返回 fsync() 返回
↓ ↓
数据位置: [ Page Cache (内存) ] → [ 磁盘 (持久化) ]
如果这时断电: 数据丢失!! 数据安全 ✓
这就是为什么 WAL (Write-Ahead Log) 必须 fsync:
1. write() 写入 journal → 数据在内存中
2. fsync() → 数据落盘,此时才向客户端返回 "写入成功"
3. 如果只 write 不 fsync,断电后 journal 不完整,数据丢失
6.3 fdatasync vs fsync
fsync(fd); // 刷数据 + 刷元数据 (包括文件大小、修改时间等)
fdatasync(fd); // 刷数据 + 只刷必要的元数据 (文件大小变了才刷 inode)
fdatasync 在文件大小没变的情况下(比如追加写后又追加写,但中间 prealloc 过)可以少一次 inode 写入,快一点。
七、NVMe SSD 内部:FTL、GC 与写放大
7.1 SSD 的内部架构
┌─────────────────────────────────────────────────────┐
│ NVMe SSD │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ NVMe 控制器 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ │
│ │ │ 主机接口 │ │ FTL 固件 │ │ DRAM 缓存 │ │ │
│ │ │ (PCIe) │ │(核心大脑) │ │ (映射表) │ │ │
│ │ └──────────┘ └──────────┘ └───────────┘ │ │
│ └──────────────────┬──────────────────────────┘ │
│ │ │
│ ┌──────────────────▼──────────────────────────┐ │
│ │ NAND Flash 阵列 │ │
│ │ │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │Die 0│ │Die 1│ │Die 2│ │Die 3│ ... │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │Block│ │Block│ │Block│ │Block│ │ │
│ │ │ Page│ │ Page│ │ Page│ │ Page│ │ │
│ │ └─────┘ └─────┘ └─────┘ └─────┘ │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ 可选: 电容 (Power Loss Protection, 断电保护) │
└─────────────────────────────────────────────────────┘
7.2 NAND Flash 的三个不对称操作
这是 SSD 所有"诡异行为"的根源:
操作 单位 大小 速度 特殊限制
───────────────────────────────────────────────────────────
读 (Read) Page 4KB~16KB ~25μs 随便读
写 (Write) Page 4KB~16KB ~200μs 只能写到"空白"Page!
擦 (Erase) Block 256KB~4MB ~2ms 一次擦一整个 Block
关键约束:
NAND 的 Page 不能"覆盖写"!
要写一个已有数据的 Page,必须先把整个 Block 擦除,
然后重新写入。
这就像:
- 纸上写字 (写) 很快
- 看字 (读) 很快
- 但修改一个字,要把整页纸揉掉 (擦),换一张新纸重新写
7.3 FTL(Flash Translation Layer)
FTL 是 SSD 控制器里的固件,负责假装自己是一块可以随机覆盖写的磁盘:
主机看到的: SSD 内部真实情况:
┌──────────────────┐ ┌──────────────────┐
│ LBA 0: [数据A] │ FTL 映射 │ Die0-Blk3-Page5: [数据A] │
│ LBA 1: [数据B] │ ──────────→ │ Die1-Blk7-Page2: [数据B] │
│ LBA 2: [数据C] │ │ Die0-Blk1-Page9: [数据C] │
│ ... │ │ ... │
└──────────────────┘ └──────────────────────────┘
当主机"覆盖写" LBA 0:
1. FTL 不会去擦旧的 Page
2. 而是写到一个新的空白 Page
3. 更新映射表: LBA 0 → 新位置
4. 旧的 Page 标记为 "invalid"(垃圾)
7.4 GC(Garbage Collection)与写放大
当空白 Page 快用完时,SSD 必须做垃圾回收:
GC 过程:
Block X (有垃圾): Block Y (空白):
┌───────────┐ ┌───────────┐
│ Page 0: ✓ │ (有效数据) ──copy──→ │ Page 0: ✓ │
│ Page 1: ✗ │ (垃圾) │ Page 1: ✓ │
│ Page 2: ✓ │ (有效数据) ──copy──→ │ │
│ Page 3: ✗ │ (垃圾) │ │
│ Page 4: ✗ │ (垃圾) │ │
│ Page 5: ✓ │ (有效数据) ──copy──→ │ │
└───────────┘ └───────────┘
↓
擦除 Block X → 变成空白 Block → 可以重新使用
GC 的代价:
- 要读出有效数据 (读放大)
- 要写到新 Block (写放大)
- 要擦除旧 Block (磨损)
- 在 GC 期间,正常 I/O 可能被阻塞 → 延迟毛刺!
SSD 写放大公式:
SSD Write Amplification Factor (WAF) = 实际 NAND 写入量 / 主机写入量
理想情况: WAF = 1 (写多少就写多少)
实际情况: WAF = 1.1 ~ 3+ (取决于写入模式和 SSD 使用率)
频繁小随机写 → 垃圾多 → GC 频繁 → WAF 高 → SSD 寿命缩短
大块顺序写 → 垃圾少 → GC 少 → WAF 低 → SSD 长寿
八、多进程共享磁盘:隔离与干扰
8.1 数据层面:完全隔离
两个进程在同一块磁盘上写不同文件,数据绝对不会互相污染:
隔离机制:
1. 文件系统块分配隔离
└─ 不同文件的物理块永远不重叠
2. Page Cache 按 inode 隔离
└─ (inode_A, offset) 和 (inode_B, offset) 是不同的页
3. VFS 层的 inode 锁
└─ 同一文件的并发写由内核串行化
└─ 不同文件之间完全并行
结论: 进程 A 的对齐策略 ≠ 影响进程 B 的数据完整性
一个进程不对齐,不会让另一个进程的数据出错
8.2 性能层面:会间接影响
虽然数据隔离,但它们共享物理资源:
共享资源:
┌──────────────────────────────────────────────────────┐
│ │
│ 1. I/O 调度器队列 ← 带宽争用 │
│ 2. 磁盘控制器 ← 命令队列深度有限 │
│ 3. Page Cache 内存 ← 互相挤占 │
│ 4. 文件系统日志 (jbd2) ← 串行化提交 │
│ 5. SSD 内部 FTL/GC ← 写放大 + GC stall │
│ 6. HDD 磁头 ← 寻道抖动 │
│ │
└──────────────────────────────────────────────────────┘
各种干扰的严重程度排名:
|---------------|----------|----------|-------------------------|
| 干扰类型 | HDD 严重程度 | SSD 严重程度 | 说明 |
| 磁头寻道争用 | ★★★★★ | N/A | HDD 上两个进程写不同位置 = 随机 I/O |
| SSD GC stall | N/A | ★★★★☆ | 一个进程碎片写导致 GC,影响另一个进程的延迟 |
| I/O 带宽争用 | ★★★☆☆ | ★★★☆☆ | 共享有限带宽 |
| Page Cache 争用 | ★★★☆☆ | ★★★☆☆ | 一方大量写入挤占另一方缓存 |
| 文件系统日志争用 | ★★☆☆☆ | ★★☆☆☆ | 频繁 fsync 串行化 jbd2 事务 |
8.3 HDD 上的灾难场景
HDD 磁盘:
进程 A 的数据 (外圈) 进程 B 的数据 (内圈)
████████ ████████
磁头: "我要去外圈写 A... 又要去内圈写 B... 又要回外圈..."
←── seek 5~10ms ──→←── seek 5~10ms ──→
顺序写吞吐: ~150MB/s
两进程交替写: ~1~5MB/s (暴跌 30~150 倍!)
原因: 每次 seek 浪费 5~10ms,而传输 4KB 只要 ~0.03ms
99.7% 的时间在 seek,0.3% 的时间在传数据
8.4 SSD 上的隐蔽杀手:GC Stall
时间线:
t=0s 进程 A: 正常写入,延迟 50μs ✓
进程 B: 频繁小写+fsync,产生大量碎片
t=30s SSD 内部: 空闲 Block 减少...
进程 A: 延迟 50μs ✓ (还没感觉)
t=60s SSD 内部: 触发 GC!要搬移数据 + 擦除 Block
进程 A: 延迟突然飙到 5ms~50ms !!! ← GC stall
进程 A: "我什么都没改,怎么突然变慢了??"
t=61s SSD 内部: GC 完成
进程 A: 延迟恢复 50μs ✓
这种间歇性毛刺在监控上表现为 P99/P999 延迟飙升
九、Torn Write(撕裂写):断电时的数据安全
9.1 什么是 Torn Write?
你要写 8KB 数据 (跨越 2 个扇区):
正常完成:
┌─────────────┬─────────────┐
│ 扇区 1 ✓ │ 扇区 2 ✓ │ 两个扇区都写完 → 数据一致
└─────────────┴─────────────┘
写到一半断电 (Torn Write):
┌─────────────┬─────────────┐
│ 扇区 1 ✓ │ 扇区 2 ✗ │ 扇区 1 是新数据,扇区 2 是旧数据
└─────────────┴─────────────┘
→ 数据不一致!半新半旧!
9.2 磁盘的原子写保证
HDD: 原子写单位 = 512 字节 (1 个扇区)
→ 一个扇区要么全写完,要么全没写
→ 跨扇区的写入没有原子性保证
SSD: 原子写单位 = 取决于厂家,通常 4KB (1 个 NAND Page)
→ 一个 4KB Page 要么全写完,要么全没写
→ 有些企业级 SSD 支持 16KB 原子写
NVMe 规范:
→ AWUN (Atomic Write Unit Normal): 保证原子写的最小单元
→ AWUPF (Atomic Write Unit Power Fail): 断电时的原子写单元
→ 可通过 nvme id-ns 查看
9.3 对齐与 Torn Write 的关系
场景: 写 1KB 数据,磁盘原子写单位 = 512B
不对齐的写入:
┌──────┬──────┬──────┐
│Sect 0│Sect 1│Sect 2│
│ ... │▓▓▓▓▓▓▓▓▓▓▓▓│← 1KB 数据跨越 Sect 1 和 Sect 2
└──────┴──────┴──────┘
断电时: Sect 1 写完了但 Sect 2 没写完 → 数据损坏!
对齐的写入 (对齐到 1024B):
┌──────┬──────┬──────┬──────┐
│Sect 0│Sect 1│Sect 2│Sect 3│
│ │▓▓▓▓▓▓▓▓▓▓▓▓│ │← 1KB 数据完整落在 Sect 1+2
└──────┴──────┴──────┴──────┘
这里仍然跨 2 个扇区,仍有 torn write 风险
对齐到 4KB + 数据 < 4KB:
┌──────────────────────────┐
│ 4KB Block │
│ ▓▓▓▓▓▓▓▓ + padding │← 数据 + padding 在一个原子写单元内
└──────────────────────────┘
如果磁盘保证 4KB 原子写 → 完全安全!
这就是为什么 BookKeeper Journal 的 journalAlignmentSize设为 512------它确保每次 flush 的数据都对齐到扇区边界,配合 Journal 的 replay 机制保证恢复正确性。
十、综合实战:BookKeeper 的 I/O 设计哲学
把以上所有知识串起来,看看 BookKeeper 是怎么应用的:
10.1 Journal(预写日志)
I/O 方式: Buffered I/O + fsync (Group Commit)
对齐策略: journalAlignmentSize=512 (V5 格式, 扇区对齐)
目的: 保证断电后可以正确恢复
为什么不用 DIO: Journal 写入量小但 fsync 频繁,
Buffered I/O 的 Page Cache 可以合并写入,效率更高
关键优化:
- Group Commit: 攒一批 entry 再 fsync,减少 fsync 次数
- Pre-allocation: 预分配文件空间,减少文件系统元数据更新
- journalRemoveFromPageCache: fsync 后用 POSIX_FADV_DONTNEED
提示内核释放 Page Cache (因为 journal 写完就不再读了)
10.2 EntryLogger(数据存储)
默认实现 (DefaultEntryLogger):
I/O 方式: Buffered I/O
问题: 数据在 Page Cache 和 BookKeeper 的 Cache 各一份
→ Double Caching,浪费内存
Direct I/O 实现 (DirectEntryLogger):
I/O 方式: O_DIRECT + O_DSYNC
对齐策略: Buffer.ALIGNMENT = 4096 (4KB 页对齐)
优势:
1. 消除 Double Caching → 内存利用率翻倍
2. 避免 Page Cache 争用 → 把 Page Cache 留给 Journal
3. 可预测延迟 → 没有内核 writeback 的突然刷盘
4. 减少 CPU 开销 → 少了一次内存拷贝
前提:
- 只支持 Linux
- 只支持 DbLedgerStorage
- 需要 JNI (native-io)
10.3 最佳磁盘部署方案
推荐部署:
┌─────────────────────┐ ┌─────────────────────┐
│ NVMe SSD #1 │ │ NVMe SSD #2 │
│ │ │ │
│ Journal 专用 │ │ EntryLog 专用 │
│ (Buffered I/O) │ │ (Direct I/O) │
│ │ │ │
│ 需求: 低延迟 fsync │ │ 需求: 高吞吐顺序写 │
│ 大小: 不需要很大 │ │ 大小: 尽可能大 │
└─────────────────────┘ └─────────────────────┘
为什么要分开?
→ 避免 Journal 的频繁 fsync 和 EntryLog 的大块写入互相争带宽
→ 避免 EntryLog 的 I/O 导致 SSD GC,影响 Journal 的延迟
→ Journal 盘可以选延迟更低的 Optane SSD
→ EntryLog 盘选容量大的 TLC/QLC SSD
十一、诊断命令速查表
# ═══════════ 磁盘信息 ═══════════
# 查看磁盘拓扑 (扇区大小、调度器等)
lsblk -t
# 查看逻辑/物理扇区大小
cat /sys/block/nvme0n1/queue/logical_block_size # 通常 512
cat /sys/block/nvme0n1/queue/physical_block_size # 通常 4096
# NVMe 详细信息 (原子写单元等)
sudo nvme id-ns /dev/nvme0n1 -n 1
# 文件系统块大小
stat -f /data | grep "Block size"
sudo tune2fs -l /dev/nvme0n1p1 | grep "Block size" # ext4
sudo xfs_info /dev/nvme0n1p1 # xfs
# ═══════════ I/O 监控 ═══════════
# 实时磁盘 I/O 统计
iostat -dxz 1
# 查看 I/O 调度器
cat /sys/block/nvme0n1/queue/scheduler
# 查看 Page Cache 使用
free -h # Buff/Cache 列
cat /proc/meminfo | grep -E "Cached|Dirty|Writeback"
# ═══════════ 高级追踪 ═══════════
# 追踪块设备层真实 I/O (需要 bcc-tools)
sudo biosnoop -d nvme0n1 # 每笔 I/O 的大小、延迟
sudo biolatency -d nvme0n1 # I/O 延迟分布直方图
# 追踪文件级 I/O
sudo opensnoop # 谁在打开什么文件
sudo filetop # 哪些文件 I/O 最多
# 追踪 fsync 调用
sudo strace -e fsync,fdatasync -p <pid>
十二、一句话总结
Buffered I/O 是"内核替你管缓存",Direct I/O 是"我要自己管缓存";对齐是 DIO 的入场券,不对齐连门都进不去;fsync 是确保数据落盘的唯一方式,但它比你以为的要重得多;同一块盘上多个进程数据不会互相污染,但 I/O 性能一定会互相干扰------分盘部署是生产环境的黄金法则。