Skip to content

第13章:文件系统与崩溃恢复——你的文件真的"安全"吗


plaintext
┌──────────────────────────────────────────────────────────────┐
│  元数据卡                                                     │
│  ├─ 主题:文件系统与崩溃恢复                                   │
│  ├─ 难度:★★★★☆                                               │
│  ├─ 前置要求:第6章(虚拟内存)、第8章(进程上下文)              │
│  ├─ 核心概念:inode / Journaling / COW / FSCK / RAID           │
│  ├─ 主语言:C (POSIX)                                          │
│  └─ 预计阅读:45 min                                            │
└──────────────────────────────────────────────────────────────┘

你在哪

"地心里存储着海量的数据——文件系统是它们的档案馆。但如果突然断电了怎么办?文件写到一半,系统崩溃了,数据会变成什么样?"

你写过无数次这样的代码:

c
FILE *fp = fopen("data.txt", "w");
fprintf(fp, "Hello, world!\n");
fclose(fp);

跑完你关掉编辑器,以为数据已经"稳稳地"写在了磁盘上。

真的吗?

如果在你刚写完 fprintf() 的那一刻——电源线被踢掉了——你的文件还在吗?一半在?还是在?完全不在?

这个问题比你想象的复杂得多。现代文件系统用了各种诡异的招数来保证你的数据在断电后不会变得一团糟:日志、写时拷贝、校验和。但没有一个方案是完美的——每个方案都有自己妥协的地方。


你的任务

本章分层

  • 必读(文件系统基础):文件系统的三层抽象;inode 的作用与结构;崩溃一致性问题的本质
  • 必读(可靠性/崩溃恢复):Journaling 与 COW 两种技术路线的对比
  • 深水区:RAID 各等级的容错模型与选型;FSCK 的工作原理与局限性 本章不会要求你掌握
  • ext4 的 extent 树和间接块指针细节
  • ZFS/Btrfs 的 COW 事务组实现
  • RAID 控制器的固件级配置

学完本章后,你应该能:

  1. 从裸磁盘到底层文件系统抽象:解释块、inode、目录项、超级块各自的作用
  2. 理解崩溃一致性问题:为什么写入一个文件至少需要多个独立的磁盘 I/O?
  3. 对比 FAT / ext4 / NTFS 的设计哲学差异
  4. 说清日志(Journaling)和写时拷贝(COW)两种解决崩溃一致性的技术路线
  5. 理解 FSCK 的工作原理和局限性
  6. 解释 RAID 0/1/5/6/10 的容错模型和代价
  7. 回答一个灵魂拷问fclose() 之后,数据就一定在磁盘上了吗?

第一部分:文件系统基础


遭遇战——文件系统三层抽象

文件系统的三层抽象

从扇区到文件,操作系统需要做好几层包装:

┌─────────────────────────────────┐
│  文件/目录层                     │  ← 你看到的:/home/user/hello.txt
│  inode + dentry + 文件路径       │
├─────────────────────────────────┤
│  块层                            │  ← 块设备驱动:按块(4KB)读写
│  块分配器、空闲空间管理          │
├─────────────────────────────────┤
│  磁盘层                          │  ← 物理扇区(512B ~ 4KB)
│  CHS / LBA、DMA 传输            │
└─────────────────────────────────┘

inode 是文件系统的灵魂——它是文件的"身份证":

c
// 简化的 ext4 inode
struct ext4_inode {
    __u16  i_mode;        // 文件类型 + 权限
    __u16  i_uid;         // 所有者用户 ID
    __u32  i_size;        // 文件大小(字节)
    __u32  i_atime;       // 最后访问时间
    __u32  i_ctime;       // inode 修改时间
    __u32  i_mtime;       // 文件内容修改时间
    __u32  i_dtime;       // 删除时间
    __u16  i_gid;         // 所有组
    __u16  i_links_count; // 硬链接计数
    __u32  i_blocks;      // 占用的块数(512B 为单位)
    __u32  i_flags;       // 标志位

    // 数据块指针数组 —— 嵌入式 + 间接寻址
    __u32  i_block[EXT4_N_BLOCKS];  // 12直接 + 1间接 + 1二级 + 1三级
};

