跳转到内容
← 返回系统剖析
系统剖析当代36 分钟阅读

ZFS:不覆盖写,与端到端校验

ZFS: Never Overwrite, Always Verify

有一类故障,比磁盘冒烟难对付得多。 磁盘没坏,SMART 全绿,读请求正常返回,没有任何一层报错——但返回的那 4 KiB 不是你当年写进去的内容。可能是固件把数据写到了错误的扇区,可能是控制器的 DMA 传输翻了一个比特,可能是缓存声称"已经落盘"其实没有。这类错误有个名字:静默数据损坏(silent data co…

文件系统写时复制数据完整性存储可靠性快照

有一类故障,比磁盘冒烟难对付得多。

磁盘没坏,SMART 全绿,读请求正常返回,没有任何一层报错——但返回的那 4 KiB 不是你当年写进去的内容。可能是固件把数据写到了错误的扇区,可能是控制器的 DMA 传输翻了一个比特,可能是缓存声称"已经落盘"其实没有。这类错误有个名字:静默数据损坏(silent data corruption),俗称 bit rot。它的可怕之处不在于概率高,而在于没有人会告诉你。你的备份会忠实地备份坏数据,你的 RAID 会忠实地把坏数据算进校验,等到三年后你打开那个文件,一切都太晚了。

ZFS 是对这个问题给出的最系统的一次回答。它由 Sun Microsystems 的团队开发,主创是 Jeff Bonwick、Matt Ahrens 与 Bill Moore,开发始于 2001 年,2004 年 9 月 14 日公开宣布,2005 年 10 月 31 日并入 Solaris 主干,同年 11 月 16 日随 OpenSolaris build 27 发布。它的核心主张只有两句话:

  1. 永远不覆盖已经写下去的数据(copy-on-write,写时复制)。
  2. 每个块的校验和存在它的父块里,从根到叶构成一棵校验树。

这两句话推导出了它几乎全部的特性——不需要 fsck、能发现磁盘在撒谎、RAID-Z 没有写洞、快照几乎免费——也推导出了它几乎全部的代价:吃内存、会碎片化、池满即塌、扩容长期受限。这篇文章要讲的就是这条推导链:从两句话到一整套后果,好的和坏的都是同一个决定生出来的。

破除误解

误解一:ZFS 不需要 fsck,所以 ZFS 的数据坏不了。

这是把"一致性"和"正确性"搞混了。ZFS 不需要 fsck,是因为磁盘上的状态在任何时刻都是一棵完整合法的树——要么是旧的那棵,要么是新的那棵,不存在"改了一半"的中间态。fsck 这个工具存在的意义,本来就是修复那种中间态;没有中间态,工具自然没了用武之地。

但反过来说:ZFS 也没有 fsck 可以救你。当硬件用某种 ZFS 没有预料到的方式撒谎(比如 RAID 卡的电池坏了、缓存丢了、磁盘对 flush 命令阳奉阴违),元数据仍然可能坏到无法导入。这时候 ZFS 的手段不是"修",而是"退":zpool import -F 回退到更早的一个事务组,代价是丢掉最近几秒到几十秒的写入;-X 是更暴力的搜索式回退。退不回去,就只剩 zdb 手工刨数据。传统文件系统坏了还有 e2fsck 帮你把东西凑回来一个"能用但不知道对不对"的状态,ZFS 的哲学是宁可拒绝导入也不给你一个来路不明的文件系统——这是取舍,不是免疫。

误解二:我已经上了 RAID-5/RAID-6,校验和是多此一举。

传统 RAID 的奇偶校验解决的是"磁盘明确地告诉你它读不出来"这个问题。当磁盘返回一个读错误,RAID 用其他盘重算出丢失的数据。但如果磁盘成功返回了错误的数据呢?RAID 控制器在正常读取时根本不会去核对奇偶校验——那样每读一个块都要读全部磁盘,性能无法接受。即使核对了、发现不一致,它也没有办法判断是哪一块错了:数据和校验对不上,可能是任意一个数据盘错,也可能是校验盘错。

ZFS 不一样:每个块自己有 256 位校验和,且校验和存在父块里。所以任何一个块被读上来,ZFS 立刻能判定它是不是原件;一旦判定为假,它明确知道该找谁重建。这是"能定位到具体块"和"只知道系统里有个地方不对"之间的差别。

误解三:ZFS 必须用 ECC 内存,否则一次 scrub 会把整个池毁掉。

这个说法在论坛上流传了十几年,被称为 "scrub of death":据说非 ECC 内存里的一个比特翻转会让 scrub 误判好块为坏块,然后把坏数据写回去,一遍遍放大直到全池报废。ZFS 的核心开发者 Matt Ahrens 公开否认过这套机制——scrub 的修复路径需要重建出的副本通过校验才会写回,坏内存并不会驱动出这样一条自我毁灭的循环。

正确的说法是:ECC 内存对任何认真存数据的系统都值得上,但 ZFS 并不比 ext4 或 NTFS 更依赖它。真正的知识点在于"端到端"这个词的边界——ZFS 的校验和是在数据进入它的内存缓冲区之后才计算的。如果数据在计算校验和之前就在内存里被写坏了,ZFS 会一丝不苟地为这份坏数据计算校验和、写盘、并在此后每次读取时郑重地告诉你"完全正确"。威斯康星大学 2010 年那篇 FAST 论文《End-to-end Data Integrity for File Systems: A ZFS Case Study》做的正是这件事:证明 ZFS 对磁盘侧的错误几乎无懈可击,对内存侧的错误则毫无防御。

一、事务组与不覆盖写:为什么不存在"写到一半"

先看一次最普通的修改:你改了一个文件中间的 4 KiB。

传统文件系统的做法是把新数据写回原来那个位置。这一步在物理上不是原子的——扇区一个个落盘,掉电就可能留下"前半段是新的、后半段是旧的"的块。更麻烦的是元数据:分配位图、inode 的大小和时间戳、目录项,这些东西分散在磁盘各处,它们之间的一致性没有任何机制保证。日志式文件系统(ext4、XFS)用预写日志把元数据更新变成原子的,但日志本身是额外一份写入,而且默认只保护元数据,不保护数据。

ZFS 的做法是不覆盖

text
修改一个叶子块,整条祖先链都要重写:

uberblock ─┐ uberblock' ─┐ ↓ ↓ MOS MOS' ↓ ↙ ↘ dnode(文件) dnode' (旧 dnode 原地不动) ↓ ↓ 间接块 L1 间接块 L1' ↓ ↓ ↓ ↘ D0 D1 D0' ────→ D1(旧块被新树共享) ↑ 新数据写在全新位置 ```

新数据写到一块全新的空闲空间,旧块一个字节都没动。但新数据的地址和校验和记在父块(间接块)里,所以父块也得改;父块也不能原地改,于是父块也被复制到新位置;这样一路向上,直到树根。没有被修改的那些子树(图里的 D1)不用动,新旧两棵树直接共享它们。所以一次小修改的写放大是一条路径,不是整棵树。

这些写入不是来一个做一个,而是攒在事务组(transaction group,txg)里。txg 走一条三段流水线:open(接收新写入)→ quiescing(封口,等待已有操作结束)→ syncing(真正下盘)。默认每 5 秒推进一次(zfs_txg_timeout),脏数据超过 zfs_dirty_data_max 也会提前触发。同步写(fsync、NFS 的同步语义、数据库的日志刷盘)不能等这 5 秒,它们先被记进 ZIL(ZFS Intent Log,意图日志),掉电重启后从 ZIL 重放——注意 ZIL 是"崩溃后补写"用的,正常路径上数据仍然走 txg,它不是写缓存

关键在最后一步。整棵新树写完之后,只剩一个指针需要切换:uberblock

uberblock 存在 vdev 标签里。每个物理设备上有 4 份完全相同的 256 KiB 标签(两份在盘头、两份在盘尾,防止一端被整片破坏),每份标签末尾是一个 128 KiB 的 uberblock 环形数组——512 字节扇区的盘上是 128 个 1 KiB 槽位,4K 扇区的盘上是 32 个 4 KiB 槽位。每个 txg 写入 txg mod 槽位数 那一格,写满一圈就覆盖最旧的。

导入池时,ZFS 扫描所有设备的所有标签,挑出事务号最大且校验通过的那个 uberblock 作为起点。uberblock 是整棵树上唯一没有父亲的块,所以它的校验和只能存在自己身上(SHA-256 自校验),并且计算时会把该块应当所处的物理偏移一起算进去——这样连"标签被写到了错误的位置"这种错误都能识破。

于是"崩溃一致性"这件事被压缩成了一个问题:uberblock 的那一次写是否成功。成功了,新树生效;没成功,上一个 txg 的 uberblock 仍然指着一棵完整的旧树,那棵树里所有的块都还在原处(因为从来没被覆盖过)。两种结果都是合法的文件系统,没有第三种可能。这就是不需要 fsck 的全部理由。

这个保证有一个硬前提:uberblock 必须在所有数据真正落到介质之后才写。ZFS 靠向设备发送缓存刷新命令(SCSI 的 SYNCHRONIZE CACHE / ATA 的 FLUSH CACHE)来建立这个顺序。廉价 U 盘、某些消费级 SSD、配置错误的 RAID 卡如果对 flush 撒谎(立即回复"完成"但数据还在易失缓存里),这条链就断了。ZFS 的一致性不是凭空来的,它建立在"硬件不撒谎"这个假设上——而这也正是它对硬件挑剔的原因。

二、块指针:一棵会自己验证自己的 Merkle 树

ZFS 里所有的"指针"都是同一个结构:blkptr_t,128 字节。它长这样(简化):

c
typedef struct blkptr {
    dva_t    blk_dva[3];   /* 最多 3 个数据虚拟地址(副本) */
    uint64_t blk_prop;     /* 逻辑/物理大小、压缩、校验算法、类型、层级 */
    uint64_t blk_pad[2];
    uint64_t blk_phys_birth; /* 数据实际写下去的 txg */
    uint64_t blk_birth;      /* 逻辑诞生的 txg */
    uint64_t blk_fill;       /* 子树里非空块的数量 */
    zio_cksum_t blk_cksum;   /* 256 位校验和 —— 校验的是"儿子",不是自己 */
} blkptr_t;
```

最后一行是整个设计的枢纽:校验和描述的是它指向的那个块,而不是它自己所在的块。父块的校验和又存在祖父块里,一路到 uberblock。这正是密码学里 Merkle 树的结构——ZFS 的整个磁盘布局就是一棵巨大的、自验证的 Merkle 树。

把校验和放在父块而不是块自身,差别有多大?如果校验和跟数据放在一起,磁盘只要把"数据 + 校验和"整体写到了错误的位置,或者返回了同一个块的旧版本,自校验一定通过——数据和校验和是自洽的,只是不是你要的那份。把校验和挪到父块,这两类错误立刻现形。

具体能抓到什么:

错误类型磁盘自身 ECC传统 RAID-5/6ZFS 校验树
介质缺陷导致读不出(磁盘明确报错)发现发现并重建发现并重建
位翻转发生在盘片上多数能发现依赖磁盘报错发现并重建
错位写(数据写到了错误的 LBA)无感无感发现(父块不符)
幽灵写(报告成功、其实没写)无感无感发现(读到旧块)
错位读(读回了另一个扇区)无感无感发现
控制器 / 线缆 / DMA 途中翻转无感无感发现
驱动或固件 bug 造成的内容错乱无感无感发现
数据在进入 ZFS 之前就在内存里坏了无感无感无感

前七行是 ZFS 相对于传统栈的全部价值,最后一行是它诚实的边界。

校验算法:数据默认用 fletcher4(非密码学哈希,快,输出 256 位);需要更强保证时可选 SHA-256、Skein、Edon-R,BLAKE3 在 OpenZFS 2.2(2023 年)进入发行版。去重功能强制使用密码学哈希,因为它要靠哈希相等来判定内容相等。

自愈:读到校验不符时,ZFS 不是报错了事。镜像上它去读另一面;RAID-Z 上它用奇偶校验重建;重建出来的候选块必须再次通过校验才被接受,然后 ZFS 把好数据写回坏的那个位置。用户程序对这一切毫无察觉,只是 zpool status 的 CKSUM 计数加了一。RAID-Z2/Z3 上还有一招"组合重建":如果不知道哪块盘错了,就穷举各种重建组合,直到某一种的校验和对上。

主动扫描zpool scrub 按 txg 顺序遍历整棵树上所有活着的块,读出全部副本逐一校验。这是防止潜在错误堆积的关键——一块盘上的静默损坏如果不主动去碰,会一直躺到另一块盘故障、真正需要它重建的那一刻才暴露,而那时已经没有冗余可用了。

多份元数据(ditto blocks):元数据默认存 2 份,池级元数据存 3 份,尽量分散到不同 vdev。数据也可以用 copies=N 属性存多份。这些副本地址就是 blk_dva[3] 那三个槽位的用途。

这套机制值不值得?两组公开数据:

  • CERN,2007 年。Bernd Panzer-Steindel 的内部数据完整性报告:在约 3000 个节点上每两小时写入 2 GB 并回读比对,持续 5 周,最终在约 100 个节点上发现约 500 处不匹配,其中大部分是 64 KiB 量级的区域损坏。这些机器在此期间没有报告任何错误。
  • NetApp,2008 年。Bairavasundaram 等人的 FAST '08 论文《An Analysis of Data Corruption in the Storage Stack》,统计了 153 万块生产环境磁盘 41 个月的数据,记录到超过 40 万次校验和不匹配。近线(SATA)盘出现校验和不匹配的频率比企业级盘高一个数量级

换句话说:静默损坏不是理论上的担忧,它每天都在发生,只是绝大多数系统没有能力知道。

三、RAID-Z:用变长条带消灭写洞

要理解 RAID-Z,先要理解它针对的那个具体病灶。

RAID-5 的一行叫条带(stripe):n 块数据 + 1 块奇偶校验,校验是这 n 块的异或。现在你只想改其中一块数据。理论上应该重算整行校验,但那要读全部 n 块,太贵。实际做法是读-改-写:读出旧数据块和旧校验块,用 新校验 = 旧校验 ⊕ 旧数据 ⊕ 新数据 算出新校验,然后写下新数据和新校验——两次独立的写

写洞(write hole)就在这两次写之间。掉电了,假设新数据写成了、新校验没写成。这一行现在处于自相矛盾的状态,但没有任何人知道:所有扇区都读得出来,磁盘不报错,校验只有在需要重建时才会被用到。等到某天一块盘故障,RAID 拿这个陈旧的校验去重建,重建出的是一份看起来完全正常的错误数据。一次掉电,在几个月后变成了静默数据损坏。

传统方案是往这个洞里塞硬件:带电池的 RAID 卡缓存(BBU/NVRAM),或者再加一层日志(Linux md 从 4.4 起的 write-journal、4.12 起的 PPL 部分奇偶日志)。两条路都是"再补一层保护",而不是"让这个洞不存在"。

RAID-Z 的做法是从根上取消读-改-写。它的条带是变长的:每一次块写入都自己构成一个完整条带,宽度按需决定。

text
6 盘 RAID-Z1(单奇偶)上写入不同大小的块:

盘: d0 d1 d2 d3 d4 d5 128K块: P0 D D D D D ← 用满 5 个数据列 P0' D D D D D 4K 块: P1 D ← 只用 1 数据 + 1 校验 16K块: P2 D D D D ← 换行继续 ```

一个 4 KiB 的块在 6 盘 RAID-Z1 上只占 1 个数据扇区 + 1 个校验扇区,不会去动这一"行"里其他任何东西。因为每次写都是全条带写,读-改-写这个动作根本不存在;又因为 ZFS 是写时复制,这个新条带写在空闲空间里,在 uberblock 提交之前对任何人都不可见。没有部分更新,就没有写洞。 这不是补丁,是把病灶所在的那个操作从系统里删掉了。

RAID-Z1/Z2 随 ZFS 一同发布,三重奇偶的 RAID-Z3 由 Adam Leventhal 在 2009 年加入——他那篇《Triple-Parity RAID and Beyond》从磁盘容量增长快于吞吐增长的趋势出发,论证了重建窗口越来越长、双奇偶迟早不够用。

代价同样具体:

维度传统 RAID-5/6RAID-Z1/Z2/Z3dRAID(OpenZFS 2.1, 2021)
写洞存在,需 NVRAM 或日志规避结构上不存在结构上不存在
随机读性能单块可落在单盘,IOPS 可叠加每块跨多列,单 vdev 的 IOPS ≈ 单盘同 RAID-Z
小块空间按条带对齐,浪费可控分配须为(奇偶数+1)扇区的整数倍,小块浪费显著固定宽度条带,小块浪费更严重
重建方式全盘顺序复制,与用量无关遍历块树,只重建活数据,但是随机 I/O顺序重建 + 分布式热备,快得多
校验是否核对正常读取时通常不核对每次读都核对,且能定位到具体块同 RAID-Z

第二行是选型时最容易被忽略的:一个 RAID-Z vdev 的随机读 IOPS 约等于一块盘,因为读任何一个块都要拉齐它的所有数据列。12 块盘做成一个 raidz2,容量利用率漂亮,随机 IOPS 却只有一块盘的水平;做成 6 组镜像,容量减半,IOPS 是六块盘。跑数据库和虚拟机磁盘用镜像、存归档和媒体用 RAID-Z,原因就在这里。

第三行的坑更隐蔽:RAID-Z 的分配必须凑成(奇偶数 + 1)个扇区的整数倍,好让释放出来的空间将来总能容纳校验。在 4 KiB 扇区(ashift=12)的盘上用 8 KiB 的 zvol 块跑 raidz2,实际空间效率会远低于名义上的 (n-p)/n,出现"明明按公式算有 40 TB,实际只装得下 26 TB"这种令人困惑的结果。

四、快照与克隆:为什么几乎是免费的

理解了写时复制,快照就没什么可解释的了——它本来就是免费掉出来的。

打快照做的事情是:把当前这棵树的根记下来,并声明它不许被回收。不复制任何数据块,不遍历任何目录,创建时间与数据量无关,是 O(1)。此后所有的修改都写到新位置去,快照那棵树的每个块仍然待在原处。快照不是"数据的副本",是"一组块引用的保留声明"。

真正需要设计的是删除。一个块什么时候可以真的被释放?答案藏在块指针的 blk_birth 字段里:如果一个块的诞生 txg 晚于最近一个快照,说明没有任何快照见过它,删掉它时可以立即回收;如果它的诞生 txg 更早,说明至少有一个快照还引用着,只能记进那个快照的 dead list(死亡列表)。将来销毁快照时,遍历它的 dead list,逐个判断这些块是否还被更早或更晚的快照需要。这套设计避开了"给每个块维护引用计数"——那在几十亿个块的池上是灾难——把成本压到与两个快照之间的变化量成正比。

同一个机制还解释了 zfs send -i 为什么快得不像话:增量发送时遍历块树,任何一棵子树只要它的根块 blk_birth 不晚于基准快照,整棵子树直接剪掉不看。所以增量的代价正比于改动量,与文件系统的总大小、文件总数完全无关。这是它相对于 rsync 的结构性优势——rsync 必须把两边的目录树全部走一遍才知道什么变了。

克隆(clone)是可写的快照:从一个快照派生出一个新数据集,初始时它与源共享全部块,此后各写各的。开一百个虚拟机镜像的克隆,磁盘占用几乎为零,直到它们开始各自写入。zfs promote 可以把克隆和源的父子关系对调,好让原始数据集能被销毁。

代价也是这个机制直接推出来的:

  • 删文件不释放空间。数据集上只要有快照引用着那些块,rm -rf 释放的是零。运维最常见的困惑"df 说满了,可我什么都删了",答案通常在 zfs list -o spaceusedbysnaps 列里。
  • 销毁快照可能很贵。dead list 的遍历和空间回收是真实的 I/O 工作,销毁一个引用了大量独占块的老快照会引发可观的后台负载(现代版本有 async_destroy 做后台异步销毁,但工作量不会消失)。
  • 快照数量本身有成本。每小时一次、保留一年,加上高写入频率的数据集,池会被大量"没人再看但也不能删"的旧块填满。快照免费的是创建,不是保留

五、账单:内存、碎片、以及池满时的塌陷

到这里为止全是好消息。现在算账。

内存

ZFS 有自己的缓存 ARC(Adaptive Replacement Cache,改编自 IBM 的 Megiddo 与 Modha 于 2003 年提出的算法),它不是操作系统页缓存的一部分。在 Linux 上这意味着 ARC 和页缓存可能各存一份同样的数据,并且 ARC 在内存压力下的收缩历史上一直不够灵敏——"ZFS 吃内存"的名声主要来自这里。

流传最广的"每 TB 存储需要 1 GB 内存"是 FreeNAS 论坛时代的经验法则,不是 ZFS 的技术要求;普通读写场景下 ZFS 在内存有限的机器上跑得动,只是缓存命中率低。真正硬性的内存开销来自去重:去重表(DDT)里每个块一条记录,约 320 字节。一个 10 TB 的池,若全部是 128 KiB 的记录,约有 8200 万个块,DDT 就是 26 GB——而这已经是最乐观的估计,块越小条目越多。DDT 装不进 ARC 的后果不是"慢一点",而是每一次写入都要去磁盘上随机查表,写吞吐直接塌掉。OpenZFS 2.3(2025 年 1 月 13 日发布)的 fast dedup 用 DDT 日志、预取、剪枝和配额四招大幅改善了这一点,但没有改变它的性质:去重是一个必须用内存换的功能,绝大多数场景下不该开。

碎片化

写时复制的另一面是:数据一旦写下就再也不会被挪动。一个被随机改写多年的数据库文件,它的块散落在池的各个角落,逻辑上连续的读取变成了物理上的随机 I/O。ZFS 没有碎片整理工具——原因在下一节。

比文件碎片更要命的是空闲空间碎片化zpool listFRAG 列量的正是这个,而不是文件碎片)。ZFS 把每个 vdev 的空间切成约两百个 metaslab,每个 metaslab 的空闲空间用 space map 记录,使用时载入内存成为区间树。分配器采用"动态适配"策略:只要该 metaslab 的空闲比例还在 metaslab_df_free_pct(默认 4%)之上,就用首次适配(first-fit,从前往后找第一个够大的洞,快);跌破这条线就切换成最佳适配(best-fit,按大小搜索最贴合的洞,慢得多)。

