13.2 文件系统与崩溃恢复
write 返回成功,只说明内核接受了这些字节;fclose 返回成功,也不等于数据已经穿过页缓存、设备缓存并稳定保存。文件系统还要协调数据块、空间分配信息、inode 与目录项。断电可能发生在这些更新之间,崩溃一致性因此成为文件系统设计的核心问题。
文件怎样从路径落到 inode 与数据块,见文件系统结构。本节集中回答另一个问题:更新到一半崩溃后,文件系统如何回到可解释的状态,应用又该怎样声明自己的持久化边界。
1. 一次写入经过哪些缓冲层
以 fprintf 为例,数据可能依次经过:
应用数据
↓
C stdio 缓冲区
↓ fflush / 缓冲区满 / fclose
write 系统调用
↓
内核页缓存
↓ 回写 / fsync / fdatasync
块层、设备队列与设备缓存
↓
持久介质每一层都能合并和重排写入。这样做能显著提高吞吐,却意味着几个常见操作的承诺不同:
| 操作 | 可以说明什么 | 不能据此断言什么 |
|---|---|---|
fprintf 成功 | 数据进入 stdio 的处理范围 | 已进入内核或介质 |
fflush 成功 | stdio 缓冲已提交给底层流 | 已持久保存 |
write 成功 | 内核接受了返回字节数 | 已写满请求;已经落盘 |
fclose 成功 | stdio 刷新并关闭描述符 | 自动执行了 fsync |
fsync 成功 | 请求同步文件数据及所需元数据 | 整个业务事务原子完成 |
write 可能只写入一部分,程序必须处理短写;异步 I/O 错误还可能直到后续 fsync 或 close 才被报告。fdatasync 同步文件数据及恢复这些数据所必需的元数据,可能省略与内容无关的部分元数据,但具体收益取决于文件系统和工作负载。
持久化承诺还跨越操作系统、驱动器与存储设备。程序只能依赖平台公开的同步接口和正确配置;若设备错误地报告缓存已经稳定,文件系统无法凭空兑现承诺。
2. 崩溃为何会留下不一致状态
向文件末尾追加数据,可能需要修改:
- 新数据块的内容;
- 空闲空间位图或其他分配结构;
- inode 的大小与块映射;
- 相关时间戳;
- 某些情况下的目录或扩展属性元数据。
这些变化无法假设为一个不可分割的介质写。设备可能重排请求,单个较大写入也可能撕裂。若 inode 已指向新块而新数据尚未稳定,读取可能看到旧内容或无效内容;若空间标为占用却没有 inode 引用,就会泄漏可用空间。
因此文件系统维护的是一组不变量,例如:
- 一个已分配块不会同时属于两个不允许共享的文件;
- inode 引用的块应在分配结构中标为已使用;
- 目录项应指向有效对象;
- 链接计数与命名关系应相容;
- 空闲空间不能与活跃数据重叠。
崩溃恢复首先保护这些结构不变量。它不自动保证应用的一组业务写入具有事务语义。
3. 日志文件系统:先留下可重放的意图
日志文件系统把一组元数据修改组织为事务。一个简化流程是:
准备事务修改
↓
把日志记录写入 journal
↓
持久化 commit 记录
↓
把修改写回最终位置(checkpoint)
↓
回收对应日志空间恢复时:
- 没有完整提交记录的事务被忽略;
- 已提交但尚未全部写回最终位置的事务可以重放;
- 重放必须设计成重复执行仍安全。
日志具体记录什么并不统一。元数据日志只保护文件系统结构;数据可能走普通写回路径。有些模式会约束数据先于引用它的元数据提交,以免恢复后暴露旧块内容;另一些模式允许更宽松的顺序。不能只凭“使用日志文件系统”就推断应用数据一定已持久化。
日志也不是应用事务。两次 write、一次 rename 和一条数据库记录,可能落在不同的文件系统事务中。应用若需要“全有或全无”,仍要选择协议或使用提供事务能力的存储系统。
4. 写时复制:发布一棵新的状态树
Copy-on-Write(COW)文件系统不在原位置覆盖活跃块,而是把修改后的数据与元数据写到新位置,最后发布新的根或检查点:
旧根 ──> 旧元数据 ──> 旧数据
写入新数据
↑
新根 ──> 新元数据 ─────┘
↑
最后发布若发布前崩溃,旧根仍描述旧状态;发布后恢复,则从新根继续。真正的实现还要保护根指针本身,管理引用计数、空间回收、校验和与事务组,远不只是“原子改一个 inode 指针”。
COW 便于制作快照:新旧根可以共享未修改块,只有后续差异占用额外空间。但快照会延长旧块生命周期,随机覆盖也会变成新位置写入,可能增加碎片和写放大。
日志与 COW 不是绝对互斥的标签。一个系统可以让部分组件写日志、部分结构使用 COW,或为不同操作提供不同持久化协议。理解更新顺序比背文件系统阵营更重要。
5. 校验和解决检测,冗余才提供修复来源
数据块校验和能检测许多读取损坏,但碰撞或数据与校验一起被一致地改错仍可能漏检。为了避免数据和它自己的校验和一起被错误覆盖,端到端设计通常让校验信息位于受不同更新路径保护的元数据中。
检测到校验失败后,系统只有在存在另一份可信副本时才能自动修复,例如镜像、奇偶校验重建结果或远端副本。没有冗余时,校验和能明确告诉你“数据坏了”,却不能推导出原来的字节。
定期 scrub 会主动读取并验证冷数据,尽早暴露潜伏错误。它与备份恢复演练解决不同问题:scrub 检查当前副本,备份用于回到另一个时间点或故障域。
6. fsck 是恢复工具,不是版本控制系统
非日志文件系统或恢复后仍存疑的文件系统,可以扫描全局结构并检查不变量。典型工作包括:
- 对照块引用与空闲空间信息;
- 检查目录项指向的对象;
- 修正可确定的链接计数;
- 隔离无法重新归属的对象;
- 标记坏块或不可恢复结构。
扫描成本通常随文件系统规模与对象数量增长。日志恢复把常见崩溃后的工作限制在近期事务,但并不意味着永远无需一致性检查。
fsck 也无法凭结构猜回丢失的应用内容。如果两个版本都形式合法,它不知道用户想保留哪一个;若数据块已被覆盖,没有备份就没有恢复来源。
7. 原子可见与崩溃后持久是两件事
rename 在同一文件系统内可以为读者提供原子的名字切换:读者看到旧文件或新文件,不会看到半个目录项更新。但“其他进程立即看到完整新版本”与“断电重启后新版本一定还在”不是同一个保证。
在 Linux/POSIX 风格环境里,替换一个小状态文件的常见协议是:
- 在目标目录创建临时文件;
- 写完全部内容并检查短写;
fsync临时文件;- 关闭它;
- 在同一文件系统内
rename到目标名; fsync目标目录,持久化名字变更。
保存下面的 Linux 示例为 durable-replace.c,用 cc -std=c17 -Wall -Wextra -Werror durable-replace.c -o durable-replace 编译,在专用测试目录中运行。它演示单写者替换 state.txt,要求当前目录可信且调用期间不变;路径参数只接受当前目录中的文件名。权限继承、多写者和启动清理仍由应用定义。
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static int write_all(int fd, const void *buffer, size_t length) {
const unsigned char *cursor = buffer;
while (length > 0) {
ssize_t written = write(fd, cursor, length);
if (written > 0) {
cursor += (size_t)written;
length -= (size_t)written;
} else if (written < 0 && errno == EINTR) {
continue;
} else {
if (written == 0) errno = EIO;
return -1;
}
}
return 0;
}
enum ReplaceResult {
REPLACE_FAILED = -1,
REPLACE_OK = 0,
REPLACE_PUBLISHED_WITH_ERROR = 1
};
static int durable_replace(const char *target,
char *temporary_template,
const void *data,
size_t length) {
if (target == NULL || temporary_template == NULL || *target == '\0'
|| strchr(target, '/') != NULL
|| strchr(temporary_template, '/') != NULL) {
errno = EINVAL;
return REPLACE_FAILED;
}
int directory = open(".", O_RDONLY | O_DIRECTORY);
if (directory < 0) return REPLACE_FAILED;
int fd = mkstemp(temporary_template);
if (fd < 0) {
int error = errno;
close(directory);
errno = error;
return REPLACE_FAILED;
}
if (write_all(fd, data, length) != 0 || fsync(fd) != 0) goto cleanup;
if (close(fd) != 0) {
fd = -1;
goto cleanup;
}
fd = -1;
if (rename(temporary_template, target) != 0) goto cleanup;
int error = 0;
if (fsync(directory) != 0) error = errno;
if (close(directory) != 0 && error == 0) error = errno;
if (error != 0) {
errno = error;
return REPLACE_PUBLISHED_WITH_ERROR;
}
return REPLACE_OK;
cleanup:
{
int saved_errno = errno;
if (fd >= 0) close(fd);
unlink(temporary_template);
close(directory);
errno = saved_errno;
return REPLACE_FAILED;
}
}
int main(void) {
static const char payload[] = "version=2\n";
char temporary[] = "state.txt.tmp.XXXXXX";
int result = durable_replace("state.txt", temporary,
payload, sizeof payload - 1);
if (result != REPLACE_OK) {
perror(result == REPLACE_PUBLISHED_WITH_ERROR
? "published; final sync/close failed" : "not published");
return EXIT_FAILURE;
}
puts("state.txt replaced");
return EXIT_SUCCESS;
}返回值有三个含义:0 表示同步与关闭均成功;-1 表示尚未成功发布目标;1 表示 rename 已成功,但目录同步或关闭报告错误。第三种情况下读者已经可能看到新文件,不能再把失败解释成“目标没变”,也不能直接删除目标当回滚。这里针对本地 Linux 文件系统;NFS 等远端故障可能连 rename 的结果都存在歧义。
目录 fd 在写入前打开,错误清理保留原始 errno;Linux close 报错后不重试同一 fd,以免误关复用的描述符。真实应用还应:
- 用目录描述符和
openat一族接口约束路径解析; - 明确目标权限、所有者和扩展属性;
- 处理多个写者的并发协议;
- 对错误路径和启动时遗留临时文件制定规则;
- 在平台不支持目录
fsync时采用其官方持久化方案。
不要把这段协议泛化成“任何文件系统和硬件上绝对不会丢一个比特”。它表达的是应用应请求并检查的持久化边界。
8. 数据库怎样用 group commit 分摊同步成本
持久化同步可能包含设备排队和缓存刷新,固定成本通常高于复制几个字节。数据库常使用 write-ahead log(WAL)与 group commit:
事务 A ┐
事务 B ├─> 先追加各自日志记录 ─> 一次同步 ─> 分别确认各事务提交
事务 C ┘这把一次持久化成本分摊给多个事务,同时仍要求确认前相关日志已经越过持久化边界。具体系统还会处理日志序号、检查点、数据页写回和副本确认;文件系统 journal 不能替代数据库 WAL,因为两者维护的状态不变量不同。
性能测试不能照抄固定的“每次 fsync 多少微秒”。结果受介质、队列深度、虚拟化、文件系统、挂载选项、设备缓存和同步并发影响。应在目标环境测量延迟分布,并在测试前明确数据安全假设。
9. 崩溃测试怎样做得有意义
只正常退出再检查文件,测不到崩溃窗口。更有价值的测试会在协议的不同阶段强制终止进程或模拟掉电,然后验证:
- 重启后只能看到旧版本或完整新版本;
- 文件系统结构仍可挂载和检查;
- 临时文件可安全识别与清理;
- 重放操作是幂等的;
- 同步调用的错误会向上层传播;
- 恢复过程不会再次破坏原本有效的状态。
进程被 SIGKILL 只能模拟用户态突然停止,不能完全模拟整机断电和设备缓存丢失。文件系统级测试通常需要虚拟块设备、故障注入或专用断电测试环境,并避免拿真实数据盘做实验。
10. 小结
崩溃一致性是对更新顺序的约束:
- stdio、页缓存和设备缓存对应不同完成边界;
- 日志通过提交记录让近期修改可重放;
- COW 先构造新状态,再发布新的根;
- 校验和负责发现损坏,修复仍需要冗余副本;
rename的原子可见性不等于断电后的持久性;- 应用需要明确
fsync文件、发布名字并同步目录的协议; - 数据库 WAL 与文件系统恢复各自保护不同的不变量。
下一节讨论存储冗余与备份:RAID 能承受哪些磁盘故障,为什么它仍然不能代替可恢复的备份。