本篇位置 持久化的收尾。把第 24、26、27 篇留下的线索收到一起
运行环境 容器里的 Linux,Python 3

一、先看看还有什么没解决

第 27 篇的日志解决了"改到一半断电"。但还剩两个问题。

问题一:随机小写依然很贵。

第 24 篇算过,机械盘随机 4 KB 读约 115 IOPS、顺序约 37000 IOPS。而日志让写入量翻了倍——虽然日志本身是顺序写,但 checkpoint 那一步还是要回到各自的位置去改。

问题二:没有人在检查数据对不对。

这个更隐蔽。看一眼:

with open(PATH, "wb") as f: f.write(data)
os.sync()

# 模拟位翻转:绕过应用,直接改盘上的这个文件
fd = os.open(PATH, os.O_WRONLY)
os.pwrite(fd, b"\x00", 40000)
os.close(fd)

back = open(PATH, "rb").read()
  写入时的 SHA-256:d11cb402ae1d4b529d05a6d450e2264c…

  再读回来:
    read() 返回了错误吗?   没有,一切正常,返回 69632 字节
    内容和写进去的一样吗?  ★ 不一样
    差了几个字节?          1
    读回来的 SHA-256:      bdcef07ac4ed85f0411336ab140c5ec2…

改了一个字节,read() 返回成功,返回了完整的长度,没有任何错误。

这个演示里那一个字节是我自己写进去的,但在真实系统上,同样的事会由宇宙射线翻转的比特、老化的闪存单元、有 bug 的固件、传输线上的干扰造成。这叫静默数据损坏(silent data corruption)。

传统文件系统对此毫无办法——因为它从来没有记下"这块数据应该长什么样"。 第 27 篇那句话在这里再一次成立:一致 ≠ 正确。 fsck 会告诉你结构完美无缺,而你的文件内容已经错了。

二、日志结构:干脆全部顺序写

1991 年,Rosenblum 和 Ousterhout 提出了一个当时很激进的想法:

既然顺序写比随机写快几百倍,那就让所有的写都是顺序的。 整个磁盘当成一个只能往后追加的日志。

改一个块?不回原地改,追加到日志末尾。 改 inode?也追加。磁盘上永远只在一个地方写:末尾。

这就是 LFS(日志结构文件系统)。它的动机是这样一条推理:

  1. 内存越来越大,读越来越多地被缓存吃掉(第 15 篇)。
  2. 所以磁盘的负载会越来越偏向写
  3. 那就该按"写"来优化——而写的最优形态是顺序

⚠️ 但立刻有个问题:inode 也在动,怎么找到它?

传统文件系统里 inode 在固定位置(第 26 篇的 inode 表)。LFS 里 inode 到处飘。解法是inode 映射表(imap):一张"inode 号 → 它最新的位置"的表——而这张表本身也是追加写的,只在盘上某个固定位置存一个指向它最新版本的指针。

你应该觉得眼熟:这和第 24 篇 SSD 的 FTL 是同一个东西——一张把"逻辑标识"翻译成"当前物理位置"的映射表。

代价:垃圾回收

一直追加,盘会写满,而里面全是被新版本取代的旧数据。所以必须有垃圾回收:挑一些段,把还活着的数据搬到末尾,把整段腾空。

这和第 24 篇 SSD 的垃圾回收,是同一件事、同一个代价、同一个"写放大"。

LFS 本身没有在通用文件系统里成功——垃圾回收在盘快满时表现很差。但它的思想赢了,而且赢得非常彻底:

  • SSD 内部的 FTL 就是一个日志结构系统。 你用 ext4,底下的固件也在做这件事。
  • 所有 LSM-tree 存储引擎(LevelDB、RocksDB、Cassandra、HBase)都是这个思想:写入先追加到日志,后台再合并(compaction),而 compaction 就是垃圾回收换了个名字
  • F2FS 是专门为闪存设计的日志结构文件系统,用在很多 Android 设备上。

⚠️ 有意思的是:LFS 为机械硬盘的寻道成本设计,而它真正的舞台是闪存——闪存没有寻道,但它"不能原地改"(第 24 篇那条怪规则),恰好也要求追加写。同一个设计,两个完全不同的理由。

三、写时复制:把提交收缩成一个指针

第 27 篇末尾提过这条路,这里展开。

永不覆盖任何东西。 要改一个数据块:

1. 把新内容写到一个空闲块 B'
2. 指向它的那个索引块也不能原地改 —— 复制一份 I',让 I' 指向 B'
3. 指向 I' 的上一级也一样 …… 一路复制到根
4. ★ 最后,原子地把「根指针」换成新的根

第 4 步之前,磁盘上完整地并存着新旧两棵树;第 4 步之后,瞬间全部切换。中间没有任何时刻是不一致的。

这就是写时复制(CoW)。ZFS 和 btrfs 走这条路,顺带白拿三个好处:

  • 快照几乎免费——留着旧的根指针,那棵旧树就还在。
  • 不需要 fsck——盘上永远是某个完整的版本。
  • 不需要日志——提交本身就是原子的。

代价也很实在:

  • ⚠️ 碎片。 一个顺序写出来的文件,被随机改几次之后,物理上散得到处都是。数据库文件在 CoW 文件系统上性能会明显下降——所以 btrfs 提供了 nodatacow 属性专门给这类文件用。
  • ⚠️ 空间放大。 旧版本要等没人引用才能回收。快照留得多,空间就下不去。
  • 改一个字节要重写一整条到根的路径。

⭐ 第 27 篇说过这句话,这里再说一遍:它和 SSD 的 FTL 是同一招。区别只是一个在文件系统里、由内核做,一个在盘的固件里、由厂商做。两层同时在做同一件事,这正是现代存储栈里很多性能困惑的来源。

