Skip to content

崩溃一致性实践

  • 写作时间:2026-03-04 首次提交,2026-04-03 最近修改
  • 当前字符:6360

上一课讨论了文件系统怎样用日志或写时复制来保护自己的元数据一致性。但那些保护针对的是文件系统内部结构——inode 位图、目录条目、块位图之间不出现矛盾。应用程序的数据一致性是另一个层面的问题。数据库要保证一条事务要么完整写入,要么完全不写;配置管理工具要保证配置文件不会变成半新半旧;包管理器要保证安装过程中断电后不会留下损坏的软件包。这些需求超出了文件系统日志的保护范围,需要应用程序自己来确保。

本课先明确 fsync 的语义——应用程序要求数据落盘的唯一可靠手段。然后看 rename 的原子性,它为什么是实现安全文件更新的基石。在这两个工具的基础上,我们列出一组崩溃一致性不变量,作为编写崩溃安全代码的检查清单。最后讨论怎样用 fault injection 来验证这些不变量确实成立,以及在工程实践中 COW 和日志 各自适用的场景。

fsync 语义

fsync(fd) 将指定文件的所有脏数据和元数据从 page cache 刷写到磁盘,并等待磁盘确认写入完成后才返回。

上一课已经明确了:write() 返回成功只意味着数据到达了 page cache,不代表数据已在磁盘上。从 write() 到磁盘之间存在一个断电风险窗口。fsync() 的作用就是把这个窗口关闭:调用 fsync(fd) 之后,如果返回成功,那么该文件在此时间点之前的所有写入都已经持久化到了磁盘上1

几个关键细节:

fsync 只保证指定文件的数据和元数据落盘。 它不保证其他文件的数据,也不保证目录的元数据。如果创建了一个新文件并写入数据,即使对文件调用了 fsync,包含这个文件的目录条目也可能还没有落盘。断电后文件的数据在磁盘上,但目录中可能找不到这个文件名。要保证新创建的文件名可见,需要对父目录也调用 fsync

fdatasync(fd)fsync 的轻量版本。 它只刷写文件数据和影响后续读取的元数据(主要是文件大小),不刷写不影响数据访问的元数据(如 atimemtime)。对于只是追加写入的场景(比如日志文件),fdatasync 可以减少不必要的元数据写入。

sync() 是全局版本。 它把系统中所有文件系统的所有脏页都刷到磁盘,但 POSIX 标准不要求它等待写入完成。Linux 的实现会等待,但如果磁盘有写入缓存(write cache)且没有启用 write barrier,数据可能还停留在磁盘的硬件缓存中。

fsync 的性能代价

fsync 要求数据真正到达磁盘介质(而不只是磁盘的 DRAM 缓存),所以它的延迟取决于磁盘硬件。对 HDD 来说,一次 fsync 可能需要 5-15ms(一次磁头寻道加旋转延迟)。对 SSD 来说,通常在 0.1-1ms 范围内。如果应用程序在每次 write() 后都调用 fsync,吞吐量会急剧下降。

数据库通常的做法是批量提交:攒一批写入,然后调用一次 fsync,把多次 I/O 的 fsync 开销摊到一次。这就是数据库中"组提交(group commit)"的核心思想。

rename 原子性

rename(old_path, new_path) 将一个目录条目从旧路径原子地移动到新路径。如果新路径已经存在一个文件,rename 会原子地替换它。

POSIX 保证 rename 是原子的:在任何时刻,new_path 要么指向旧文件,要么指向新文件,不会出现中间状态(既不指向旧文件也不指向新文件,或者指向一个半写入的文件)2。这个原子性保证使得 rename 成为实现安全文件更新的基本工具。

安全更新一个文件的标准做法(常被称为"写入-fsync-重命名"模式):

c
/* safely update /etc/config */

// 1. write new content to a temporary file in the same directory
int fd = open("/etc/config.tmp", O_WRONLY | O_CREAT | O_TRUNC, 0644);
write(fd, new_data, new_data_len);

// 2. ensure new content is on disk
fsync(fd);
close(fd);

// 3. atomically replace old file with new file
rename("/etc/config.tmp", "/etc/config");

