Skip to content

日志与一致性

  • 写作时间: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。这个操作需要更新磁盘上的四个位置:

  1. inode 位图:标记一个新 inode 为已分配
  2. 新 inode:写入文件的元数据(权限、大小、时间戳)
  3. 目录数据块:在 /home/wei/ 的数据中添加 (report.txt, inode_number) 条目
  4. 块位图:如果同时写入了文件数据,还需要标记数据块为已分配

如果这四个写入只完成了一部分就断电了,重启后文件系统可能出现以下矛盾:

完成的写入未完成的写入结果
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: 数据写入缓存页,标记为脏
  → [延迟分配:此时不分配磁盘块]

回写线程触发:
  → 延迟分配器分配连续磁盘块
  → 数据写入目标磁盘块
  → 元数据修改写入日志
  → 提交事务
  → 元数据写入磁盘上的实际位置(检查点)

ext4 的容量限制

ext4 支持的最大文件系统大小为 1 EB(1 exabyte = 10^18 字节),最大单文件大小为 16 TB。inode 数量在创建文件系统时确定(mkfs.ext4-N 选项),之后不能增加。这是 ext4 和 Btrfs/ZFS 等新一代文件系统的一个重要区别:后者可以动态分配 inode。

ext4 的块大小通常是 4KB,区段树最多支持 4 层,每个区段最多描述 128MB 的连续空间(32768 个 4KB 块)。

小结

概念说明
页缓存(page cache)内核在 RAM 中维护的文件数据缓存,write() 的数据先到这里,稍后再回写磁盘
脏页(dirty page)已被修改但尚未写回磁盘的缓存页
回写(writeback)内核后台线程将脏页刷回磁盘的过程
预读(read-ahead)内核提前加载后续可能需要的文件页到 page cache
一致性(consistency)文件系统所有数据结构之间不存在矛盾
fsck事后扫描修复不一致的工具,慢且只能补救
预写日志(WAL)先写日志再改目标位置,保证操作的原子性
有序日志(ordered)只记录元数据日志,但保证数据先于元数据落盘;ext4 的默认模式
写时复制(COW)不覆盖旧块,修改写到新位置后原子切换指针
ext4Linux 最广泛使用的文件系统,结合了区段分配、延迟分配和有序日志

write() 返回成功不代表数据已经安全落盘——它只是到达了 page cache。从 page cache 到磁盘之间存在断电风险窗口,文件系统用日志或 COW 来保证这个窗口中不会产生结构性矛盾。但这些机制保护的是文件系统元数据的一致性,应用程序自身的数据一致性还需要额外的手段。下一课就来讨论这个问题。


Linux 源码入口