OpenZFS 存储栈深度工程实战:从 SPA/DMU/DSL 四层架构、COW 与 TXG 事务组、ZIL/SLOG 同步写、RAID-Z 可变条带到 ARC 自适应替换缓存的全链路解析
执行摘要
大多数人对 ZFS 的理解停留在"一个支持快照和压缩的文件系统"。但真正让 ZFS 在二十多年后依然没有过时设计的替代品,是它把事务语义下沉到了存储栈的最底层:任何一次写,无论是一个字节还是一整个文件,都会进入一个全局有序的事务组(TXG),要么整体生效,要么整体不生效。这意味着 ZFS 永远不需要 fsck,因为磁盘上的上一代 uberblock 天然就是一个一致的时间点快照。
本文拆解 ZFS 落盘的每一层结构:SPA/DMU/DSL/ZPL 四层是怎么分工的,128KB 的 blkptr 间接块树如何支撑写时复制(COW),TXG 的三态机如何把随机写聚合成顺序写,ZIL 与 SLOG 为什么是同步写负载的救命稻草,RAID-Z 的可变条带为什么会吃掉你的容量,以及 ARC 为什么不是 LRU。最后给出一份生产环境可照抄的调优清单。
一、四层架构:SPA / DMU / DSL / ZPL
ZFS 不是"一个文件系统 + 一个卷管理器"的拼接,而是一棵严格分层的对象树:
| 层 | 全称 | 职责 |
|---|---|---|
| ZPL | ZFS POSIX Layer | 把 POSIX 文件语义(目录、inode、权限)映射到 DMU 对象 |
| DSL | Dataset and Snapshot Layer | 数据集、快照、克隆、配额、压缩/去重属性的管理者 |
| DMU | Data Management Unit | 真正的对象存储层,dnode → 间接块 → 数据块 |
| SPA | Storage Pool Allocator | 块分配、vdev 树、I/O 调度、磨损管理 |
关键工程点:DMU 之下没有"文件"这个概念。一个文件在 ZPL 里是一个 znode,在 DMU 里就是一个 dnode(512 字节,包含最多 3 个直接块指针,超过则挂一级间接块)。快照之所以是 O(1) 的,正是因为快照只是复制了 dnode 的 block pointer 集合,并给被引用的块增加引用计数——没有任何数据搬运。
二、写路径:COW 与 128 字节的 blkptr
ZFS 的块指针(blkptr)是整个设计的枢纽。简化后的结构:
typedef struct blkptr {
dva_t blk_dva[3]; /* 3 份数据虚拟地址(ditto block) */
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; /* 强校验和 */
} blkptr_t;
每个 blkptr 内置 3 个 DVA,意味着元数据默认存 3 份(ditto_blocks),数据存 1 份。这就是为什么 ZFS 对"静默数据损坏"(bit rot)的抵抗力远超 ext4/XFS——文件系统自己知道每个块的校验和,而硬件 RAID 卡不知道该信任哪块盘。
写时复制的流程是:
- 修改某个 128KB 记录(recordsize 默认 128KB)。
- 不覆写原块,而是在 metaslab 里分配新块写入。
- 更新父间接块的 blkptr → 父块本身也要 COW → 一路向上直到 uberblock。
- uberblock 原子提交(256 个轮转槽位,取 TXG 最大且校验通过者)。
# 观察一次写到底产生了多少 COW 放大
zpool iostat -r 1 5 # 看 ARC 命中与 DMU 读写
zfs get recordsize,compression,logbias tank/pgdata
这里有个常被忽略的代价:COW 把顺序写变成了随机写。一个被反复覆盖的数据库文件,在无 COW 文件系统上是原地更新同一批 LBA,在 ZFS 上每次都落在新的 metaslab 位置。这也是为什么数据库场景下必须把 recordsize 调到与数据库页大小一致(PostgreSQL 用 8KB 或 16KB,MySQL InnoDB 用 16KB):否则一次 8KB 的页写入会触发读-改-写整个 128KB 记录的放大。
三、TXG 事务组:把随机写聚合成组提交
TXG(Transaction Group)是 ZFS 吞吐的发动机。它有三态,任意时刻只有一个 TXG 处于 syncing:
open ← 接收新写入,可无限增长(受 dirty_data 上限约束)
↓ (5 秒 或 dirty_data 达到阈值)
quiescing ← 停止接收,等待已发起的 I/O 完成
↓
syncing ← 把所有脏块一次性写入,最后提交 uberblock
工程含义非常直接:一次 fsync 之外的普通写,其持久化延迟是"下一个 TXG 提交"而不是"现在"。默认 zfs_txg_timeout=5 秒,意味着断电最多丢 5 秒的异步写——这与 ext4 的 commit=5 语义相当,但 ZFS 的一致性更强(丢的是整个 TXG,而不是半个 journal 回放)。
问题在于 zfs_dirty_data_max(默认约为物理内存的 10%,最小 64MB)。当脏数据达到该值的 zfs_dirty_data_sync_percent(默认 20%)时,写入方会被主动延迟(每个 TXG 注入一点点 sleep),这是 ZFS 特有的"写停顿"现象:
# 诊断写停顿:看 txg_sync 是否长期占满,以及延迟注入是否触发
cat /proc/spl/kstat/zfs/txg # 或 FreeBSD: sysctl kstat.zfs.misc.txg
cat /proc/spl/kstat/zfs/dbufs
生产经验:不要让 dirty_data 无限增长。在写入密集、且后端是 NVMe 的场景,把它压到 2~4GB 反而能让延迟曲线更平滑,因为每个 TXG 更小、syncing 更快完成,避免了"攒一波大的"导致的周期性毛刺。
四、ZIL 与 SLOG:同步写的逃生通道
TXG 是异步写的故事。对于 fsync()、O_SYNC、数据库的 redo log 写入,等 5 秒是不可接受的。ZIL(ZFS Intent Log)就是为这类请求准备的:
- 同步写先以日志形式 append 到 ZIL(池内或独立的 SLOG 设备)。
- 立即返回,保证崩溃后可重放。
- 数据最终仍由 TXG 正常落盘,ZIL 只是"意图"记录,稳态下几乎不被读取。
# 加一块 NVMe 作为 SLOG(注意:镜像以保障 SLOG 自身故障不丢数据)
zpool add tank log mirror /dev/nvme1n1 /dev/nvme2n1
zfs set logbias=latency tank/pgdata # 数据库 redo:走 ZIL
zfs set logbias=throughput tank/backup # 大块顺序写:绕过 ZIL 直接写主池
三个血泪教训:
- SLOG 必须是断电保护的 NVMe。普通消费级 SSD 掉电会丢失 DRAM 缓存中的 ZIL,效果等同于没加。
- SLOG 不需要大。它只需容纳一个 TXG 周期(约 5~10 秒)的同步写量,16~32GB 对绝大多数场景绰绰有余。买 1TB 的 SLOG 是纯粹的浪费。
- 不是所有慢都要加 SLOG。先确认你的负载真的是同步写:用
zilstat观察每秒 ZIL 提交次数与字节数,如果zilstat显示几乎为 0,说明瓶颈在 TXG 或 ARC,加 SLOG 毫无帮助。
五、RAID-Z:可变条带与容量税
RAID-Z 与传统 RAID-5 的根本差别是变长条带:一次写入 N 个数据块 + P 个校验块,N 取决于本次写入的实际大小(受 recordsize 约束),而不是固定的条带宽度。这消除了 RAID-5 的写惩罚(read-modify-write),但引入了两个代价:
- 校验开销:小块写入会产生 padding。当 recordsize=128KB 而单次写入只有 4KB 时,条带里塞不满,padding 白白占用校验空间。经验法则:RAID-Z 上跑小随机写,有效容量可能只有理论值的 60%~70%。
- ashift 一旦设定不可更改:
ashift=12(4KB 扇区)是现代盘的标配,如果在 4Kn/8Kn 盘上误用ashift=9,会遭受灾难性的写放大。OpenZFS 通常能自动探测,但务必在zpool create时用zpool get ashift复核。
# 建池时显式指定,杜绝探测失败
zpool create -o ashift=12 tank raidz2 sda sdb sdc sdd sde sdf
# recordsize 与业务对齐
zfs create -o recordsize=16K -o compression=zstd tank/pgdata
另一个实战要点:resilver(重建)是顺序读 + 校验驱动的,不需要读整块盘的全部元数据,因此 ZFS 的重建速度通常显著快于硬件 RAID 的全盘重建;但重建期间 I/O 优先级调度(zfs_resilver_delay)会挤压正常业务 I/O,需要用 zfs_resilver_min_time_ms 与扫描限速参数在"重建越快越好"与"业务别被打垮"之间权衡。
六、ARC:它不是 LRU
ARC(Adaptive Replacement Cache)是 ZFS 最被低估的设计。它不是 LRU,而是同时维护 MRU 与 MFU 两条链表,并为每条链表配一条 ghost(幽灵)链表:
MRU ← 最近只用了一次的块 MRU ghost ← 被 MRU 淘汰的块的"元数据"
MFU ← 被访问过多次的块 MFU ghost ← 被 MFU 淘汰的块的"元数据"
当 MFU ghost 命中时(一个曾经很热、被挤出去、现在又被访问的块),说明缓存太小,ARC 会扩大 MFU 并收缩 MRU。这个反馈回路让 ARC 对"扫描型负载"免疫:全表扫描会流过 MRU,但不会污染 MFU 中的热数据——这正是 LRU 会被一次 SELECT * 冲垮的原因。
# 真实命中率与容量分配
arc_summary | head -40
cat /proc/spl/kstat/zfs/arcstats | grep -E "hits|misses|size|c_max|arc_meta"
生产建议:
- 不要把 ARC 当"可以通过压缩白嫖的内存"。开启
compression=lz4后,ARC 缓存的是解压后的数据,压缩节省的是磁盘 I/O 与容量,不是内存。 - 元数据缓存有独立上限
zfs_arc_meta_limit/arc_meta_min。目录巨大的小文件场景(如镜像仓库、node_modules),元数据压力会先于数据到来,需要显式调大。 - L2ARC 用 SSD 做二级缓存时,务必开启 persistent L2ARC(重启后不失效),否则每次重启都要经历漫长的预热期;同时注意 L2ARC 本身要占用 ARC 内存存放头部索引,内存紧张时反而有害。
七、压缩、去重与 BRT
- 压缩:默认永远开
lz4。它的"提前放弃"逻辑会在数据不可压缩时直接放弃,几乎零成本;zstd在归档类数据上压缩率更好,但 CPU 开销更高,可按数据集分别设置。 - 去重(dedup):生产环境默认不开。它依赖 DDT(Deduplication Table),而 DDT 本身必须常驻内存——经验值是每 TB 去重数据需要数 GB 内存,DDT 一旦被挤出 ARC,每次写都要一次额外的随机读,性能会断崖式下跌。压缩已经能拿到 dedup 的大部分收益,且没有这个风险。
- BRT(Block Reference Tree,OpenZFS 2.2+):这是值得关注的新特性,它提供了 O(1) 的块级克隆(
zfs clone与cp --reflink)。对虚拟机镜像、容器镜像层这类"大量相似大文件"的场景,BRT 能拿到接近 dedup 的容量收益,而不付出 DDT 的内存代价。
八、生产调优清单
| 场景 | 关键参数 | 建议值 |
|---|---|---|
| PostgreSQL / MySQL | recordsize | 16K(与页大小对齐) |
| 数据库 redo / WAL | logbias | latency + 镜像 SLOG |
| 大文件顺序归档 | recordsize | 1M,logbias=throughput |
| 高频覆盖写 | zfs_dirty_data_max | 2~4GB(抑制写停顿毛刺) |
| 元数据密集(海量小文件) | arc_meta_min / zfs_arc_meta_limit | 显式调大,避免"special" vdev 缺失 |
| 通用池 | compression、atime、xattr | lz4、off、sa |
| 扫描优先级 | zfs_scan_idle、zfs_resilver_delay | 按业务 SLA 调整,避免 scrub 打垮业务 |
另外两条硬规则:atime=off 必须开(否则每次读都触发一次元数据写),xattr=sa 应该开(把扩展属性存进 dnode 的 System Attribute 区,避免每个文件额外一次随机读)。
九、结论:ZFS 的工程遗产
把 ZFS 的设计提炼成可迁移的原则,其实只有三条:
- 用不可变块 + 引用计数换取一致性:快照、克隆、回滚都不是"功能",而是 COW 的自然推论。今天的 Iceberg、Paimon、Delta Lake 在表格式层做的,本质上就是把这套思路搬到了对象存储上。
- 用事务组把随机写聚合成顺序写:TXG 的思想在 RocksDB 的 group commit、PostgreSQL 的 WAL 组提交里反复出现——代价是可控的延迟,收益是可预测的吞吐。
- 用自适应结构对抗负载突变:ARC 的 ghost 链表、metaslab 的多级分配器,都在表达同一个观点——存储系统面对的负载永远是混合的,任何固定策略(纯 LRU、纯 MRU)都会在某种负载下崩溃。
真正理解 ZFS,不是为了背下 zfs set 的参数表,而是理解一件事:当"正确性"由校验和与事务保证之后,所有的工程精力都可以投入到"如何在不可预测的负载下保持可预测的性能"这一件事上。这套思路,在今天的任何存储引擎里依然一字未改。

发表评论 取消回复