当连一个足够大的连续空洞都找不到时,ZFS 会启用 gang block(团块):把一个逻辑块拆成几段分别存放,再写一个"团块头",里面装着指向这几段的块指针。逻辑上成功了,物理上一次读变成了多次随机读。频繁出现 gang block 是池已经碎得很难看的信号。

池满时的塌陷

这是 ZFS 最经典的运维陷阱,而它的根源很有意思:在写时复制的世界里,释放空间本身是一个写操作。删除文件要更新元数据,更新元数据要写新块,写新块需要空闲空间。一个真正 100% 满的 COW 池,连删文件都做不到。

ZFS 为此预留了 slop space:spa_slop_shift 默认为 5,即扣下池容量的 1/32(约 3.1%)不给普通写入用,专门留给"为了释放空间而必须做的那些写"。这条保险绳的存在本身就说明了问题的严重性。

实践上,使用率越过 80%~90% 之后,分配器进入最佳适配、连续空间越来越难找、gang block 开始出现,写吞吐可能掉到几分之一。这不是渐进的性能退化,更像是撞墙。"ZFS 池不要用超过 80%"不是玄学,是分配器行为的直接后果。

扩容为什么长期受限

这一节是全文的收束点。看一眼这些操作在 ZFS 里的处境:

你想做的事传统卷管理ZFS 的情况
给 RAID 组加一块盘常规操作直到 OpenZFS 2.3(2025)才支持,且旧块保持旧的数据/校验比
移除一块盘 / 缩小池常规操作仅镜像与条带 vdev 可移除(0.8, 2019),且永久留下间接映射表;RAID-Z 不支持
加了新 vdev 后重新平衡数据常规操作不会自动发生;旧数据永远待在旧 vdev 上
给已有文件改 recordsize做不到,即使用 zfs rewrite 也不行
碎片整理常规操作没有这个工具

