第21讲 文件系统崩溃一致性I

第21讲 文件系统崩溃一致性I
agsd第十三周:文件系统崩溃一致性 学习指南
以下内容按照课件与学习指南规范整理:先建立主线,再解释机制、对比与易错点,最后给出不含答案的自测题。
0. 这一章到底在解决什么问题?
文件系统的一次操作,往往不只是修改一个地方。
例如创建文件 /chb,至少要完成三项修改:
- 在 inode 分配表中,把某个 inode 标记为已占用。
- 初始化这个 inode。
- 在父目录中加入目录项,让文件名
chb指向该 inode。
从用户角度看,这只是一次 create();但从文件系统内部看,它是多个磁盘结构的联合修改。
问题在于:
如果系统恰好在这些修改进行到一半时断电,磁盘上会留下什么状态?
例如目录项已经写入磁盘,但 inode 还没有初始化,那么用户虽然能找到这个文件名,它却指向一个无效甚至属于其他文件的 inode。文件系统内部的数据结构关系就被破坏了。
因此,这一章研究的核心是:
如何让由多个磁盘写组成的文件系统操作,在任意时刻崩溃后,仍然保持一个可以解释、可以恢复、不会破坏内部结构的状态?
这里追求的不一定是“所有最新数据都不丢失”,而是:
- 文件系统结构不能损坏;
- 已经暴露出来的状态必须合理;
- 崩溃后能够恢复到操作前或操作后的某个完整状态;
- 不能停留在危险的中间状态。
本章关键词
- 崩溃一致性
- 一致性约束
- 原子性
- 持久性
- 有序性
- Journaling
- JBD2
- Transaction
- Commit
- Checkpoint
- Copy-on-Write
- Soft Updates
1. 本节知识地图
这一章可以分为四层:
1 | 文件系统为什么会不一致 |
2. 文件系统为什么存在“一致性约束”?
2.1 它是什么?
文件系统不是一个单独的数据表,而是由许多相互关联的数据结构组成,例如:
- 超级块;
- 数据块分配信息;
- inode 分配信息;
- inode 表;
- 目录项;
- 文件数据块;
- 一级、二级间接索引块。
这些结构之间存在依赖关系。
例如:
- inode 中记录的文件大小,应当与其指向的数据块数量相匹配;
- inode 的链接数,应当与指向它的目录项数量匹配;
- 标记为空闲的数据块,不应同时被某个文件引用;
- 标记为已使用的 inode,应当能够从目录树中访问;
- 目录项不应指向一个未分配或未初始化的 inode;
- 超级块记录的文件系统大小,应当与实际管理空间相符。
所谓文件系统一致性,就是这些关系没有被破坏。
2.2 为什么容易被破坏?
创建文件 /chb 的三个修改,理论上有 (2^3=8) 种可能的持久化组合。
课件第18—20页把这八种状态完整列了出来。关键不是 CPU 按什么顺序执行,而是崩溃发生时,哪些缓存内容已经真正写入磁盘。
| 已持久化的修改 | 结果 |
|---|---|
{} |
什么都没发生,文件不存在 |
{1} |
inode 被占用但无法访问,造成空间泄漏 |
{2} |
初始化结果之后可能被覆盖,一般不会形成严重不一致 |
{3} |
目录项指向未初始化或错误 inode,十分危险 |
{1,2} |
inode 已存在但没有目录项,造成空间泄漏 |
{1,3} |
目录项指向未正确初始化的 inode |
{2,3} |
目录项指向一个仍被认为空闲的 inode,可能被其他文件重复分配 |
{1,2,3} |
文件正常创建 |
这里可以把问题分成两类:
- 良性不一致:浪费一些空间,但不会让文件系统错误解释数据。
- 恶性不一致:出现悬空指针、重复分配、错误共享,可能造成数据损坏或安全问题。
2.3 为什么内存缓存让问题更复杂?
文件系统不会每修改一个字段就立即同步写磁盘。
它通常先修改内存中的:
- page cache;
- inode cache;
- directory entry cache;
- buffer cache;
- 分配器状态。
之后再由后台线程异步写回磁盘。
因此:
1 | 程序执行顺序 ≠ 磁盘持久化顺序 |
即使代码中先修改 inode,再修改目录项,也不能仅凭代码顺序保证 inode 一定先落盘。
3. 崩溃一致性到底要求什么?
3.1 用户期望
系统重启后,用户通常接受“刚刚修改的内容可能没保存”,但不能接受:
- 昨天的文件突然损坏;
- 一个磁盘块同时属于两个文件;
- 文件名指向随机数据;
- 文件系统无法挂载;
- 后执行的操作存在,前面的操作反而消失。
因此,崩溃一致性不是简单地追求“不丢数据”。
它首先追求:
无论在哪一刻崩溃,文件系统都能恢复到某个内部结构正确的状态。
3.2 课件中的磁盘假设
课件作了几个简化假设:
- 磁盘是 fail-stop 的:失败后停止工作;
- 正常时,磁盘会执行文件系统下发的命令;
- 崩溃时,最近的一部分写操作可能没有完成;
- 磁盘不会无缘无故把内容写到错误位置,即不存在 wild write。
也就是说,我们主要研究的是:
最近的写可能丢失或只完成一部分,而不是磁盘随机篡改其他区域。
3.3 文件系统操作的三个属性
假设程序执行:
1 | create("a"); |
持久性 Durable
关心哪些操作已经真正保存到磁盘。
例如 create("a") 返回后,文件 a 是否一定能在重启后看到。
原子性 Atomic
一组修改要么全部可见,要么全部不可见。
创建文件涉及 inode bitmap、inode 和目录项,不能只完成其中一部分。
有序性 Ordered
崩溃后可见的操作,应当形成原操作顺序的一个前缀。
例如:
1 | create("a") |
如果重启后可以看到 b,那么通常也应当看到更早创建的 a。不能出现后面的操作已经持久化,前面的操作反而消失。
4. 崩溃后的恢复方式
4.1 离线恢复
典型工具包括:
- Windows 的
chkdsk - Linux 的
fsck
它们会扫描文件系统中的各种结构,重新检查:
- 分配表是否与实际引用一致;
- inode 是否可达;
- 链接数是否正确;
- 是否存在丢失的块或重复分配。
问题是,文件系统越大,扫描时间越长。在检查完成前,文件系统通常不能正常使用。
4.2 在线检查
文件系统运行过程中检查部分关键约束,尽早发现问题。
但在线检查一般只能检查部分关系,很难代替完整的崩溃一致性机制。
所以更理想的思路不是:
崩溃后把所有磁盘内容重新猜一遍。
而是:
在正常运行时就留下足够的信息,使崩溃恢复变得简单。
这就引出了日志。
5. 核心机制一:日志 Journaling
5.1 它是什么?
日志的基本思想是:
在修改文件系统正式位置之前,先把这次修改完整地记录到一块专门的日志区域。
可以把它理解成正式修改文件系统前,先写一份“施工方案”。
基本流程是:
1 | 1. 把所有修改写入日志 |
只有看到完整的提交标记,恢复程序才认为这次操作是完整的。
5.2 它为什么能保证原子性?
假设创建 /chb 的三个修改已经被记录在日志中。
在 Commit 之前崩溃
日志不完整。
恢复时直接忽略这条日志,相当于文件没有被创建。
在 Commit 之后崩溃
日志已经完整。
即使正式位置只修改了一部分,恢复时也可以根据日志把全部修改重新执行一遍。
因此,从恢复后的结果看,只可能是:
1 | 操作完全没发生 |
不会暴露中间状态。
5.3 日志的完整运行流程
阶段一:记录日志
把待修改块的新内容,以及它们对应的原磁盘块号写入日志区域。
阶段二:提交事务
写入 Commit Block,表示:
本事务所有必要内容已经完整进入日志。
Commit 是事务是否有效的分界线。
阶段三:写回正式位置
把日志中的数据复制到文件系统原本的位置,这一步也叫:
- checkpoint;
- checkpointing;
- 写回 home location。
阶段四:回收日志
当正式位置已经更新,原日志不再有用,可以将事务标记为无效,释放日志空间。
5.4 为什么还要使用内存缓存?
最朴素的日志做法是每次文件操作都同步写磁盘,但会有几个问题:
- 每个操作都等待磁盘,缓存失去意义;
- 同一份修改通常要先写日志,再写正式位置;
- 同一个块短时间内被修改多次,会产生重复记录;
- 大量小写操作效率很低。
所以实际系统会:
- 在内存中积累修改;
- 将多个文件系统操作合并为一个事务;
- 同一个被多次修改的块,只记录最终版本;
- 定期或按需统一提交。
提交常见触发条件包括:
- 每隔一段时间提交;
- 日志达到一定大小;
- 应用调用
fsync(); - 文件系统卸载或需要回收日志空间。
5.5 日志的代价
日志虽然简化了恢复,但并不是免费机制。
主要代价是:
- 写放大:相同内容先写日志,再写正式位置;
- 占用额外日志空间;
- Commit 需要保证写入顺序;
- 提交事务可能增加延迟;
- 如果事务过大,恢复和提交成本也会增加。
6. Linux 的 JBD2 日志系统
6.1 JBD2 是什么?
JBD2 是 Journal Block Device 2,是 Linux 中通用的块设备日志模块。
Ext4 可以借助 JBD2 实现日志功能。
JBD2 中有三个重要概念:
| 概念 | 含义 |
|---|---|
| Journal | 整个日志区域,可以位于文件中或直接位于设备块中 |
| Handle | 一次需要原子完成的文件系统操作 |
| Transaction | 为提高效率,把多个 Handle 合并后形成的一次批量提交 |
也就是说:
1 | 一次 create 操作 → 一个 Handle |
6.2 JBD2 事务状态
课件将事务划分为五个主要状态:
1 | 运行 |
运行 Running
新产生的文件系统原子操作还可以加入事务。
锁定 Locked
不再接受新的操作,但已经加入的操作可能仍在完成修改。
写入 Flushing
开始把事务内容写入日志设备。
提交 Committing
事务数据已经写入,正在写 Commit Block。
完成 Finished
Commit 已完成,事务在日志中已经有效。
6.3 文件系统如何使用 JBD2?
简化后的调用关系如下:
1 | handle = jbd2_journal_start(journal, nblocks); |
表示开始一个原子操作,并预估最多会修改多少块。
在修改某个 buffer_head 前:
1 | jbd2_journal_get_write_access(handle, bh); |
表示:
接下来我要修改这个块,请把它纳入日志保护范围。
完成内存修改后:
1 | jbd2_journal_dirty_metadata(handle, bh); |
表示:
这个块已经被修改,需要记录到事务中。
操作完成后:
1 | jbd2_journal_stop(handle); |
结束本次 Handle。
重点不是记住函数名,而是理解 JBD2 要知道三件事:
- 一次原子操作从哪里开始、在哪里结束;
- 这次操作可能修改哪些块;
- 哪些块确实已经被修改。
6.4 JBD2 的磁盘结构
课件第46页展示了一个事务的典型结构:
1 | Descriptor Block |
Descriptor Block 中保存多个 tag,每个 tag 说明:
- 后面的某个日志块;
- 最终应该写回文件系统的哪个块;
- 校验信息和标志位。
恢复时,JBD2 读取 Descriptor Block,就知道:
这块日志数据原本应该写回磁盘的哪个位置。
Commit Block 则表示事务是否完整有效。
7. Ext4 的三种日志模式
Ext4 可以在数据安全性和性能之间选择不同模式。
7.1 Writeback 模式
只对元数据记日志,普通文件数据不受日志写入顺序保护。
1 | 日志保护:元数据 |
优点:
- 写入次数少;
- 性能较高。
缺点:
- 只能较好地保证文件系统结构;
- 崩溃后文件内容可能是旧数据或意外内容。
可以概括为:
保结构,不一定保数据。
7.2 Journal 模式
元数据和文件数据都写入日志。
1 | 数据 + 元数据 |
优点:
- 一致性最强;
- 文件内容也受到日志保护。
缺点:
- 数据通常要写两次;
- 写放大和性能开销最大。
7.3 Ordered 模式
只把元数据写入日志,但要求:
文件数据必须先写入正式位置,之后才能提交引用这些数据的元数据日志。
例如 inode 即将指向一个新数据块:
1 | 先写新数据块 |
这样即使崩溃,也不会出现 inode 已经指向一个尚未写入的数据块。
它是 Ext4 的典型默认模式,在性能和一致性之间折中。
三种模式对比
| 对比点 | Writeback | Ordered | Journal |
|---|---|---|---|
| 元数据是否记日志 | 是 | 是 | 是 |
| 文件数据是否记日志 | 否 | 否 | 是 |
| 是否约束数据先于元数据 | 否 | 是 | 通过完整日志保证 |
| 一致性强度 | 较弱 | 中等 | 最强 |
| 写入开销 | 最低 | 中等 | 最高 |
| 主要目标 | 保文件系统结构 | 防止元数据指向未写数据 | 同时保护元数据和数据 |
8. 核心机制二:写时复制 Copy-on-Write
8.1 它是什么?
写时复制的核心原则是:
不要直接覆盖当前正在使用的数据,而是在新位置上构造修改后的版本。
只有当新版本全部准备好之后,才原子地切换入口,使新版本正式生效。
8.2 它为什么能保证一致性?
假设文件系统中的结构是一棵树:
1 | A |
现在要同时修改 C 和 E。
不能直接覆盖它们,而是创建:
1 | C' 和 E' |
由于父节点中的指针也要改变,还需要复制父节点:
1 | B' 和 D' |
接着根节点也要指向新的父节点,因此还需要新根节点 A'。
最终磁盘中同时存在:
1 | 旧版本:A → B、D → C、E、F |
最后只需要原子修改根指针:
1 | root = A' |
根指针切换之前,读者看到完整旧版本;切换之后,读者看到完整新版本。
不会看到一半新、一半旧的混合状态。
课件第56—59页的树形图体现的正是:
把大范围修改递归转换成一次很小的根指针原子更新。
8.3 文件中的写时复制流程
文件可能通过多级索引找到数据:
1 | inode |
如果某个数据块需要修改:
- 为该数据块分配新块;
- 把旧数据复制到新块并修改;
- 复制指向该数据块的一级索引块,修改其中的指针;
- 复制更上层的二级索引块;
- 复制 inode 或更高层结构;
- 原子切换入口;
- 确认旧版本不再使用后,回收旧块。
重点是:
修改沿着“数据块到根节点”的路径逐层向上传播。
8.4 写放大问题
用户可能只想修改两个小数据块,但系统还需要复制:
- 相关索引块;
- 二级索引块;
- inode;
- 其他祖先节点。
因此:
1 | 实际写入量 > 用户修改量 |
这叫写放大。
写时复制不代表一定比日志快。
如果只是修改一个很小的数据区域,却需要复制很长的索引路径,写入开销仍然可能很大。
8.5 写时复制文件系统
课件以 Btrfs 为例说明,写时复制可以应用于整个文件系统的树状结构。
文件系统中的多种结构都可以通过树组织,例如:
- 根树;
- 文件系统树;
- 区段树;
- 设备树;
- 校验码树。
修改时构造新树节点,再切换相应的根或指针。
9. 日志与写时复制的区别
| 对比点 | 日志 Journaling | 写时复制 Copy-on-Write |
|---|---|---|
| 原数据是否原地修改 | 最终会修改正式位置 | 不直接覆盖旧版本 |
| 修改前做什么 | 把修改记录到日志 | 在新位置复制并修改 |
| 生效标志 | Commit Block | 根指针或入口指针切换 |
| 崩溃前新版本未完成 | 忽略未提交日志 | 继续使用旧树 |
| 新版本完成后的恢复 | 可能需要重放日志 | 入口已经指向新版本 |
| 主要额外开销 | 日志写入、数据可能写两遍 | 复制祖先节点、空间回收 |
| 典型问题 | 日志空间、提交延迟、写放大 | 碎片、写放大、回收复杂 |
| 典型系统 | Ext4 + JBD2 | Btrfs 等 |
可以把它们直观地区分为:
1 | 日志: |
10. 核心机制三:Soft Updates
10.1 它是什么?
Soft Updates 的思想与日志和写时复制不同。
它不一定要求一次操作的全部修改原子落盘,而是认为:
有些不完整状态只是空间泄漏,并不会破坏文件系统;真正需要避免的是危险的不一致状态。
例如:
1 | inode 已标记为占用 |
这会浪费一个 inode,但不会让目录项指向错误对象。
相反:
1 | 目录项已经指向 inode |
就是危险状态。
因此 Soft Updates 的方法是:
在内存中记录修改之间的依赖关系,严格安排它们写入磁盘的先后次序。
10.2 它是怎么工作的?
内存中维护最新的元数据,并把各个修改组织成依赖图。
例如创建文件时:
1 | 修改1:在 bitmap 中标记 inode 已占用 |
因此持久化顺序必须满足:
1 | 分配 inode |
后台可以异步写磁盘,但不能违反依赖顺序。
10.3 Soft Updates 的三个次序规则
规则一:不要指向未初始化的结构
目录项指向 inode 之前,inode 必须已经初始化。
1 | 正确: |
规则二:仍被引用的结构不能重新分配
如果某个 inode 仍然指向数据块,该数据块就不能同时被重新分配给其他文件。
必须先让旧指针失效,再把资源放回空闲池。
规则三:不要过早删除最后一个有效指针
例如重命名文件:
1 | 旧目录项 → 文件 |
应当先写入新目录项,再删除旧目录项。
否则如果先删除旧目录项并发生崩溃,文件可能失去最后一个可访问入口。
10.4 Soft Updates 的优缺点
优点:
- 不需要在磁盘上维护额外日志;
- 正常读写可以大量利用内存缓存;
- 磁盘上的元数据始终保持结构一致;
- 崩溃后通常不需要重放日志即可挂载。
缺点:
- 依赖关系极其复杂;
- 每种文件系统操作都要拆成多个细粒度修改;
- 需要记录旧值、新值和依赖边;
- 很难实现和验证正确性。
课件也明确指出:Soft Updates 的实现复杂度很高。
11. 三种崩溃一致性方案对比
| 对比点 | 日志 | 写时复制 | Soft Updates |
|---|---|---|---|
| 基本思想 | 先记录,再原地修改 | 新位置构造,再切换入口 | 控制依赖写入顺序 |
| 是否保留旧版本 | 通常不长期保留 | 切换前保留 | 不以保留完整版本为核心 |
| 磁盘额外信息 | 需要日志区域 | 需要新副本和新节点 | 通常不需要额外日志 |
| 崩溃恢复 | 检查并重放已提交事务 | 选择有效入口或根 | 通常可直接使用一致磁盘状态 |
| 原子性来源 | Commit Block | 原子切换根指针 | 避免危险中间状态 |
| 性能问题 | 双写、提交延迟 | 写放大、碎片和回收 | 依赖追踪开销 |
| 实现复杂度 | 较高但机制清晰 | 较高 | 非常高 |
三种方案解决问题的角度不同:
1 | 日志: |
12. 关键机制总流程
12.1 日志恢复流程
1 | 系统启动 |
12.2 写时复制更新流程
1 | 保留旧结构 |
12.3 Soft Updates 写入流程
1 | 把文件系统请求拆成多个修改 |
13. 最容易混淆的点
1. 崩溃一致性不等于所有数据都不会丢
崩溃一致性首先保证结构正确。
即使文件系统完全一致,刚写入但尚未持久化的数据仍可能丢失。
1 | 一致性:磁盘上的关系是否合理 |
二者不是同一件事。
2. 系统调用返回不一定等于数据已经进入磁盘
一次 write() 返回,往往只代表数据进入了内核缓存。
只有完成必要的写回或同步操作后,数据才真正进入持久化设备。
3. CPU 执行顺序不等于磁盘持久化顺序
代码里先修改 inode,再修改目录项,并不能天然保证 inode 一定先落盘。
缓存、后台写回和设备内部调度都会改变真实持久化顺序。
4. 日志不是普通的应用日志
这里的文件系统日志不是:
1 | 用户 A 在 10:30 登录 |
而是保存:
1 | 把磁盘块 100 修改成某个新内容 |
它直接服务于磁盘结构恢复。
5. Commit 不等于正式位置已经修改完成
Commit 表示:
完成修改所需的信息已经完整写入日志。
正式文件系统位置可能尚未全部更新。
这时崩溃仍然没关系,因为恢复时可以重放日志。
6. Transaction 和单次文件操作不完全相同
一次文件系统原子操作通常对应一个 Handle。
为了提高效率,JBD2 会把多个 Handle 合并到同一个 Transaction 中一起提交。
7. Ordered 模式不是“所有写操作都严格按程序顺序持久化”
它主要保证与某次元数据提交相关的数据块,先于相应元数据到达磁盘。
它不是对所有应用写入建立一个全局严格顺序。
8. 写时复制不是普通的内存 Copy-on-Write
二者思想相同:修改前保留旧副本。
但本章讨论的是磁盘文件系统中的数据块、索引节点和树结构更新,不只是 fork() 后的物理内存页复制。
9. Soft Updates 不等于完全避免不一致
它允许某些良性不一致,例如 inode 泄漏。
它保证的是:
不出现会导致错误引用、重复分配等恶性不一致。
14. 本节一句话总复习
文件系统崩溃一致性的核心矛盾是:一次逻辑操作需要修改多个磁盘结构,而崩溃可能让其中只有一部分持久化。日志通过“先记录、后提交、再写回”实现恢复,写时复制通过“构造新版本后原子切换入口”避免覆盖完整旧版本,Soft Updates 则通过控制元数据依赖顺序,阻止危险中间状态进入磁盘。
15. 自测题,先不放答案
基础理解
为什么创建一个空文件也不是一次简单的磁盘写操作?
创建文件时,如果目录项已经持久化,但 inode 仍然被标记为空闲,可能出现什么问题?
为什么代码中“先初始化 inode,再写目录项”,仍然不能直接证明磁盘上的顺序也一定如此?
文件系统崩溃一致性为什么不等于“最近写入的数据绝对不会丢失”?
持久性、原子性和有序性分别约束什么?
日志机制
日志中的 Commit Block 有什么作用?为什么不能只看前面的日志数据块是否存在?
系统在 Commit 之前崩溃和 Commit 之后崩溃,恢复行为有什么区别?
既然事务已经写入日志,为什么还要将内容写回文件系统的正式位置?
为什么文件系统会把多个操作合并进同一个日志事务,而不是每次系统调用都立即提交?
JBD2 中 Handle、Transaction 和 Journal 是什么关系?
jbd2_journal_get_write_access()为什么要在真正修改块之前调用?
Ext4 日志模式
Writeback 模式为什么可以保证文件系统结构基本一致,却不能保证文件内容正确?
Journal 模式为什么一致性最强,又为什么写入开销最大?
Ordered 模式为什么要求数据块先于对应元数据日志提交?
如果只记元数据日志,却不控制数据块的写入顺序,可能产生什么现象?
写时复制
写时复制为什么要从被修改的叶子节点一路复制到根节点?
为什么只要原子切换根指针,就能让整棵新树一起生效?
如果新版本构造到一半时崩溃,旧版本为什么仍然可以正常使用?
用户只修改一个数据块时,写时复制为什么可能还要修改多个索引块?
写时复制为什么并不一定比日志更高效?
Soft Updates
为什么“inode 已分配但没有目录项指向它”通常比“目录项指向未初始化 inode”安全?
Soft Updates 为什么可以不把多个修改作为一个完整事务一起写入磁盘?
“不要修改最后一个指向有用结构的指针”在文件重命名过程中具体意味着什么?
为什么 Soft Updates 不需要日志重放,却很难实现?
综合题
用自己的话分别概括日志、写时复制和 Soft Updates 如何处理“危险中间状态”。
假设你正在设计一个重视写入性能、允许丢失最近数据但绝不能破坏目录结构的文件系统,你会优先考虑哪类机制?说明理由。
假设事务已经 Commit,但只完成了一半 checkpoint,此时崩溃。重启后应该如何处理?
写时复制完成根指针切换后,为什么不能立刻无条件回收全部旧块?
fsync()在缓存、日志事务和数据持久化之间大致起什么作用?为什么说崩溃一致性的本质不是“避免崩溃”,而是“控制崩溃后允许出现的状态”?