四、校验和:让静默损坏不再静默

回到第一节那个演示。

答案很朴素:给每个数据块存一个校验和,读的时候算一遍,对不上就报错。

要点在于校验和存在哪

⚠️ 不能和数据存在一起。 存在一起的话,整块写丢了(比如写了一半断电、或者盘把整块搞坏了),数据和校验和一起是旧的——它们互相一致,检查通过

正确的做法是把校验和存在"指向这个块的那个指针"旁边。ZFS 就是这么设计的:父节点里同时存着"子块在哪"和"子块的校验和"。于是:

  • 读一个块 → 用它父节点里的校验和验证。
  • 一路到根,形成一棵默克尔树——根的校验和覆盖了整个文件系统。

⭐ 这个结构你在别的地方见过:Git 的对象图、区块链的默克尔树,是完全一样的东西。(如果你读过区块链原理那一支,那里的第 5 篇讲的就是它。)

而且校验和只是发现问题。 ZFS 更进一步:如果有冗余(镜像或 RAID-Z),它会自动从好的那份读出来,顺手把坏的那份修好——这叫 self-healing。

⚠️ 但要注意范围:ext4 有元数据校验和,但默认不校验文件数据。 所以在普通的 Linux 系统上,第一节那个演示的结果就是你会遇到的结果——没人告诉你数据错了

五、几条线索收到一起

这一部分(第 23–28 篇)里,同一个手法反复出现,值得并排放一次:

手法 出现的地方
不原地改,写到新地方再改指针 SSD 的 FTL(24)、LFS(28)、写时复制(28)、LSM-tree(28)
一张"逻辑 → 当前物理位置"的映射表 页表(12)、FTL(24)、inode 的块地图(26)、LFS 的 imap(28)
有固定开销就批量 DMA(23)、日志的组提交(27)、LSM 的 compaction(28)
不能保证所有事,就保证一个顺序 ext4 的 ordered 模式(27)、日志的提交记录(27)
一致 ≠ 正确 fsck 只保证结构(27)、静默损坏(28)

值得注意的是,这些手法在不同层次上被独立地重新发明了好几次。 页表、FTL、inode 的块地图,是三个团队在三个年代、为三个完全不同的问题设计的——结构一模一样

这也是这门课想让你带走的东西:记住一个机制的名字,不如认出它的形状。

六、代价与取舍

这三个方向各自换来了什么: 顺序写的性能、原子提交和免费快照、以及"知道数据错了"这个能力。

它们花了什么:

  • 日志结构和 CoW 都要垃圾回收,都有写放大,都在盘快满时变差。
  • CoW 会碎片化,对数据库这类负载不友好。
  • 校验和要额外的空间和 CPU,而且要求文件系统重新设计(不能事后加上去)。

共同放弃了什么:简单。 ext4 至今仍是 Linux 上最常用的文件系统,不是因为它最先进,是因为它足够好、足够久经考验、行为足够可预测。ZFS 和 btrfs 更强大,也更复杂——而在存储这一层,复杂度本身就是一种风险

⚠️ 还有一层现实:这些机制在存储栈里正在重复。 你的数据库在做日志和 compaction,文件系统在做日志或 CoW,SSD 固件在做 FTL 和垃圾回收——三层都在"不原地改、写新的、回收旧的"。三层的写放大是相乘的。这就是为什么高性能存储系统会想尽办法绕开某几层(O_DIRECT、裸设备、ZNS SSD)。

七、小结

  • ⭐ 实测:改掉一个字节,read() 返回成功、长度正确、没有任何错误。 传统文件系统从没记下"这块应该长什么样"——一致 ≠ 正确
  • LFS:把整个盘当成只能追加的日志,所有写都是顺序的。inode 到处飘,靠 imap 找——⭐ 和 SSD 的 FTL 是同一个东西。代价是垃圾回收
  • LFS 本身没成功,但思想赢了:SSD 的 FTL、所有 LSM-tree 引擎(compaction 就是垃圾回收)、F2FS。⚠️ 它为机械盘的寻道设计,真正的舞台却是闪存——同一个设计,两个完全不同的理由。
  • 写时复制:一路复制到根,⭐ 最后一次原子的根指针替换就是提交。白拿快照、不用 fsck、不用日志。⚠️ 代价是碎片(数据库负载受损)和空间放大
  • 校验和:⭐ 必须存在指向这个块的父节点里,不能和数据放一起(一起坏就一起自洽)。一路到根形成默克尔树——和 Git、区块链是同一个结构。⚠️ ext4 默认不校验文件数据。
  • ⭐ 同一批手法在不同层次被独立发明了好几次:页表 / FTL / inode 块地图结构一模一样。认出形状,比记住名字有用。
  • ⚠️ 这些机制在存储栈里正在重复,数据库、文件系统、SSD 固件三层都在做同一件事,写放大是相乘的

思考题

  1. 第二节说 LFS 的垃圾回收在盘快满时表现很差。这和第 24 篇说的"SSD 快写满时变慢"是同一个原因吗?两者能同时缓解吗?
  2. 第四节说校验和不能和数据存在一起。那么它存在父节点里,父节点自己坏了怎么办?这个递归在哪里停下来?
  3. 一个数据库在 CoW 文件系统上跑,它自己也有 WAL(预写日志)。这两层日志有没有重复?能不能关掉一层?关哪一层更安全?
  4. 第五节那张表里,“页表 / FTL / inode 块地图"结构相同。它们在失效时的表现上有什么根本不同?(提示:谁能重建,谁不能。)

延伸