五行的根因是同一件事:块指针重写(block pointer rewrite, BPR)从未被实现。而它之所以做不出来,恰恰是因为第二节那个漂亮的设计——一个块的地址和校验和记在它的父块里。要把一个块搬到别处,就得改它的父块;改父块就得改祖父块;一路改到根。而这条祖先链在每一个引用了它的快照和克隆里都存在一份,快照按定义是不可变的。再加上去重表也按地址引用着这些块。于是"把一个块挪个位置"这件在传统文件系统里再普通不过的操作,在 ZFS 里牵动整个宇宙。

社区在这个约束下做的是绕行:

  • RAID-Z 扩容(2.3,2025 年 1 月):把已有块"重排"(reflow)到更宽的盘组上,但不重写它们,所以每个旧块保留着创建时的数据/校验比。加完盘容量数字变好看了,但旧数据没有享受到新的空间效率,要等它们被自然改写才逐渐收敛。
  • zfs rewrite(2.3.4,2025 年):原地逻辑重写文件内容,可以让新的压缩算法、校验算法、copies 属性作用到老数据上,顺带完成去碎片和跨 vdev 再平衡。但它不能改 recordsize——因为它替换的是"相同逻辑大小"的块,逻辑分块方式本身动不了。
  • dRAID(2.1,2021):用固定宽度条带 + 分布式热备换取顺序化重建,把大池的重建时间从几天压到几小时,代价是小块空间浪费更严重。

