本篇位置 第 26 篇末尾留下的问题:改多个地方,中间断电怎么办
运行环境 容器里的 Linux,Python 3

一、一次追加,要改三个地方

往一个文件末尾追加 4 KB,文件系统要做三件事:

  1. 数据位图:把某个空闲块标成"已用"。
  2. inode:文件大小改大,加一个指向新块的指针。
  3. 数据块:把内容写进去。

这三次写,是三次独立的磁盘写。 而且第 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(⚠️ 可能读到旧数据)。不能同时保证所有事时,就保证一个特定的顺序。
  • 你自己写代码只需记住一条:写临时文件 → fsyncrenamefsync 目录。 实测:原地覆盖 92% 的读拿到残缺文件,rename 方案 0%。⚠️ 代价是慢八倍——那是你为安全付的钱。
  • 写时复制永不覆盖,把"提交"收缩成一次指针替换,顺带让快照几乎免费。⭐ 和 SSD 的 FTL 是同一招。
  • ⚠️ ⭐ 可靠性是整条链的性质:盘的固件如果对 FLUSH 撒谎,上面所有努力全部作废。

思考题

  1. 第一节那张表里,“只有 inode 落盘"会导致块被重复分配。为什么这比"只有位图落盘”(块泄漏)严重得多?
  2. ordered 模式保证数据块先于元数据落盘。那么它能防住"文件末尾多出一段垃圾"吗?能防住"文件内容是新旧混合的"吗?
  3. 第四节的 rename 方案,如果省掉 fsync(临时文件) 直接 rename,在进程崩溃机器断电两种情况下分别会怎样?(提示:这两种情况完全不同。)
  4. 银行流水簿那个比方里,“提交记录必须单独写"对应什么?如果流水簿上的一条记录本身写了一半就停电了,怎么办?

延伸