i_block 的设计很有趣:

  • 前 12 个指针直接指向数据块(小文件不用额外读取指针块)
  • 第 13 个指向一个"间接块"——里面存储的是指向数据块的指针数组
  • 第 14 个二级间接——指向"指向数据块的指针块"的指针块
  • 第 15 个三级间接——就是你把"指向指针块的指针块的"再套一层

这意味着一个文件可以大到荒谬的程度(ext4 支持最大 16 TB 的单文件)。但大多数文件很小,前 12 个直接指针就够用了——这是文件系统对局部性的经典利用。

第二部分:可靠性/崩溃恢复


核心问题:为什么写文件不是"一个操作"?

当你把"Hello"追加到一个文件中,看起来是一次操作——但对于文件系统,它需要完成至少三次独立的磁盘写入:

c
// 假设当前 hello.txt 的大小是 5 字节
// 在新磁盘块上写入 "Hello, world!\n"(14 字节)
char *data = "Hello, world!\n";

// 需要:
// 1️⃣ 写数据块(写入 "Hello, world!\n" 到某个空闲磁盘块)
write_block(new_data_block, data);

// 2️⃣ 写 inode 表(更新文件大小、数据块指针、时间戳)
update_inode(hello_inode, size=14, block_ptr);

// 3️⃣ 写位图(标记该数据块为"已使用")
update_bitmap(mark_block_used);

问题是:磁盘不能在一次原子操作中写三样东西。如果写完数据块后————断电了,会发生什么?

场景 A:写了数据块 ✓ | 没写 inode ❌
→ 磁盘上躺着正确的数据,但文件系统完全不知道
→ 这是个"孤儿数据块"——永久泄露的空间

场景 B:写了 inode ✓ | 没写数据块 ❌
→ 文件系统以为文件有内容
→ 去读时发现 inode 指向的块里是垃圾数据

场景 C:写了位图 ✓ | 其他没写 ❌
→ 位图标记块已用,但 inode 没指向它
→ 也是空间泄露——而且谁也找不回来

这十六个字总结了一切:崩溃一致性(crash consistency) ——如何保证系统崩溃后,文件系统的元数据(inode、目录、位图)和数据块的相对关系仍然一致。

💡 洞见: 你在 C 里写 fwrite()write() 时,数据先进入页缓存(page cache)——内核的一块内存区域。sync() / fsync() / fclose() 才触发真正的磁盘写入。也就是说,你的 C 代码跑完 fclose() 时,数据可能还在内存里,离磁盘还有十万八千里。


获得技能——三种崩溃恢复方案

方案 1:FSCK——丑但有效

经典 Unix 方案——不做任何预防,崩溃后跑 fsck(file system check)。

崩溃 → 重启 → fck → 修复 → 加载文件系统

FSCK 扫描整个文件系统,检查矛盾:

检查清单:
📋 位图标记已使用的块 → 是否真的被某个 inode 引用?
📋 inode 中标记的数据块 → 是否在位图中标记为已使用?
📋 目录项 → 指向的 inode 是否存在且类型正确?
📋 硬链接计数 → 是否与目录中实际引用数一致?

问题在于规模——2010 年的 2 TB 磁盘跑 FSCK 可能要几小时。到了 2024 年,冷数据服务器 FSCK 跑一两天不稀奇。对于现代生产环境,这根本不可接受。

核心缺陷:FSCK 只能修复元数据不一致,无法修复数据内容损坏。如果数据块里写了一团乱,FSCK 检查不出来——它只关心"谁指向谁"的一致性。

方案 2:Journaling(日志)——ext3 / ext4 的选择

思路很简单:写数据之前,先把"我要写什么"记在日志里。 这样即使系统崩溃,重启后只需要检查日志——而不是扫描整个文件系统。

日志(Journal)是一个循环缓冲的磁盘区域:
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ Tx1 │ Tx2 │ Tx3 │ Tx4 │     │     │     │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
         ↑                          ↑
    最早未提交                  当前写入位置

一次文件写入的日志化事务流程:

1️⃣ 开始事务(Begin Tx)—— 在日志中标记事务开始
2️⃣ 写日志数据 —— 把要修改的 inode、位图、数据块的"拷贝"写入日志
3️⃣ 提交事务(Commit Tx)—— 在日志末尾写入 COMMIT 标记
   ──── 到这一步,事务就是"安全的"了 ────
4️⃣ 检查点(Checkpoint)—— 把日志中的修改真正应用到磁盘原位置
5️⃣ 回收日志(Reclaim)—— 标记日志空间可重用

如果宕机发生在 Step 3 之后:重启后扫描日志,发现有 COMMIT 标记的事务 → 重放(redo)这些修改。

如果宕机发生在 Step 3 之前:该事务被丢弃,文件系统状态保持原样。

ext3 支持三种 journaling 模式:

模式日志内容安全性性能
Journal数据 + 元数据最高(数据也日志化)最慢(写两次)
Ordered(默认)只记元数据;先写数据块高(数据先落地)中等
Writeback只记元数据;数据随意写低(数据可乱序)最快
bash
# 查看当前 ext4 的 journaling 模式
$ tune2fs -l /dev/sda1 | grep "Default mount options"
Default mount options:    journal_data_ordered

# 改成最安全的模式
$ sudo tune2fs -o journal_data /dev/sda1

ext4 额外特性:内联数据与纳秒时间戳

相比 ext3,ext4 还有几个实用创新:

  • Extents:不再用间接块指针的"逐个指向"方式,而是用一个 (起始块号, 长度) 的区间描述连续块——这就像用 [0, 1023] 代替 1024 个单独的指针。大文件的元数据开销骤降。
  • 纳秒时间戳stat 输出的时间精度从 1 秒提升到 1 纳秒,这对需要精确排序的构建系统和日志系统意义巨大。
  • 预分配fallocate() 系统调用可以预先分配磁盘空间而不写入数据——对数据库和视频下载来说,减少了在写入过程中磁盘空间不足的风险。

💡 认知刷新: ext4 的 Ordered 模式下,数据块先于元数据日志写入磁盘。这保证了:即使系统崩溃,最坏情况也就是丢失一些文件末尾的数据——文件系统的元数据结构不会破坏。你不会因为断电而丢失整个文件,最多丢最后几行日志。

方案 3:Copy-on-Write (COW)——ZFS / Btrfs 的路线

Journaling 的问题是"写两次"——日志一次,原位一次。COW 的思路完全不同:永远不原地修改数据,而是写到一个新位置。写完之后,用一个原子操作更新指针。

更新前:
文件 inode → 数据块 A

更新时:
写新数据 → 数据块 B(COW:Copy A → B,在 B 上修改)
更新 inode → 指向 B(这是唯一的原地写——但极小)

更新后:
文件 inode → 数据块 B(原子指针切换)
数据块 A → 变成空闲块

这带来了几个超能力:

1. 快照(Snapshot)

时间 T0:inode → 数据块 A
时间 T1:创建快照 → 把 inode 复制一份(快照 inode 指向 A)
时间 T2:修改文件 → inode 指向新块 B
         快照 inode 仍然指向 A(A 不会被回收)

效果:瞬间得到一个"冻结版"文件,不占额外空间
      (COW 保证只有真正不同的数据占用额外空间)

2. 校验和(Checksum)—— 沉默数据损坏的防线

ZFS 最著名的特性:每个数据块都带有一个校验和,和块数据一起存储。读取时验证校验和,如果数据静默损坏——比如内存比特翻转、磁盘扇区退化——ZFS 能发现并自动修复(如果有冗余副本或 RAID 支撑)。

c
/* ZFS 的块指针含校验和——这是文件系统层面的端到端数据完整性 */
struct blkptr {
    uint8_t     bp_checksum[32];   /* SHA-256 or Fletcher-4 */
    uint64_t    bp_dva[3];         /* 数据虚拟地址(三元镜像) */
    uint64_t    bp_blk_prop;       /* 块大小、压缩、加密标志 */
    uint64_t    bp_birth;          /* 事务组编号(按 COW 语义) */
};