这就是那条推导链的终点:让 ZFS 崩溃一致、可校验、可快照的那个决定,同时让它的数据无法被搬动。优点和缺点不是两笔账,是同一笔账的两面。

六、什么时候不该用它

  • 单盘无冗余。ZFS 能告诉你哪个文件坏了(zpool status -v 会列出文件路径),但修不了。copies=2 只防局部损伤,不防整盘故障。知道坏了却救不回来,比不知道好,但也仅此而已。
  • 小块随机写为主的负载放在 RAID-Z 上。8 KiB 的写落进 128 KiB 的记录里,触发的是整条记录的读-改-写,写放大惊人。这类负载要么调小 recordsize/volblocksize,要么老老实实用镜像。
  • SMR(叠瓦式)硬盘。它与写时复制的随机写模式是天生的冤家。
  • Linux 上的许可证摩擦。ZFS 采用 CDDL,与 GPL 不兼容,因此永远不会进入 Linux 主线内核,只能以树外模块存在。内核升级后 DKMS 编译失败、根文件系统起不来,是这条路上真实存在的运维成本。
  • 实现层面的 bug 不受任何数学保证的庇护。2023 年 11 月的 CVE-2023-49298 是最好的提醒:一个长期潜伏的 dnode 脏状态判断缺陷,会让 lseek(SEEK_HOLE) 把刚写入、尚未落盘的数据误报成空洞,而使用空洞跳过优化的 cp 就把那段写成了全零。OpenZFS 2.2.0 新引入的块克隆功能大幅提高了触发概率,社区在 2.2.1 里紧急默认关闭该功能,2.2.2 修复了根因。请注意这个故事的关键处:数据是在进入 ZFS 的写路径之后、计算校验和之前就已经错了,于是 Merkle 树一丝不苟地为这份全零数据计算了校验和、写盘、并在此后每次读取时确认它"完全正确"。

