Linux 磁盘与文件 I/O 深度笔记

从电子到比特,从应用到 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 性能一定会互相干扰------分盘部署是生产环境的黄金法则。

相关推荐
勇往直前plus3 小时前
大模型开发手记(九):LangChain Agent 中间件-提升Agent的可靠性与可控性
中间件·langchain
Volunteer Technology3 小时前
中间件场景题归纳(二)
中间件
隔壁小邓21 小时前
数据库中间件全景解析:从连接管理到分布式协同
数据库·分布式·中间件
smart19981 天前
Infortrend 普安科技U.2 NVMe 全闪阵列GS 5024U性能提升2.5倍,蓄能AI算力迸发
存储
爱喝可乐的老王1 天前
LangChain内置中间件总结
中间件·langchain
marsh02061 天前
12 openclaw中间件开发:打造可复用的业务逻辑组件
ai·中间件·编程·技术
爱喝可乐的老王1 天前
LangChain自定义中间件
中间件·langchain
汀沿河1 天前
3 LangChain 1.0 中间件(Middleware)- after_model、after_agent
前端·中间件·langchain
xht08321 天前
docker离线安装及部署各类中间件(x86系统架构)
docker·中间件·系统架构