/* 读取时验证流程 */
int zio_read(zio_t *zio) {
    /* 1. 从磁盘读取块数据 + 原始校验和 */
    /* 2. 重新计算校验和 */
    /* 3. 对比:一致 → 返回;不一致 → 尝试镜像副本 */
    if (checksum_mismatch(zio)) {
        return zio_read_from_replica(zio);  /* 自动修复! */
    }
    return 0;
}

3. 去重:ZFS 和 Btrfs 都支持块级去重——如果两个文件有相同的数据块,COW 保证它们共享同一个物理块。这是虚拟机磁盘镜像的福音。

文件系统横向对比

特性              FAT32         ext4           NTFS            ZFS / Btrfs
──────────────────────────────────────────────────────────────────────────
诞生年代          1977          1992 (ext2)     1993            2001 / 2007
                      1998 (ext3)
                      2006 (ext4)
最大单文件        4 GB          16 TB          16 EB           16 EB
最大卷大小        2 TB          1 EB           256 TB          256 ZiB
崩溃恢复          无            Journal        Journal +      COW + Checksum
                                  (Ordered)    Tx Logging     + Snapshot
日志模式          无            Journal        USN Journal    Transaction Groups
数据校验          ❌            ❌(extent校验)   ❌            ✅ (端到端)
快照              ❌            ❌             ❌             ✅
去重              ❌            ❌             ❌             ✅ (ZFS)
压缩              ❌            ❌             ✅ (LZX)       ✅ (LZ4/ZSTD)
透明加密          ❌            ❌             ✅ (EFS)       ✅ (ZFS)

💡 洞见: FAT32 没有日志、没有 COW、没有校验和——它是文件系统界的"I型拖拉机"。但它依然是世界上部署最广泛的文件系统之一(U 盘、SD 卡、嵌入式设备)。简单 = 可靠,对于大量不需要崩-溃-恢-复的消费品场景,FAT32 的简单性就是最大的优势。


存储专题:RAID 属于存储系统和硬件架构的交叉领域。以下内容面向需要在生产环境做存储选型的读者,不作为主线通关要求。

常见陷阱——RAID:多个硬盘一起唱戏

RAID(Redundant Array of Independent Disks)的思想很简单:多个硬盘组合成一个逻辑设备,提升性能或容错能力——或两者兼有。

RAID 0——条带化(Striping)

        逻辑文件: A1 A2 A3 A4 A5 A6 | A7 A8 A9 A10 ...

        磁盘 0         磁盘 1
        ┌─────┐       ┌─────┐
        │ A1  │       │ A2  │
        │ A3  │       │ A4  │
        │ A5  │       │ A6  │
        └─────┘       └─────┘

    性能:读/写 x2(N 盘xN)
    容错:0——坏一块盘,全部数据丢失
    实际容量:全部磁盘之和

RAID 1——镜像(Mirroring)

        磁盘 0         磁盘 1
        ┌─────┐       ┌─────┐
        │ A1  │       │ A1  │
        │ A2  │       │ A2  │
        │ A3  │       │ A3  │
        └─────┘       └─────┘

    性能:读 x2,写同单盘
    容错:坏一块盘,数据完整(另一块直接顶上)
    实际容量:总容量 / 2
    注意:写入时必须两个都成功才算成功——如果写完一个后另一个没写完就断电,两个盘就不一致了。

RAID 5——带奇偶校验的条带化

这是最经典的折中——用一块盘的容量提供容错:

        磁盘 0     磁盘 1     磁盘 2     磁盘 3
        ┌─────┐   ┌─────┐   ┌─────┐   ┌─────┐
        │ A1  │   │ A2  │   │ A3  │   │ P1  │   ← P1 = A1 XOR A2 XOR A3
        │ A4  │   │ A5  │   │ P2  │   │ A6  │   ← P2 = A4 XOR A5 XOR A6
        │ A7  │   │ P3  │   │ A8  │   │ A9  │   ← P3 = A7 XOR A8 XOR A9
        └─────┘   └─────┘   └─────┘   └─────┘

    奇偶校验分散在不同磁盘(避免热盘瓶颈)
    性能:读 xN(但写需要计算 XOR——性能折损)
    容错:任意坏一块,数据完整
    实际容量:总容量 - 1 块盘

