Appearance
日志与一致性
- 写作时间:
2026-03-04 首次提交,2026-04-03 最近修改 - 当前字符:
7172
上一课介绍了 VFS 怎样在多种文件系统之上提供统一接口,以及文件系统怎样在磁盘上组织超级块、inode 表和数据块。但那些讨论隐含了一个假设:写入操作总是完整地完成。现实远没有这么理想。程序调用 write() 后,数据不会立刻落到磁盘——它先写入内存中的缓存,稍后才被刷回磁盘。如果在这个"稍后"到来之前发生了断电或系统崩溃,磁盘上的数据就可能处于一个不完整的中间状态。
这种不完整状态为什么危险?因为文件系统的很多操作需要更新多个磁盘位置。一次创建文件可能要同时修改目录的数据块(加入新的目录条目)、inode 位图(标记新 inode 已分配)、新 inode 的内容(写入元数据),以及块位图(标记数据块已分配)。如果只有部分修改落盘了,文件系统的结构就可能出现矛盾:位图说某个块已分配,但没有任何 inode 指向它;或者目录条目指向一个未初始化的 inode。这就是一致性问题。
本课先从 page cache 开始,看 write() 之后的数据在落盘之前经历了什么。然后看断电时会出现什么样的不一致,以及早期文件系统用 fsck 怎样事后修复。fsck 的代价太大,于是引出两种事前预防策略:预写日志(WAL) 和写时复制(COW)。最后用 ext4 作为实例,看日志在一个真实文件系统中怎样落地。
Page Cache
页缓存(page cache)是内核在 RAM 中维护的文件数据缓存,以页(通常 4KB)为单位,位于文件系统和块设备之间。
内存映射那一课已经提到,文件映射(file-backed mapping)的读写会通过页缓存与底层文件内容发生联系。现在我们从文件系统的写入路径重新审视这个缓存层。
当进程调用 write(fd, buf, 4096) 时,数据并不会直接到达磁盘。实际的路径是:
write(fd, buf, 4096)
│
▼
VFS: vfs_write()
│
▼
文件系统: ext4_file_write_iter()
│
▼
page cache: 找到或创建对应的缓存页,把数据从用户空间复制进去
│
▼
标记该页为脏页(dirty page)
│
▼
write() 返回(数据还在内存中)
│
... 稍后 ...
│
▼
内核回写线程: 将脏页提交给块层
│
▼
块层 → 磁盘驱动 → 磁盘关键在于 write() 返回时数据只到达了 page cache,还没有写入磁盘。这个设计有三个好处:
写入合并。 如果进程连续多次写入同一个文件的同一个区域,page cache 中只有一个脏页。内核不需要把每次写入都单独发给磁盘,只需要在回写时把最终状态写一次。
预读(read-ahead)。 当进程顺序读取文件时,内核会预测接下来要读的页,提前把它们从磁盘加载到 page cache 中。这样后续的 read() 调用直接从内存返回数据,不需要等待磁盘 I/O。
统一缓存。 read()/write() 和 mmap() 访问的是同一份 page cache。一个进程用 write() 修改了文件,另一个进程通过 mmap() 映射了同一个文件,看到的是相同的修改。这种一致性由 page cache 作为单一数据源来保证。
内核中的回写线程(writeback thread)负责定期把脏页刷回磁盘。回写的触发条件包括:脏页数量超过阈值、脏页存在时间超过 dirty_expire_centisecs(默认 30 秒)、内存压力导致需要回收页面,以及进程显式调用 fsync() 或 sync()。
如果回写线程来不及刷盘,脏页会不会把内存撑满?
不会。内核通过两个参数控制脏页的总量。dirty_background_ratio(默认 10%)是回写线程开始工作的阈值。dirty_ratio(默认 20%)是硬限制:当脏页占比达到这个值时,产生新脏页的 write() 调用会被阻塞,直到回写线程腾出空间。这个机制叫做脏页限流(dirty throttling)。它确保了 page cache 不会无限膨胀,同时在正常负载下 write() 仍然可以快速返回。
一致性问题与 fsck
一致性(consistency)是指文件系统在磁盘上的所有数据结构之间不存在矛盾。
page cache 带来了写入性能的巨大提升,但它同时也引入了风险:脏页还在内存中尚未回写时,如果发生断电,这部分修改就永久丢失了。丢失用户数据本身已经很糟糕,但更严重的问题是文件系统元数据的不一致。
考虑一个具体的场景:向目录 /home/wei/ 中创建一个新文件 report.txt。这个操作需要更新磁盘上的四个位置:
- inode 位图:标记一个新 inode 为已分配
- 新 inode:写入文件的元数据(权限、大小、时间戳)
- 目录数据块:在
/home/wei/的数据中添加(report.txt, inode_number)条目 - 块位图:如果同时写入了文件数据,还需要标记数据块为已分配
如果这四个写入只完成了一部分就断电了,重启后文件系统可能出现以下矛盾:
| 完成的写入 | 未完成的写入 | 结果 |
|---|---|---|
| inode 位图 | 新 inode、目录条目 | inode 被标记为已分配,但没有任何目录条目指向它——空间泄漏 |
| 目录条目 | 新 inode | 目录条目指向一个未初始化的 inode——访问这个文件会得到垃圾数据 |
| 目录条目、新 inode | 块位图 | 文件声称占有某些数据块,但块位图说那些块是空闲的——同一块可能被分配给另一个文件 |
早期 Unix 文件系统的解决方案是 fsck(file system check):在系统重启后,fsck 扫描整个文件系统,检查所有数据结构之间的一致性,修复发现的矛盾。它会遍历所有 inode、所有目录条目、所有位图,交叉验证它们之间的关系。
fsck 能修复大部分不一致,但它有两个严重的缺点。第一,它非常慢。fsck 需要扫描整个磁盘,时间和磁盘大小成正比。一块 1TB 的磁盘做完整 fsck 可能需要几十分钟甚至几个小时,这段时间文件系统不可用。第二,它是事后修复而不是事前预防。数据已经丢失了,fsck 只能尽量让剩余的数据结构保持自洽,对已丢失的数据无能为力。
现代文件系统用两种策略来从根本上避免不一致:预写日志和写时复制。
预写日志
预写日志(Write-Ahead Logging, WAL)的核心思想是:在修改文件系统的数据结构之前,先把即将做的修改写入一块专门的日志区域。只有日志写入完成后,才真正去修改目标位置。
日志区域通常是磁盘上一块预留的环形缓冲区(circular buffer)。一次文件系统操作(比如创建文件)的日志流程是:
1. 开始事务(transaction begin)
│
▼
2. 把所有即将修改的块写入日志区域(日志写入)
│
▼
3. 写入提交记录(commit record),标记这个事务完整
│
▼
4. 把修改写入磁盘上的实际目标位置(检查点, checkpoint)
│
▼
5. 释放日志空间这个流程的关键在于提交记录。提交记录写入成功之前,事务不算完成。如果在步骤 2 期间断电,日志中只有不完整的数据,重启后内核发现没有提交记录,直接丢弃这个事务,文件系统回到上一个一致的状态。如果在步骤 4 期间断电(日志已提交,但目标位置还没写完),重启后内核从日志中重放(replay)这个事务,把修改重新写入目标位置。
日志保证的是:文件系统要么处于旧的一致状态,要么处于新的一致状态,永远不会处于中间状态。
日志有两种模式,它们在保护范围和性能之间做出不同的选择。
数据日志(journal mode) 把元数据和文件数据都写入日志。这是最安全的模式:即使断电,文件数据也不会出现部分写入的情况。代价是每份数据都要写两次——一次写日志,一次写目标位置。
有序日志(ordered mode) 只把元数据写入日志,但保证在提交元数据之前,相关的文件数据已经写入了目标位置。这样元数据始终指向有效的数据块,不会出现"inode 指向垃圾数据"的情况。文件数据本身可能丢失最后一次写入的内容,但至少不会产生结构性矛盾。ext4 默认使用有序日志。
回写日志(writeback mode) 也只记录元数据,但不保证数据和元数据的写入顺序。性能最好,但文件数据可能出现不一致:inode 可能指向尚未写入的数据块。这个模式只适合对数据一致性要求不高的场景。
| 模式 | 日志内容 | 数据安全 | 写入开销 |
|---|---|---|---|
| 数据日志(journal) | 元数据 + 数据 | 最高 | 最大(数据写两次) |
| 有序日志(ordered) | 仅元数据 | 中等 | 中等 |
| 回写日志(writeback) | 仅元数据 | 最低 | 最小 |
写时复制文件系统
写时复制(Copy-on-Write, COW)文件系统用完全不同的策略来保证一致性:它永远不覆盖已有的数据块,而是把修改写到新的空闲位置,然后原子地更新指针指向新位置。
COW 文件系统的写入流程:
修改文件中的一个数据块:
1. 分配新的空闲块
2. 把修改后的内容写入新块
3. 更新 inode 中的指针,指向新块(inode 本身也写到新位置)
4. 更新超级块中的根指针(如果需要)
5. 旧块变成空闲
写入前 写入后
┌─────┐ ┌─────┐
│ root│──→ inode ──→ A │ root│──→ inode' ──→ A'
└─────┘ └─────┘ │
└──→ B (未修改,仍指向原块)整个更新是一条从叶子到根的路径:修改了数据块,就要更新指向它的 inode;更新了 inode,就要更新指向它的父结构。最终在超级块级别完成一次原子的指针切换。如果在这条路径完成之前断电,旧的数据仍然完好无损,因为旧块从未被覆盖。
COW 天然支持快照(snapshot):只需要保留旧的根指针,就能在不复制任何数据的情况下冻结一个时间点的文件系统状态。Btrfs 和 ZFS 都是 COW 文件系统,它们的快照功能就来源于这个特性。
COW 的代价是写入放大(write amplification):修改一个叶子块可能需要连锁更新从叶子到根的整条路径。对于随机小写入,这种放大可能很显著。另外,COW 文件系统容易出现碎片:新块分配在磁盘的随机位置,文件的数据不再连续,顺序读取性能会下降。
ext4 概览
ext4 是 Linux 上最广泛使用的文件系统,它在 ext2/ext3 的基础上引入了区段(extent)、延迟分配(delayed allocation)和日志(journal)等特性。
区段(extent) 取代了传统的多级间接块索引。上一课已经介绍了区段的概念:一个 (起始块号, 长度) 对可以描述一段连续的磁盘区域。ext4 的 inode 中存放一棵区段树(extent tree)。对于小文件,区段直接放在 inode 内部;对于大文件,区段树可以扩展到额外的磁盘块中。
延迟分配(delayed allocation) 将块分配推迟到脏页回写时才执行。传统文件系统在 write() 时就分配磁盘块,但此时内核还不知道进程最终会写多少数据。延迟分配等到回写线程准备刷盘时,才一次性为所有待写入的脏页分配连续的磁盘块。这样做的好处是显而易见的:分配器看到的是完整的写入请求,能做出更好的布局决策,减少碎片。
日志(journal) 是 ext4 的一致性保障机制。ext4 默认运行在有序日志模式下。日志区域是磁盘上一块固定大小的环形缓冲区(默认 128MB),由 JBD2(Journaling Block Device 2) 子系统管理。ext4 把多个文件系统操作打包成一个复合事务,每隔几秒提交一次,减少磁盘写入次数。
一个典型的 ext4 写入路径:
write(fd, buf, 4096)
→ page cache: 数据写入缓存页,标记为脏
→ [延迟分配:此时不分配磁盘块]
回写线程触发:
→ 延迟分配器分配连续磁盘块
→ 数据写入目标磁盘块
→ 元数据修改写入日志
→ 提交事务
→ 元数据写入磁盘上的实际位置(检查点)小结
| 概念 | 说明 |
|---|---|
| 页缓存(page cache) | 内核在 RAM 中维护的文件数据缓存,write() 的数据先到这里,稍后再回写磁盘 |
| 脏页(dirty page) | 已被修改但尚未写回磁盘的缓存页 |
| 回写(writeback) | 内核后台线程将脏页刷回磁盘的过程 |
| 预读(read-ahead) | 内核提前加载后续可能需要的文件页到 page cache |
| 一致性(consistency) | 文件系统所有数据结构之间不存在矛盾 |
| fsck | 事后扫描修复不一致的工具,慢且只能补救 |
| 预写日志(WAL) | 先写日志再改目标位置,保证操作的原子性 |
| 有序日志(ordered) | 只记录元数据日志,但保证数据先于元数据落盘;ext4 的默认模式 |
| 写时复制(COW) | 不覆盖旧块,修改写到新位置后原子切换指针 |
| ext4 | Linux 最广泛使用的文件系统,结合了区段分配、延迟分配和有序日志 |
write() 返回成功不代表数据已经安全落盘——它只是到达了 page cache。从 page cache 到磁盘之间存在断电风险窗口,文件系统用日志或 COW 来保证这个窗口中不会产生结构性矛盾。但这些机制保护的是文件系统元数据的一致性,应用程序自身的数据一致性还需要额外的手段。下一课就来讨论这个问题。
Linux 源码入口:
mm/filemap.c— page cache 的核心逻辑,generic_file_read_iter()/generic_file_write_iter()mm/page-writeback.c— 脏页限流与回写控制fs/jbd2/— JBD2 日志子系统fs/ext4/inode.c— ext4 inode 操作,包括延迟分配路径fs/ext4/extents.c— ext4 区段管理