Appearance
崩溃一致性
- 写作时间:
2026-03-04 首次提交,2026-07-13 最近修改 - 当前字符:
6946
日志与一致性解释了 ext4 怎样通过日志恢复自身元数据,但应用仍然要决定一次更新在掉电后应当是什么结果。假设程序直接打开 settings.json、截断旧内容并写入新配置。若写到一半崩溃,日志可以保证 inode 和空闲块结构合法,却完全可以留下半份新配置。文件系统没有足够信息判断应用希望保留旧版本还是提交新版本。
要把这个需求表达清楚,程序先要区分系统调用返回与介质完成之间的 持久化边界,再理解 文件同步 能确认哪些状态。安全更新不能直接覆盖目标,而要通过同目录临时文件完成 原子替换。新文件数据落盘仍不等于新名称落盘,因此还要处理 目录持久化。最后用 故障注入 在各个系统调用边界制造失败,验证崩溃后只会得到协议允许的状态。
持久化边界
持久化边界(durability boundary)是应用能够确认某组更新已经到达约定的非易失存储,并在随后崩溃后仍可恢复的执行点。
文件写入会依次经过用户缓冲区、页缓存、文件系统、块层、设备写缓存和非易失介质。各个系统调用的成功返回只承诺自己的接口语义:
| 操作 | 成功时能够确认 | 不能单独确认 |
|---|---|---|
write() | 内核接受了返回字节数的数据 | 数据已经到达非易失介质 |
close() | 释放 fd,并完成关闭流程 | 之前写入已经持久化 |
rename() | 名称切换对并发查找原子可见,不暴露替换到一半的状态 | 新名称关联掉电后一定存在 |
fsync(file_fd) | 文件数据和恢复该文件所需的元数据已按接口要求同步 | 新增目录项已经持久化 |
fsync(dir_fd) | 该目录此前的名称修改已按接口要求同步 | 其他目录或文件内容也已同步 |
write() 还可能发生短写:返回值大于 0 但小于请求长度。信号、磁盘空间不足、配额和 I/O 错误都可能让程序只写入一部分,或者让错误延迟到写回与同步阶段才报告。因此可靠代码必须循环写完全部数据,并检查 fsync()、close() 和 rename() 的返回值。
“成功后可以恢复”还取决于实际存储栈是否履行 flush 与 FUA 语义。若设备谎报完成、RAID 控制器在易失缓存尚未持久化时就确认 flush,或虚拟化层直接忽略 flush,文件系统无法仅靠软件顺序补救。没有掉电保护的缓存并非必然不安全,关键在于它是否在持久化请求返回前把数据真正写入非易失介质。应用协议建立在内核与设备正确实现持久化契约的前提上。
持久化也不是复制与备份。fsync() 可以让当前版本在本机介质上稳定,但不会保留被覆盖的旧历史,也不能抵抗介质整体损坏。需要处理这些风险时,还要使用校验、冗余、快照和独立备份。
文件同步
文件同步是把指定打开文件的脏状态提交到持久化存储并等待完成的操作,Linux 主要通过 fsync() 与 fdatasync() 提供这项接口。
I/O 系统已经区分普通 write() 返回与数据持久化,并简要介绍了这两个接口。本节继续划清它们各自覆盖的数据与元数据范围,以及为什么同步文件仍然没有同步父目录。
它们的 C 接口都接收一个 fd,成功返回 0,失败返回 -1 并设置 errno:
c
#include <unistd.h>
int fsync(int fd);
int fdatasync(int fd);fsync() 同步文件数据以及与文件恢复相关的元数据。fdatasync() 可以省略不影响后续读取数据的元数据,例如某些时间戳更新;若文件长度变化,长度仍必须同步,否则崩溃后无法读到新写入范围。具体文件系统和设备可以让两者成本接近,调用者不应假设 fdatasync() 必然便宜很多。
同步调用会沿写回路径提交脏页、等待 I/O,并在需要时让设备刷新易失性写缓存。返回成功以后,应用才获得该 fd 所覆盖状态的持久化确认。若较早的异步写回失败,错误也可能在这一步暴露:EIO 表示 I/O 错误,ENOSPC 表示空间耗尽,EDQUOT 表示用户或项目获分配的磁盘配额已经用尽。忽略返回值会把一次未确认提交误判成成功。
O_SYNC 和 O_DSYNC 把类似约束附加到每次写操作。前者接近每次写后完成 fsync() 所需的同步,后者接近数据完整性同步。它们适合必须逐次确认的记录,但每次写都等待持久化会限制吞吐。许多系统选择先批量写入,再用一次 fdatasync() 或 fsync() 形成组提交。
同步普通文件并不自动同步父目录。文件内容和 inode 长度属于文件对象,新增名称、删除名称和重命名属于目录对象。这个边界正是安全替换协议需要两类 fsync() 的原因。
原子替换
原子替换(atomic replacement)是先完整构造新文件,再用同一文件系统内的 rename() 一次性改变目标名称指向的更新协议。
直接用 open(path, O_TRUNC) 覆盖目标存在不可恢复窗口。截断一旦生效,旧数据已经失去;新数据若尚未写完就崩溃,目标只能留下空文件或部分内容。原子替换改用以下顺序:
- 在目标的同一目录创建唯一临时文件,并使用
O_EXCL防止误用已有名称。 - 循环处理短写,直到新内容全部写入临时文件。
- 对临时文件调用
fsync(),确认新内容与必要元数据持久化。 - 用
renameat()把临时名称原子替换为目标名称。 - 对父目录调用
fsync(),确认名称变更持久化。
临时文件必须与目标位于同一文件系统,因为普通 rename() 不能跨文件系统原子移动对象。若新文件还需要特定权限、所有者或 xattrs,这些元数据也应在文件 fsync() 之前设置;下面的示例只演示内容替换。创建模式 0644 会先经过进程的文件模式创建掩码,掩码中置位的权限会从创建模式中移除。
c
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
static int write_all(int fd, const char *data, size_t length)
{
size_t written = 0;
while (written < length) {
ssize_t n = write(fd, data + written, length - written);
if (n > 0) {
written += (size_t)n;
continue;
}
if (n < 0 && errno == EINTR)
continue;
if (n == 0)
errno = EIO;
return -1;
}
return 0;
}
static int split_path(const char *path, char **directory, const char **name)
{
const char *slash = strrchr(path, '/');
if (slash == NULL) {
*directory = strdup(".");
*name = path;
} else if (slash == path) {
*directory = strdup("/");
*name = slash + 1;
} else {
*directory = strndup(path, (size_t)(slash - path));
*name = slash + 1;
}
if (*directory == NULL)
return -1;
if (**name == '\0') {
errno = EINVAL;
free(*directory);
*directory = NULL;
return -1;
}
return 0;
}
int main(int argc, char **argv)
{
char *directory = NULL;
const char *target_name;
char temp_name[NAME_MAX + 1] = {0};
int directory_fd = -1;
int temp_fd = -1;
bool temp_created = false;
bool renamed = false;
int status = EXIT_FAILURE;
if (argc != 3) {
fprintf(stderr, "usage: %s PATH CONTENT\n", argv[0]);
return EXIT_FAILURE;
}
if (split_path(argv[1], &directory, &target_name) < 0) {
perror("split path");
goto out;
}
directory_fd = open(directory, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (directory_fd < 0) {
perror("open parent directory");
goto out;
}
for (unsigned int attempt = 0; attempt < 100; attempt++) {
int length = snprintf(temp_name, sizeof(temp_name),
".replace.tmp.%ld.%u", (long)getpid(), attempt);
if (length < 0 || (size_t)length >= sizeof(temp_name)) {
errno = ENAMETOOLONG;
perror("create temporary name");
goto out;
}
temp_fd = openat(directory_fd, temp_name,
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0644);
if (temp_fd >= 0) {
temp_created = true;
break;
}
if (errno != EEXIST) {
perror("open temporary file");
goto out;
}
}
if (temp_fd < 0) {
errno = EEXIST;
perror("create unique temporary file");
goto out;
}
if (write_all(temp_fd, argv[2], strlen(argv[2])) < 0) {
perror("write temporary file");
goto out;
}
if (fsync(temp_fd) < 0) {
perror("fsync temporary file");
goto out;
}
if (close(temp_fd) < 0) {
temp_fd = -1;
perror("close temporary file");
goto out;
}
temp_fd = -1;
if (renameat(directory_fd, temp_name, directory_fd, target_name) < 0) {
perror("rename temporary file");
goto out;
}
renamed = true;
if (fsync(directory_fd) < 0) {
perror("fsync parent directory");
goto out;
}
printf("replaced %s durably\n", argv[1]);
status = EXIT_SUCCESS;
out:
if (temp_fd >= 0)
close(temp_fd);
if (temp_created && !renamed && directory_fd >= 0)
unlinkat(directory_fd, temp_name, 0);
if (directory_fd >= 0)
close(directory_fd);
free(directory);
return status;
}程序先打开父目录,再使用 openat() 与 renameat(),因此后续操作都以同一个目录 fd 为起点。临时名称包含 PID 和重试编号,O_EXCL 确保调用不会打开已经存在的对象。失败路径只删除本次确实创建且尚未重命名的临时文件;一旦 renameat() 成功,即使目录 fsync() 随后失败,程序也不会谎称可以回滚已经可见的替换。
按课程环境中的命令编译并运行:
console
$ gcc -std=c17 -Wall -Wextra -Werror -O2 course/filesystem/code/durable_replace.c -o /tmp/durable_replace
$ mkdir -p /tmp/durable-demo
$ /tmp/durable_replace /tmp/durable-demo/settings.json '{"version":2}'
replaced /tmp/durable-demo/settings.json durably
$ cat /tmp/durable-demo/settings.json
{"version":2}rename() 保证的是命名空间可见性的原子性。并发读取者若在替换前打开目标,会继续持有旧 inode;替换后再打开的读取者会得到新 inode;不会有一个时刻因为替换步骤而让目标名称临时消失。若旧目标还有其他硬链接,那些名称仍指向旧 inode,只有目标目录项被换成新 inode。但这些保证本身没有说明崩溃后目录项是否仍在,因此协议还没有结束。
日志文件系统与写时复制文件系统在底层采用不同更新路线。日志通常先持久化可重放记录,提交后再把内容写到最终位置;写时复制先把新数据和元数据写到新块,再切换能够找到新版本的根引用。前者会增加日志写入,后者可能带来树节点写放大、碎片和旧版本回收成本;具体结果还受数据日志范围、校验和、快照和设备 flush 实现影响。两者都可以维持文件系统内部结构一致,却都不知道应用希望 settings.json 以哪一版作为完整业务状态,因此都不能替代临时文件、文件 fsync()、原子 rename() 与目录 fsync() 组成的应用协议。
目录持久化
目录持久化(directory durability)是把创建、删除、链接或重命名产生的目录项变化同步到非易失存储的保证。
对临时文件执行 fsync() 只能确认临时 inode 和内容。renameat() 随后修改父目录中的名称关联:删除临时名称,并让目标名称指向新 inode。若这次目录修改仍只在内存或设备缓存中,掉电恢复后可能重新看到旧目标,或者在某些文件系统语义下看不到预期的新名称。对父目录 fd 执行 fsync() 才把名称更新纳入应用确认的持久化边界。
`rename()` 已经是原子操作,为什么还要同步目录?
原子性回答并发观察问题:替换发生时,其他进程不会看到目标名称处于“先删除、后创建”的中间状态。持久性回答崩溃恢复问题:系统调用返回后掉电,重启时是否仍能看到这次名称变化。
两者相互独立。rename() 可以在内存中的目录状态上原子完成并立即返回,而脏目录页稍后才写入设备。fsync(dir_fd) 的作用不是让替换更原子,而是确认这次原子替换已经持久化。
如果重命名跨越同一文件系统内的两个父目录,源目录删除旧名称,目标目录增加新名称。严格协议需要同步两个受影响目录;若二者相同,只需同步一次。新建文件后希望名称稳定,需要同步文件再同步父目录;删除文件后希望删除结果稳定,需要同步父目录;创建新的目录层次时,每个尚未持久化的父子关联都要纳入协议。
renameat2() 还提供额外的原子命名操作。RENAME_NOREPLACE 在目标已存在时失败,避免覆盖;RENAME_EXCHANGE 原子交换两个路径。它们改变可见性语义,但不取消对文件内容和相关目录执行同步的持久化要求。
POSIX 规定 rename() 的运行时可见性,却没有统一规定断电后的目录恢复状态;目录 fsync() 的支持和具体保证也取决于文件系统实现。Linux 本地通用文件系统通常为这种协议提供所需语义,不支持同步目录的实现可能返回 EINVAL。网络文件系统、用户态文件系统或特殊存储还可能有不同错误与持久化模型。构建跨平台存储组件时,必须验证目标文件系统,而不是只根据函数名推断保证。
故障注入
故障注入(fault injection)是在受控位置主动制造系统调用失败、I/O 错误或崩溃,以验证恢复结果是否满足协议不变量的测试方法。
在目标文件系统保证恢复后的 rename() 仍以完整命名事务出现、支持文件与目录 fsync(),并且下层正确执行 flush 的前提下,安全替换协议至少应验证两个性质:任何未报告成功的执行崩溃后,目标只能是完整旧版本或完整新版本,不应出现混合与截断内容;程序报告成功后再崩溃,目标必须是完整新版本。下表只描述目标路径的内容,失败执行仍可能遗留临时文件,临时文件清理需要单独检查。
| 故障位置 | 允许恢复结果 | 禁止结果 |
|---|---|---|
| 临时文件创建前 | 旧版本 | 部分新版本 |
| 临时文件写入中 | 旧版本 | 目标被截断 |
文件 fsync() 前 | 旧版本 | 目标混合内容 |
renameat() 前 | 旧版本 | 目标缺失 |
renameat() 后、目录 fsync() 前 | 旧版本或新版本,取决于已落盘状态 | 部分新版本 |
目录 fsync() 成功后 | 新版本 | 旧版本、目标缺失或部分内容 |
只在关键行后调用 _exit() 不能完整模拟掉电。进程退出后内核仍在运行,后台写回可能继续把脏页写入磁盘;真正掉电会同时丢失内核内存和未保护的设备缓存。进程级注入适合验证清理与错误处理,崩溃一致性则需要能够丢弃未持久化状态的环境。
Linux 上可以分层测试:用 strace 确认系统调用顺序;用测试包装函数或文件系统故障注入让 write()、fsync() 返回错误;再用设备映射器(device-mapper)在真实块设备之上建立可控的虚拟块设备。dm-flakey 可以周期性拒绝或丢弃 I/O,dm-error 直接返回 I/O 错误,dm-log-writes 则记录写入与 flush 标记,供测试程序重放到某个持久化边界。还可以在虚拟机中保存初始磁盘快照,在指定 I/O 边界强制停止虚拟机,再用已确认持久化的写入构造崩溃镜像。每次恢复后不仅检查文件内容,还要运行文件系统检查并验证临时文件、权限和目录项状态。
测试必须保留同步调用本身。若测试环境把所有写都变成同步写,可能掩盖缺少 fsync() 的协议错误;若宿主机缓存让虚拟磁盘 flush 失效,也可能制造与真实部署不同的保证。故障模型应与生产存储栈相匹配,并记录设备是否具有断电保护缓存。
小结
| 概念 | 说明 |
|---|---|
| 持久化边界 | 应用可以确认更新在随后崩溃后仍可恢复的执行点 |
fsync() | 同步文件数据及恢复该文件所需的元数据,并等待完成 |
fdatasync() | 允许省略不影响数据读取的部分元数据同步 |
| 原子替换 | 同目录构造完整临时文件,再用 rename() 切换目标名称 |
| 日志与写时复制 | 用重放记录或新块根切换维护底层一致性,但都不能替代应用同步协议 |
| 目录持久化 | 用目录 fsync() 确认名称创建、删除或替换已经落盘 |
| 故障注入 | 在受控失败点验证所有恢复状态都符合协议不变量 |
崩溃一致性不是单个系统调用的属性,而是文件内容、inode、目录项与设备持久化顺序共同形成的协议。只有每一步的返回值都被检查,最后的成功才具有明确含义。
接口与验证入口:
fsync(2):文件与目录同步语义rename(2):原子名称替换与renameat2()标志open(2):O_SYNC、O_DSYNC、O_EXCL与打开文件对象- Linux fault injection:内核故障注入框架
- Device-mapper log-writes:记录块写入并构造崩溃状态
- CrashMonkey:文件系统崩溃一致性测试工具
至此,存储路径已经从块 I/O 延伸到应用持久化协议。保护与安全将继续分析进程即使能够找到某个对象,内核为何仍要依据身份、权限和安全策略决定它是否可以操作该对象。