最后这一点值得单独记住。端到端校验保证的是"你读到的,就是当初交给它的那份"。它从不保证、也无法保证"当初交给它的那份是对的"。这是一台极其精密的公证机器,而公证机器不判断真伪,只判断是否被篡改。

跨域连接

  • 概率论:RAID 的层数之争本质上是一道概率题——单盘不可恢复读错误率(URE)大约在 10⁻¹⁴ 到 10⁻¹⁵ 每比特量级,当单盘容量涨到十几 TB,一次完整重建要读取的比特数已经逼近这个数量级,"重建过程中再遇到一个坏扇区"从小概率事件变成了常规事件——Adam Leventhal 在 2009 年论证 RAID-Z3 必要性时用的正是这个推理:磁盘容量在指数增长,而单盘吞吐几乎停滞,重建窗口因此逐年拉长,窗口内发生第二次、第三次故障的概率随之上升。这解释了为什么容错层数必须随硬件演进而增加,也解释了为什么 ZFS 坚持主动 scrub——它把"故障时才发现潜在错误"的条件概率,换成了"平时就把错误清掉"的无条件低风险。
  • 工业工程与质量管理:传统存储栈的质量控制是分段自检——磁盘查自己的 ECC、控制器查自己的传输、文件系统信任下层,每一段都合格,整条链路却没人负责——这正是戴明反复批评的那种"检验不出质量"的组织形态:把检验点分散到各工序内部,各工序都能出具合格证明,而缺陷恰恰产生在工序之间的交接处。ZFS 的端到端校验相当于把检验点从每道工序内部搬到了最终消费端,由使用数据的人亲自核对它与出厂时是否一致;再配合 scrub 这种全量周期性抽检而非事后抽样,构成了质量管理里"源头控制 + 全检"的完整形态。代价也与制造业一致:全检永远比抽检贵,ZFS 为此付出的就是每一次读取的校验计算开销。
  • 失效分析:静默数据损坏是失效分析里最难处理的一类——潜在缺陷(latent defect)在很久之后才与另一个事件耦合,最终以完全不相干的症状爆发——磁盘上一处几个月前形成的坏块,直到另一块盘故障、需要用它重建时才产生后果,此时现场早已被破坏,根因不可追溯。ZFS 的应对是把"事后归因"改成"事发时定位":校验和让每一次错误在发生的那一刻就被记录到具体的块、具体的设备、具体的文件路径上,zpool status 的 READ/WRITE/CKSUM 三列相当于一份持续更新的失效分布表。这是失效分析方法论的核心转向——与其提升事后调查能力,不如在系统里预埋足够密的观测点,让失效自己说出它是谁。
  • 真理是什么:Merkle 树能证明的命题非常有限——它只能说"这份数据与写入时记录下来的那份自洽"——这是典型的融贯论式的真:一份数据之所以被判为真,是因为它与父块记录的校验和、与整棵树的结构相互吻合,而不是因为它与外部世界的任何事实相对应。CVE-2023-49298 那次事故把这个区别摊开了:全零的数据在校验体系内部完美自洽,每一次读取都被郑重确认为"正确",但它与用户实际写下的内容毫无关系。任何验证系统都必须选定一个信任起点——ZFS 的起点是"进入内存缓冲区的那一刻"——起点之前的错误在系统内部是不可见、也不可表达的。这不是工程上的疏忽,而是一切形式验证共有的边界。
  • 记忆系统:人脑的长期记忆与 ZFS 走的是完全相反的路线——每次提取都可能触发再巩固(reconsolidation),记忆在被回想的过程中被改写,然后以修改后的版本重新存回去——这是彻头彻尾的"原地覆盖写",而且没有校验和,也没有旧版本可比对,所以人无法察觉自己的记忆已经被后来的信息污染,还会对篡改后的版本报以更高的信心。ZFS 用不覆盖写换来了任意时刻的历史快照与逐块的真伪判定,代价是无法整理、无法搬移、空间只增不减;人脑选择了覆盖写,换来了极高的存储效率与灵活的重组能力,代价是没有任何一份记忆可以被证明未经改动。两种设计的取舍结构惊人地对称:可验证性与可塑性,从来不能兼得。