什么时候 RAID 5 坑爹?重建(rebuild)时再坏一块盘。 现代硬盘容量 16-24 TB,重建可能要几十个小时——期间系统承受着完整 I/O 压力,剩下的盘需要校验出全部数据。如果再坏一块,全部数据丢失。

RAID 6——双奇偶校验

    性能:读接近 RAID 5,写需要计算双 XOR
    容错:任意坏两块盘
    实际容量:总容量 - 2 块盘
    代价:写入多一次 XOR 计算,性能比 RAID 5 低 10-20%

RAID 10——RAID 1+0(先镜像,后条带化)

         RAID 0 层
       ┌──────────┐
       │  镜像 1   │        │  镜像 2   │
       ├────┬─────┤        ├────┬─────┤
       │盘A │ 盘B  │        │盘C │ 盘D  │
       └────┴─────┘        └────┴─────┘

    容错策略:每个镜像组内可坏一块。总体可以坏 N/2 块(只要不在同组)
    性能:读/写都很好,读方面 RAID 0 条带化 + RAID 1 镜像的双重加持
    代价:容量只有总容量一半——跟 RAID 1 相同

实际选型建议表

c
/* 一个简化的 RAID 选型决策树 */
enum raid_level {
    RAID0, RAID1, RAID5, RAID6, RAID10
};

raid_level recommend_raid(bool need_fault_tolerance,
                          bool need_write_performance,
                          int num_disks) {
    if (!need_fault_tolerance && num_disks >= 2)
        return RAID0;       // 临时数据处理,完全不要容错

    if (need_write_performance && num_disks >= 4)
        return RAID10;      // 数据库、视频编辑——写入最敏感

    if (need_fault_tolerance && num_disks >= 3)
        return RAID5;       // 文件服务器——容量/容错的好平衡

    if (num_disks >= 4 && need_fault_tolerance)
        return RAID6;       // 大型存储——重建安全更重要

    return RAID1;           // 只有两块盘——没得选
}

实验说明fsync() 测量实验需要真实的物理磁盘来观察写延迟差异。云服务器上的虚拟磁盘(如 EBS)可能不表现典型的 fsync 行为。建议在本地物理机或 SSD 上运行此实验。实验会写入 64MB 的临时文件,运行后自动清理。

通关挑战——测量 fsync() 的真实代价

c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <time.h>

#define FILE_SIZE (64 * 1024 * 1024)  /* 64 MB */
#define BLOCK_SIZE 4096

double now_sec() {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec + ts.tv_nsec * 1e-9;
}

int main(int argc, char *argv[]) {
    int use_fsync = (argc > 1 && argv[1][0] == '1');
    char *buf = malloc(BLOCK_SIZE);

    /* 填充数据 */
    for (int i = 0; i < BLOCK_SIZE; i++)
        buf[i] = (char)(i & 0xFF);

    int fd = open("testfile.dat", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) { perror("open"); return 1; }

    double t0 = now_sec();

    for (size_t off = 0; off < FILE_SIZE; off += BLOCK_SIZE) {
        write(fd, buf, BLOCK_SIZE);
        if (use_fsync)
            fsync(fd);  /* 每次写入后强制落盘 */
    }

    if (!use_fsync)
        fsync(fd);      /* 最后一次性落盘 */

    double t1 = now_sec();

    close(fd);
    printf("模式: %s\n", use_fsync ? "每次 fsync" : "一次性 fsync");
    printf("写入 %d MB, 耗时 %.2f\n",
           FILE_SIZE / (1024*1024), t1 - t0);
    printf("吞吐: %.1f MB/s\n",
           (FILE_SIZE / (1024.0*1024)) / (t1 - t0));

    free(buf);
    unlink("testfile.dat");
    return 0;
}

编译运行:

bash
$ gcc -o fsync_test fsync_test.c -lrt

# 测试1:不频繁 fsync
$ ./fsync_test 0
模式: 一次性 fsync
写入 64 MB, 耗时 0.18
吞吐: 355.6 MB/s