// 4. ensure the directory entry update is on disk
int dir_fd = open("/etc", O_RDONLY);
fsync(dir_fd);
close(dir_fd);

这四步保证了:在任何时刻断电,/etc/config 要么是完整的旧内容,要么是完整的新内容。步骤 2 确保新数据已经在磁盘上。步骤 3 原子地切换目录条目。步骤 4 确保目录条目的更新也落盘了。

如果省略步骤 2,rename 可能发生在新数据还只在 page cache 中的时候。断电后目录条目指向新文件,但新文件的数据块可能是空的或不完整的。如果省略步骤 4,rename 在内存中完成了但目录条目还没有落盘,断电后目录中仍然是旧文件。

为什么临时文件必须和目标文件在同一个目录(同一个文件系统)?

rename 的原子性只在同一个文件系统内有保证。如果源和目标在不同的文件系统上,rename 无法做到原子操作——内核需要先在目标文件系统上创建副本,再删除源文件。这是两个独立的操作,中间断电会导致文件出现在两个位置或者丢失。Linux 在这种情况下会返回 EXDEV 错误而不是悄悄执行非原子的跨文件系统操作。

崩溃一致性不变量

崩溃一致性不变量(crash consistency invariant)是一组必须在任何崩溃时刻都成立的条件。

上面的"写入-fsync-重命名"模式只是一个具体的例子。对于更复杂的应用(数据库、文件系统本身、分布式存储),需要一套系统性的方法来思考崩溃安全。以下是一组通用的不变量清单,覆盖了文件系统和应用层面最常见的一致性要求:

文件系统级不变量(由文件系统日志或 COW 保证):

  1. 分配位图与实际使用一致:位图标记为已分配的块,必须有 inode 指向它
  2. 每个可达的 inode 在位图中被标记为已分配
  3. 目录条目指向的 inode 编号必须对应一个有效的已分配 inode
  4. inode 的链接计数等于指向它的目录条目数量
  5. 目录的 . 指向自己,.. 指向父目录
  6. 没有孤立的已分配块(分配了但没有 inode 引用)
  7. 没有重叠的块分配(两个 inode 指向同一个数据块,除非是 reflink)

应用级不变量(需要应用程序自己保证):

  1. fsync 返回后,文件数据在磁盘上持久存在
  2. 新创建的文件名在 fsync(父目录) 后可见
  3. 通过"写入-fsync-重命名"更新的文件,在任何崩溃点都是完整的旧版或新版
  4. 追加写入的日志文件,崩溃后末尾可能截断但不会出现中间空洞
  5. 数据库事务日志的 commit 记录必须在数据页之后落盘
  6. 如果操作依赖多个文件的协调更新,每个文件的 fsync 顺序必须符合逻辑依赖

编写崩溃安全的代码时,可以用这些不变量作为检查清单:对每一个写入操作,问"如果在这一行之后断电,哪些不变量可能被违反?"如果有不变量可能被违反,就需要调整写入顺序或插入 fsync。

Fault Injection

Fault injection(故障注入)是通过在写入路径中人为引入崩溃来验证一致性不变量的测试方法。

不变量清单只是设计时的指导方针。要验证代码确实满足这些不变量,需要真正地制造崩溃并检查结果。这不能靠拔电源——拔电源的时机不可控,无法系统性地覆盖所有崩溃点。Fault injection 的思路是在软件层面模拟崩溃:在每一个可能的写入操作之后插入一个"崩溃点",然后检查恢复后的状态是否满足不变量。

常用的 fault injection 手段:

dm-fuzz / dm-log-writes。 Linux 的 device-mapper 框架可以在块设备之上插入一层过滤。dm-log-writes 记录所有写入块设备的 I/O 操作及其顺序,测试框架可以重放这些 I/O 的任意前缀来模拟在不同时间点断电的效果。

CrashMonkey 和 xfstests。 CrashMonkey 是一个专门用于文件系统崩溃测试的框架,它枚举可能的 I/O 重排序和部分写入,生成大量的崩溃状态,然后对每个状态运行一致性检查。xfstests 是 Linux 文件系统的标准测试套件,其中 generic/ 目录下有大量崩溃相关的测试用例。

