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 性能一定会互相干扰------分盘部署是生产环境的黄金法则。

相关推荐
RH2312114 小时前
2026.6.8Linux
java·数据库·中间件
理人综艺好会21 小时前
双Token机制在实际项目中的应用与实践
中间件·token
番茄去哪了1 天前
神领物流面试题(一)
java·大数据·中间件
念何架构之路1 天前
消息中间件
中间件
都说名字长不会被发现1 天前
Spring Boot Starter 中间件账号密码加密方案设计与实现
java·spring boot·后端·中间件
瀚高PG实验室2 天前
java中间件无法连接数据库
java·数据库·中间件·瀚高数据库
之歆2 天前
Day11_Express 深入解析:从中间件到项目实战
中间件·express
码农飞哥2 天前
RocketMQ消费接口设计实战:为什么HTTP回调接口必须吞掉所有异常,始终返回成功?
网络协议·http·中间件·消息队列·rocketmq
硅谷秋水2 天前
物理人工智能的驾驭工程:机器人中间件是驾驭层
人工智能·机器学习·语言模型·中间件·机器人
初中就开始混世的大魔王3 天前
6 Fast DDS-传输层
开发语言·c++·中间件·信息与通信