# 测试2:每次写入都 fsync
$ ./fsync_test 1
模式: 每次 fsync
写入 64 MB, 耗时 12.47
吞吐: 5.1 MB/s

每次 fsync() 耗时约 100 μs~数毫秒,取决于磁盘。真正的瓶颈就在这——不是 CPU,不是内存带宽,而是磁盘的物理写入延迟。

这就是为什么应用程序为了不卡顿,不会每写入一行日志就 fsync() 一次——而是聚合写入、定期 fsync()。但也不是完全不调——数据库系统会在每个事务提交时调一次 fsync(),这是"事务持久性"(Durability)的底线保障。


验收标准

读完本章后,请确认你能回答:

  1. 崩溃一致性问题为什么是文件系统的核心问题? 写一个文件涉及多少次磁盘 I/O?
  2. Journaling 和 COW 两种方案的根本区别是什么? 为什么 COW 不需要写两次?
  3. RAID 5 和 RAID 10 的区别体现在哪里? 为什么数据库多用 RAID 10 而不是 RAID 5?
  4. write() 返回后数据一定在磁盘上了吗? 为什么需要 fsync()fclose() 包含了 fsync() 吗?
  5. ZFS 的校验和在读路径上怎么工作时? 如果发现校验和不一致,ZFS 怎么知道从哪里取正确数据?

常见卡点

卡点真相
"write() 返回=数据落盘"只是从用户缓冲区复制到内核页缓存。想落盘需要 fsync()
"fclose() 会调 fsync()"只会 flush 用户级缓冲区。内核页缓存的数据不一定落盘
"Journalling 写两次太慢"Ordered 模式只日志化元数据,数据块只需要写一次。性能损失 5-15%
"RAID 5 有 N-1 的容量,很划算"重建过程中再坏一块就是灾难。大型存储 (>=8T) 推荐 RAID 6 或 RAID 10
"FAT32 没有 journal,不安全"对简单 U 盘场景,FSCK 足够。而且 FAT32 简单到不容易出磁盘结构问题

现在不需要理解

  • ZFS 的 RAID-Z 和 dRAID 的详细布局算法
  • Btrfs 子卷和配额的具体实现
  • LVM 的逻辑卷管理配置命令
  • F2FS 针对闪存优化的具体 GC 策略
  • 分布式文件系统的强一致性模型(Ceph、GFS 等)
  • NVMe 的 IO 队列和轮询模式(polling I/O)

旅人笔记

我花了很长时间才真正理解"文件系统崩溃一致性"是个什么东西。

最开始我在 ext4 上写一个数据库 demo。每次 insert 后调 fsync(),跑基准测试时发现 72% 的时间在等磁盘写日志。我心想:这东西怎么这么慢?然后我试了关掉 journalling——数据毁了两三次后,我懂了。

后来看了 ZFS 的论文,给我感觉是"这帮人遇到了真实的、深刻的痛苦"——ZFS 的作者说,他们见过太多 RAID 控制器在写校验和时出 bug,导致写了错误数据但报告成功。所以 ZFS 在文件系统层自己做校验和,绕过不可信的 RAID 控制器。

这让我开始用另一种眼光看文件系统:不是"怎么读写文件",而是"怎么对抗宇宙的熵增"。

磁盘比特会翻转。SSD 的 NAND 单元会泄露电荷。RAID 控制器有固件 bug。电源会莫名其妙断掉。而你的数据——你想要它还在。

记住这句话:RAID 不是备份。 你误删了文件,RAID 5 不会帮你恢复。你中了勒索病毒,RAID 10 不会帮你解密。文件系统只能对抗硬件故障。不怕死,但怕备份——这个教训是我亲自经历过"rm -rf /"半路按 Ctrl-C 后才真正理解的。


→ 下一站预告

第14章:虚拟化与容器——一台机器,怎么可能"同时"跑多个操作系统?

你已经理解了操作系统怎么管理物理资源——磁盘、内存、CPU。但等一下,如果你可以在同一台物理机上同时运行 Windows 和 Linux——它们各自的"操作系统"之间,是谁在仲裁?谁在做"物理资源有多份"这个幻觉?这就是下一章的主题:虚拟化。

Built with VitePress | Software Systems Atlas