参考文献

  • Jeff Bonwick, "ZFS End-to-End Data Integrity", Sun 官方博客, 2005 年 12 月。—— 校验和放在父块而非块自身的原始论证,Merkle 树式布局的第一手说明。
  • Jeff Bonwick, "RAID-Z", Sun 官方博客, 2005 年 11 月。—— 变长条带如何消除读-改-写与写洞的原始阐述。
  • Sun Microsystems, ZFS On-Disk Specification (Draft), 2006。—— vdev 标签、uberblock 数组、blkptr_t、DVA 与 dnode 的字段级权威定义。
  • Lakshmi N. Bairavasundaram, Garth R. Goodson, Bianca Schroeder, Andrea C. Arpaci-Dusseau, Remzi H. Arpaci-Dusseau, "An Analysis of Data Corruption in the Storage Stack", USENIX FAST '08 (亦见 ACM Transactions on Storage 4(3), 2008)。—— 153 万块磁盘、41 个月、40 万次校验和不匹配的实测基础。
  • Bernd Panzer-Steindel, "Data Integrity", CERN/IT 内部报告 Draft 1.3, 2007 年 4 月。—— 大规模计算集群上静默损坏的实测数据。
  • Yupu Zhang, Abhishek Rajimwale, Andrea C. Arpaci-Dusseau, Remzi H. Arpaci-Dusseau, "End-to-end Data Integrity for File Systems: A ZFS Case Study", USENIX FAST '10。—— 通过故障注入界定 ZFS 保护范围的边界,内存侧脆弱性的学术依据。
  • Adam Leventhal, "Triple-Parity RAID and Beyond", ACM Queue 7(11), 2009。—— RAID-Z3 的动机论证:容量增长与重建窗口的赛跑。

延伸阅读

  • Jim Salter, "ZFS 101—Understanding ZFS storage and performance" (Ars Technica, 2020)。面向运维的完整入门,vdev 拓扑选型、recordsize 调优与常见误区讲得比官方文档更接地气。
  • Michael W. Lucas & Allan Jude, FreeBSD Mastery: ZFSFreeBSD Mastery: Advanced ZFS (Tilted Windmill Press, 2015–2016)。两册合起来是目前最系统的 ZFS 实操教材,快照策略、send/recv 与故障处置有大量真实案例。
  • OpenZFS 官方文档(openzfs.github.io/openzfs-docs)与 zfs(8)zpool(8)zfs-rewrite(8) 手册页。参数语义与版本差异以此为准,尤其是各发行版之间默认值不同的调优项。