| 本篇位置 | 第 26 篇末尾留下的问题:改多个地方,中间断电怎么办 |
| 运行环境 | 容器里的 Linux,Python 3 |
一、一次追加,要改三个地方
往一个文件末尾追加 4 KB,文件系统要做三件事:
- 数据位图:把某个空闲块标成"已用"。
- inode:文件大小改大,加一个指向新块的指针。
- 数据块:把内容写进去。
这三次写,是三次独立的磁盘写。 而且第 15 篇讲过,它们先进页缓存,什么时候真正落盘由内核决定——顺序完全不由你控制。
现在断电。三个里可能落了任意一个子集:
| 落盘的 | 后果 |
|---|---|
| 只有数据块 | 没事。 没人指向它,等于什么都没发生 |
| 只有位图 | 块泄漏。 这个块被标成已用,但谁也不用它。浪费空间,不致命 |
| 只有 inode | ⚠️ inode 指向一个没被标记为已用的块。 这个块以后会被分配给别人——两个文件指向同一块 |
| inode + 位图,没有数据 | ⚠️ 文件里出现一段旧垃圾——那可能是别人删掉的文件残留 |
| 位图 + 数据,没有 inode | 块泄漏 |
| inode + 数据,没有位图 | 同上上,块会被重复分配 |
⭐ 最后两种带 ⚠️ 的是真正的灾难:一种是数据损坏,另一种是信息泄露——你读自己的文件,读到了别人删掉的内容。
打个比方
一家银行做转账:甲的账上减 100,乙的账上加 100。
两个动作,两本账。中间停电:
- 都没记 → 没事。
- 都记了 → 没事。
- 只记了一半 → 凭空少了 100,或者凭空多了 100。
⭐ 银行解决这个问题已经几百年了,办法不是"祈祷不停电",而是先在流水簿上写清楚"我打算做这两件事",然后再去改两本账。停电了,照着流水簿重做一遍就行。
这就是日志(journaling)。 后面第三节就是这个。
二、第一条路:事后扫描(fsck)
早期 UNIX 的做法是:崩了就崩了,重启之后把整个文件系统扫一遍,把不一致的地方修好。
fsck 检查这些:
- 超级块的数字合不合理。
- 每个 inode 指向的块,在位图里是不是标着"已用" → 不一致就以 inode 为准修位图。
- 有没有块被两个 inode 指着 → 复制一份,或者干脆报错。
- 有没有 inode 链接数不对 → 数一遍目录,改过来。
- 有没有 inode 链接数大于 0 但没有任何目录指向它 → 扔进
lost+found。
它能用。但有两个致命问题:
一、慢得不可接受。 ⭐ 它必须扫描整个文件系统,因为它不知道崩溃前正在改哪里。一块 10 TB 的盘,fsck 要跑几个小时。你的服务就停几个小时。
而且扫描时间和盘的大小成正比,跟崩溃时改了多少东西完全无关——哪怕你崩溃时只在写一个字节。
二、它只保证"结构自洽",不保证"数据正确"。 上面表里那个"文件里出现别人的旧垃圾",fsck 完全看不出来——inode 指着那个块,位图也标着已用,一切自洽。从结构上看它完美无缺。
⭐ 这是一个很重要的区分:一致 ≠ 正确。 这门课后面还会用到。
三、第二条路:先写日志
日志的想法就是银行的流水簿:
改真正的数据结构之前,先在一个专门的区域里写下"我要改什么"。 改完了,把这条日志标成已完成。 崩溃后重启,只看日志:没标完成的,重做一遍。
一次追加变成五步:
1. 日志区写入:TxBegin | 新的位图 | 新的 inode | 新的数据块
2. ★ 等这些真的落盘(barrier)
3. 日志区写入:TxEnd(提交记录)
4. ★ 等它落盘
5. 现在才去改真正的位图、inode、数据块(这一步叫 checkpoint)
第 2 步和第 4 步那两个"等"是整件事的关键。
⭐ 为什么必须等? 假设不等,磁盘可能重排顺序,先把 TxEnd 写下去了,而中间那些内容还没落盘。这时候断电——重启后你看到一条"已提交"的日志,但内容是垃圾,然后你照着这堆垃圾去改真正的文件系统。
⚠️ 一条不完整但被标成完整的日志,比没有日志更危险。 所以提交记录必须单独一次写,而且必须在前面的内容确认落盘之后。
恢复很快:重启时只扫日志区(几十 MB),找到有 TxBegin 也有 TxEnd 的事务,重做一遍;只有 TxBegin 的,直接丢弃(它本来就没生效)。
⭐ fsck 的恢复时间正比于盘的大小,日志的恢复时间正比于日志的长度。 这就是它赢的全部原因。
代价:所有东西写两遍
数据先进日志、再进正式位置,写入量翻倍。
所以 ext4 提供了三档,实践中要选:
| 模式 | 日志里写什么 | 崩溃后 |
|---|---|---|
journal |
元数据 + 数据 | 最安全,最慢 |
ordered(默认) |
只有元数据,但保证数据块先于元数据落盘 | 元数据一致,不会读到旧垃圾 |
writeback |
只有元数据,不管顺序 | 元数据一致,⚠️ 但可能读到别人的旧数据 |
⭐ ordered 是那个漂亮的折中:不把数据写两遍(省一半带宽),但强制"先数据后元数据"这个顺序。这样即使崩在中间,最坏情况是"文件大小没更新",绝不会出现"inode 指着一块还没写内容的地"。
这也是这门课里第 N 次出现同一个手法:⭐ 不能同时保证所有事时,就保证一个特定的顺序。
四、⭐ 你自己写代码时,唯一要记住的那条
上面全是文件系统内部的事。但你的应用有一个一模一样的问题:更新一个配置文件、一个数据库快照,写到一半崩了怎么办?
答案是利用 rename 的原子性:
写一个临时文件 →
fsync它 →rename覆盖原文件。
rename 在同一个文件系统内是原子的——它只改目录里的一条记录。读者要么看到完整的旧文件,要么看到完整的新文件,没有中间态。
这不是理论,量一下。一个进程反复更新一个 200 KB 的文件(两种方式),另一个进程反复读它并检查完整性,跑 3 秒:
if MODE == "inplace":
fd = os.open(PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
os.write(fd, data); os.close(fd) # 原地覆盖
else:
fd = os.open(TMP, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
os.write(fd, data); os.fsync(fd); os.close(fd)
os.rename(TMP, PATH) # 原子换名
inplace 读了 47421 次 读到内容自相矛盾 0 次 读到长度不对/文件不存在 43537 次
rename 读了 5822 次 读到内容自相矛盾 0 次 读到长度不对/文件不存在 0 次
原地覆盖:47421 次读里有 43537 次读到了残缺的文件——92%。 写临时文件再 rename:5822 次读,0 次出错。
⭐ 注意失败的形态:读到的不是"一半 A 一半 B",是长度不对。因为 O_TRUNC 先把文件截成 0,然后才慢慢写回去——在这两步之间,任何读者看到的都是一个残缺的文件。
顺带看一眼两组的读次数:5822 对 47421。rename 那一组慢了八倍——因为它每次都要 fsync,要真的等数据落盘。这就是你为安全付的钱,明明白白。
⚠️ 还有一个容易漏的细节:rename 之后还要 fsync 那个目录,否则"目录里这条记录变了"这件事本身可能还没落盘。完整的写法是:
写临时文件 → fsync(临时文件) → rename → fsync(目录)
五、更彻底的路:干脆不覆盖
日志的本质是"改之前先备份意图"。还有一条更彻底的路:永远不覆盖任何东西。
写时复制(copy-on-write):要改一个块?写到一个新块上,然后改指向它的那个指针;而那个指针所在的块也是写到新的地方……一路往上,直到根。最后原子地换一下根指针。
⭐ 于是**“提交"这个动作,收缩成了一次指针替换**。要么指向旧树,要么指向新树,没有中间态。
ZFS 和 btrfs 走的是这条路,而且顺手拿到两个额外的好处:
- 快照几乎免费——留着旧的根指针就行。
- ⭐ 它和第 24 篇讲的 SSD 的 FTL 是同一招:“不原地改,写到新地方再改指针”。这也是为什么写时复制在闪存时代格外合适。
代价是碎片(文件被改得到处都是)和空间放大(旧版本要等没人引用了才能回收)。第 28 篇细讲。
六、代价与取舍
崩溃一致性换来了什么: 你敢在一台随时可能断电的机器上存数据。
它花了什么:
- 写入量增加(日志模式下最多翻倍)。
- ⭐ 必须真的等待落盘。
fsync是所有数据库里最贵的操作之一。上面实测中rename那组慢了八倍,就是这笔钱。 - 复杂度:日志区管理、恢复逻辑、和缓存的交互,全是容易出错的地方。
它放弃了什么: ⭐ “写完就完事了"这个直觉。 你必须显式地 fsync,必须知道 rename 是原子的而 write 不是,必须知道 fsync 文件之后还要 fsync 目录。
⚠️ 而且有一条冷酷的事实:这条链上任何一环撒谎,前面所有的努力都白费。 有些廉价 SSD 的固件会在 FLUSH 命令还没真正完成时就返回成功(因为这样跑分好看)。内核以为落盘了,其实还在盘的缓存里。 断电,数据没了,而软件层完全无辜。
这就是为什么正经的存储服务器要用带掉电保护电容的盘——⭐ 可靠性是一整条链的性质,不是任何单独一层的性质。
七、小结
- 一次追加要改三个地方(位图、inode、数据块),落盘顺序不由你定。⚠️ 崩在中间,轻则块泄漏,重则两个文件指向同一块或文件里出现别人删掉的旧数据。
- fsck 事后扫描修复。⭐ 它的时间正比于盘的大小,跟崩溃时改了多少无关——10 TB 要跑几小时。而且它只保证结构自洽:⭐ 一致 ≠ 正确,“读到别人的旧数据"它完全看不出来。
- 日志先写意图再改数据,恢复时间正比于日志长度。⭐ 提交记录必须单独写,且必须在内容确认落盘之后——⚠️ 一条不完整却被标成完整的日志,比没有日志更危险。
- ext4 三档:
journal(数据也写两遍)、⭐ordered(默认,只写元数据但强制数据先落盘)、writeback(⚠️ 可能读到旧数据)。不能同时保证所有事时,就保证一个特定的顺序。 - ⭐ 你自己写代码只需记住一条:写临时文件 →
fsync→rename→fsync目录。 实测:原地覆盖 92% 的读拿到残缺文件,rename 方案 0%。⚠️ 代价是慢八倍——那是你为安全付的钱。 - 写时复制永不覆盖,把"提交"收缩成一次指针替换,顺带让快照几乎免费。⭐ 和 SSD 的 FTL 是同一招。
- ⚠️ ⭐ 可靠性是整条链的性质:盘的固件如果对
FLUSH撒谎,上面所有努力全部作废。
思考题
- 第一节那张表里,“只有 inode 落盘"会导致块被重复分配。为什么这比"只有位图落盘”(块泄漏)严重得多?
ordered模式保证数据块先于元数据落盘。那么它能防住"文件末尾多出一段垃圾"吗?能防住"文件内容是新旧混合的"吗?- 第四节的
rename方案,如果省掉fsync(临时文件)直接 rename,在进程崩溃和机器断电两种情况下分别会怎样?(提示:这两种情况完全不同。) - 银行流水簿那个比方里,“提交记录必须单独写"对应什么?如果流水簿上的一条记录本身写了一半就停电了,怎么办?