应用层模拟。 对于应用程序(比如数据库),可以通过 LD_PRELOAD 拦截 write()fsync() 等系统调用,在特定的调用序列中注入 SIGKILL 或直接终止进程,然后检查恢复后的数据是否一致。

测试的基本循环:

1. 执行正常的写入操作序列
2. 在第 N 个写入操作之后注入崩溃
3. 重新挂载文件系统(触发日志回放)
4. 检查所有不变量是否成立
5. 递增 N,重复

这种穷举式的测试不可能覆盖所有场景,但它能发现绝大多数常见的崩溃安全 bug,特别是漏掉 fsync、写入顺序错误和 rename 之前没有刷盘这些典型错误。

COW 与日志的工程权衡

上一课分别介绍了日志和 COW 两种一致性机制。在工程实践中,选择哪一种取决于工作负载的特征。

维度日志(ext4, XFS)COW(Btrfs, ZFS)
写入模式原地更新 + 日志保护永不覆盖,写到新位置
顺序写入性能好(区段分配保持连续)容易碎片化
随机写入性能好(原地更新只改一个块)写入放大(路径更新)
快照需要额外机制(LVM)原生支持,几乎零开销
数据完整性校验不内置Btrfs/ZFS 内置 checksum
成熟度ext4/XFS 极其成熟Btrfs 仍在改进中,ZFS 成熟但许可证限制
典型场景通用服务器、数据库NAS、备份、需要快照的场景

日志的优势场景: 数据库工作负载(大量随机小写入、需要精确控制 fsync 时机)、高吞吐顺序写入(日志归档、流媒体录制)。ext4 和 XFS 在这些场景中经过了二十多年的生产验证。

COW 的优势场景: 需要频繁创建快照的存储系统、需要端到端数据校验的场景(Btrfs 和 ZFS 对每个数据块计算 checksum,可以检测静默数据损坏)、多设备存储池管理。

在实际的应用设计中,无论底层文件系统用日志还是 COW,应用层的崩溃安全策略("写入-fsync-重命名"、WAL、事务日志)都是必需的。文件系统保证的是自己不坏,不保证应用程序的数据逻辑不坏。

小结

概念说明
fsync(fd)将指定文件的脏数据和元数据刷写到磁盘并等待完成
fdatasync(fd)只刷写数据和必要的元数据(如文件大小),不刷写 atime/mtime
rename 原子性同一文件系统内的 rename 是原子操作,新路径要么指向旧文件要么指向新文件
写入-fsync-重命名安全文件更新的标准模式:写临时文件 → fsync → rename → fsync 父目录
崩溃一致性不变量在任何崩溃时刻都必须成立的条件,作为检查清单使用
Fault injection在写入路径中人为注入崩溃,系统性地验证不变量是否成立
日志 vs COW日志适合原地更新和数据库负载,COW 适合快照和数据校验场景

文件系统保证的是内部数据结构的一致性,应用程序的数据一致性需要自己负责。fsync 是唯一能把"数据在 page cache"变成"数据在磁盘"的可靠手段。rename 的原子性提供了"切换到新版本"的安全机制。两者结合,加上对写入顺序的仔细控制,就是编写崩溃安全代码的全部工具。


Linux 源码入口


  1. 严格来说,fsync 依赖磁盘硬件正确响应 flush 命令(FUA 或 write barrier)。如果磁盘的写入缓存(write cache)启用了但硬件不尊重 flush 命令(某些廉价消费级 SSD 存在这种问题),数据可能仍然只在磁盘的 DRAM 缓存中而非介质上。对于需要极高可靠性的场景,应验证磁盘固件是否正确实现了 flush 语义。 ↩︎

  2. POSIX.1-2017 第 4.12 节明确要求 rename 是原子操作。在实现层面,ext4 的有序日志模式保证了 rename 的元数据更新被日志保护。但需要注意:rename 的原子性保证的是目录条目的切换,不保证文件数据已经落盘。这就是为什么 rename 之前必须对文件调用 fsync。 ↩︎