跳到内容

第 3 卷 / 第 13 章 / 第 2 课

13.2 文件系统与崩溃恢复 ​

write 返回成功,只说明内核接受了这些字节;fclose 返回成功,也不等于数据已经穿过页缓存、设备缓存并稳定保存。文件系统还要协调数据块、空间分配信息、inode 与目录项。断电可能发生在这些更新之间,崩溃一致性因此成为文件系统设计的核心问题。

文件怎样从路径落到 inode 与数据块,见文件系统结构。本节集中回答另一个问题:更新到一半崩溃后,文件系统如何回到可解释的状态,应用又该怎样声明自己的持久化边界。

1. 一次写入经过哪些缓冲层 ​

以 fprintf 为例,数据可能依次经过:

text
应用数据
  ↓
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. 日志文件系统:先留下可重放的意图 ​

日志文件系统把一组元数据修改组织为事务。一个简化流程是:

text
准备事务修改
    ↓
把日志记录写入 journal
    ↓
持久化 commit 记录
    ↓
把修改写回最终位置(checkpoint)
    ↓
回收对应日志空间

恢复时:

  • 没有完整提交记录的事务被忽略;
  • 已提交但尚未全部写回最终位置的事务可以重放;
  • 重放必须设计成重复执行仍安全。

日志具体记录什么并不统一。元数据日志只保护文件系统结构;数据可能走普通写回路径。有些模式会约束数据先于引用它的元数据提交,以免恢复后暴露旧块内容;另一些模式允许更宽松的顺序。不能只凭“使用日志文件系统”就推断应用数据一定已持久化。

日志也不是应用事务。两次 write、一次 rename 和一条数据库记录,可能落在不同的文件系统事务中。应用若需要“全有或全无”,仍要选择协议或使用提供事务能力的存储系统。

4. 写时复制:发布一棵新的状态树 ​

Copy-on-Write(COW)文件系统不在原位置覆盖活跃块,而是把修改后的数据与元数据写到新位置,最后发布新的根或检查点:

text
旧根 ──> 旧元数据 ──> 旧数据

                  写入新数据
                       ↑
新根 ──> 新元数据 ─────┘
  ↑
最后发布

若发布前崩溃,旧根仍描述旧状态;发布后恢复,则从新根继续。真正的实现还要保护根指针本身,管理引用计数、空间回收、校验和与事务组,远不只是“原子改一个 inode 指针”。

COW 便于制作快照:新旧根可以共享未修改块,只有后续差异占用额外空间。但快照会延长旧块生命周期,随机覆盖也会变成新位置写入,可能增加碎片和写放大。

日志与 COW 不是绝对互斥的标签。一个系统可以让部分组件写日志、部分结构使用 COW,或为不同操作提供不同持久化协议。理解更新顺序比背文件系统阵营更重要。

5. 校验和解决检测,冗余才提供修复来源 ​

数据块校验和能检测许多读取损坏,但碰撞或数据与校验一起被一致地改错仍可能漏检。为了避免数据和它自己的校验和一起被错误覆盖,端到端设计通常让校验信息位于受不同更新路径保护的元数据中。

检测到校验失败后,系统只有在存在另一份可信副本时才能自动修复,例如镜像、奇偶校验重建结果或远端副本。没有冗余时,校验和能明确告诉你“数据坏了”,却不能推导出原来的字节。

定期 scrub 会主动读取并验证冷数据,尽早暴露潜伏错误。它与备份恢复演练解决不同问题:scrub 检查当前副本,备份用于回到另一个时间点或故障域。

6. fsck 是恢复工具,不是版本控制系统 ​

非日志文件系统或恢复后仍存疑的文件系统,可以扫描全局结构并检查不变量。典型工作包括:

  • 对照块引用与空闲空间信息;
  • 检查目录项指向的对象;
  • 修正可确定的链接计数;
  • 隔离无法重新归属的对象;
  • 标记坏块或不可恢复结构。

扫描成本通常随文件系统规模与对象数量增长。日志恢复把常见崩溃后的工作限制在近期事务,但并不意味着永远无需一致性检查。

fsck 也无法凭结构猜回丢失的应用内容。如果两个版本都形式合法,它不知道用户想保留哪一个;若数据块已被覆盖,没有备份就没有恢复来源。

7. 原子可见与崩溃后持久是两件事 ​

rename 在同一文件系统内可以为读者提供原子的名字切换:读者看到旧文件或新文件,不会看到半个目录项更新。但“其他进程立即看到完整新版本”与“断电重启后新版本一定还在”不是同一个保证。

在 Linux/POSIX 风格环境里,替换一个小状态文件的常见协议是:

  1. 在目标目录创建临时文件;
  2. 写完全部内容并检查短写;
  3. fsync 临时文件;
  4. 关闭它;
  5. 在同一文件系统内 rename 到目标名;
  6. fsync 目标目录,持久化名字变更。

保存下面的 Linux 示例为 durable-replace.c,用 cc -std=c17 -Wall -Wextra -Werror durable-replace.c -o durable-replace 编译,在专用测试目录中运行。它演示单写者替换 state.txt,要求当前目录可信且调用期间不变;路径参数只接受当前目录中的文件名。权限继承、多写者和启动清理仍由应用定义。

c
#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:

text
事务 A ┐
事务 B ├─> 先追加各自日志记录 ─> 一次同步 ─> 分别确认各事务提交
事务 C ┘

这把一次持久化成本分摊给多个事务,同时仍要求确认前相关日志已经越过持久化边界。具体系统还会处理日志序号、检查点、数据页写回和副本确认;文件系统 journal 不能替代数据库 WAL,因为两者维护的状态不变量不同。

性能测试不能照抄固定的“每次 fsync 多少微秒”。结果受介质、队列深度、虚拟化、文件系统、挂载选项、设备缓存和同步并发影响。应在目标环境测量延迟分布,并在测试前明确数据安全假设。

9. 崩溃测试怎样做得有意义 ​

只正常退出再检查文件,测不到崩溃窗口。更有价值的测试会在协议的不同阶段强制终止进程或模拟掉电,然后验证:

  • 重启后只能看到旧版本或完整新版本;
  • 文件系统结构仍可挂载和检查;
  • 临时文件可安全识别与清理;
  • 重放操作是幂等的;
  • 同步调用的错误会向上层传播;
  • 恢复过程不会再次破坏原本有效的状态。

进程被 SIGKILL 只能模拟用户态突然停止,不能完全模拟整机断电和设备缓存丢失。文件系统级测试通常需要虚拟块设备、故障注入或专用断电测试环境,并避免拿真实数据盘做实验。

10. 小结 ​

崩溃一致性是对更新顺序的约束:

  • stdio、页缓存和设备缓存对应不同完成边界;
  • 日志通过提交记录让近期修改可重放;
  • COW 先构造新状态,再发布新的根;
  • 校验和负责发现损坏,修复仍需要冗余副本;
  • rename 的原子可见性不等于断电后的持久性;
  • 应用需要明确 fsync 文件、发布名字并同步目录的协议;
  • 数据库 WAL 与文件系统恢复各自保护不同的不变量。

下一节讨论存储冗余与备份:RAID 能承受哪些磁盘故障,为什么它仍然不能代替可恢复的备份。

对照存储接口与协议 ​

Built with VitePress | Software Systems Atlas