一、层级归属:ZFS 属于宿主内核层,粒度是 dataset
- 快照作用范围是一个 dataset——不是整个系统,也不是单个文件。粒度只到 dataset。
- 容器:宿主 ZFS 直接管得到(同内核、同文件系统)→ 快照对它等于文件级、同 TXG 的原子时点,保真度最高。
- 虚拟系统:只有当虚拟磁盘以文件或 zvol 形式存在时才管得到,管到的是块;对客户机等于「拔电源那一刻」——崩溃一致,不是应用一致。
- 嵌套(客户机内再跑 ZFS / 容器):宿主快照对客户机内部是黑盒,客户机的快照对宿主不可见。纪律:一个数据集只让一层管快照。
- ZFS 不提供跨 dataset 的原子快照。
zfs snapshot -r只是把同一批名字一次建出来,不保证所有 dataset 落在同一个 TXG。要跨 dataset 一致,只能靠上层冻结写入者(fsfreeze / 数据库锁 / 短暂停写)。
二、dataset 边界 = 策略边界 = 回退粒度
快照频率、保留、压缩、是否送备份,全部按 dataset 声明,不做全池一刀切——全池快照等于把最热的数据也拖进代价里。
| dataset | 放什么 | 快照 | 保留 | 属性与备注 |
|---|---|---|---|---|
| config/ | 核心配置、证书、compose / quadlet 文件 | 每 15 分钟 | 4h / 7d / 8w | compression=zstd;小文件多:带宽轻、元数据重 |
| data/<业务>/ | 业务数据(DB、文档、代码仓) | 每 15~60 分钟 | 24h / 30d / 12m | 每业务一个;核心库额外 zfs send 出去 |
| share/ | 导出给虚拟系统的只读面 | 低频或不开 | 可选 | 只读导出;与运行面物理分开 |
| runtime/ | 容器可写层、虚拟磁盘、索引 | 不快照 | — | 回滚语义模糊,且重跑即得 |
| tmp/ cache/ | 临时与缓存 | 不快照 | — | atime=off、compression=off |
| assets/ | iso / 镜像 | 不快照 | — | recordsize=1M |
声明方式用属性 + 策略工具,不写 cron:
zfs set com.sun:auto-snapshot=false dataset/runtime # zfs-auto-snapshot 认这个属性
sanoid.conf:
[dataset/data]
hourly=24 daily=30 monthly=12
autosnap=yes # 打快照:保持高频(便宜,护 RPO)
autoprune=no # 删过期快照:挪到业务低谷窗口单独跑
[dataset/runtime]
autosnap=no设计含义:想能单独回退的东西必须单独成 dataset。不要把「经常要单独回退的小件」和「不该动的大件」混在一个 dataset 里。
三、五种文件操作的 IO 成本
| 操作 | 带宽 | 元数据 | 说明 |
|---|---|---|---|
| 保存新文件 | 写一份 | 轻 | 最省;压缩可减;sync 写会双写(ZIL + 池) |
| 修改旧文件 | 读改写 × 放大 | 中~重 | 成本 ∝ recordsize;随机小写必须调小 recordsize |
| 复制为另一个文件 | 读 + 写全量 | 轻 | OpenZFS 2.2+ 支持块克隆(reflink),同池大文件近零拷贝 |
| 删除文件 | 无 | 中~重(海量小文件) | 空间异步回收;被快照持有的块不释放 |
| 移动 / 改名(同 dataset 内) | 无 | 极轻 | 纯元数据操作,瞬间完成 |
| 移动 / 改名(跨 dataset) | 读 + 写全量 | 重 | rename 返回 EXDEV,退化成复制 + 删除,旧块还占着空间 |
四个放大器:sync 设置(fsync 双写 → 配 SLOG)、recordsize(随机小写的致命参数)、压缩(省池 IO、花 CPU)、快照保留造成的空闲碎片(间接变慢)。
关键推论:真正制造压力的是三类——随机小改写、sync 同步写、海量元数据操作(小文件频繁建删、跨 dataset 拷贝)。数据库正好命中前两类,所以它必须独立成 dataset 并把 recordsize 调到 8K~16K。
四、快照的成本模型(反直觉)
- 打快照本身几乎免费:元数据级操作,不拷数据。副作用是会强制当前 TXG 立即落盘(为拿到一致时点),写高峰时会造成一次延迟抖动。
- 真正的成本是空间租金:≈ 被保护数据的变动率 × 保留时间。数据不动就不收费——这是它与镜像 / 实时复制那类「常态付费」机制的根本区别。
- 精度修正:ZFS 里没有「改写前先拷贝旧块」这一步(COW 本来就永远写新块)。有快照与无快照,写路径成本几乎一致;差别只是旧块不被释放 → 空间持续增长、free space 变碎 → 长期分配效率下降。
- 两种定时副作用才是撞车大头:① prune(删过期快照)= 异步释放一大批块,IO 风暴;②
zfs send增量 = 遍历差异块并读取,与业务读争 IO。频率越高,这两件事越频繁。 - 正解:snapshot 保持高频;prune 与 send 挪到业务低谷窗口单独跑;不要在整点批量打快照(错峰 5~10 分钟)。
五、回退粒度:rollback 是 dataset 级,单文件恢复走 .zfs
| 操作 | 粒度 | 后果 | 用途 |
|---|---|---|---|
| zfs rollback | dataset 级,全有或全无 | 销毁更新的快照;该 dataset 之后产生的所有数据消失 | 只用于整块业务回退(升级搞坏、迁移写坏) |
| .zfs/snapshot 拷贝 | 文件级 | dataset 停在 HEAD,其余文件不动 | 日常修复:误删、误改单个文件 |
zfs list -t snapshot -o name,creation dataset/data
cp -p /dataset/data/.zfs/snapshot/20261002-0300/app/db.conf /dataset/data/app/db.conf
rsync -a /dataset/data/.zfs/snapshot/20261002-0300/app/conf.d/ /dataset/data/app/conf.d/
zfs diff dataset/data@20261002-0300 dataset/data # 找出到底改了哪些文件
zfs clone dataset/data@20261002-0300 dataset/scratch/restore # 取证用:不扰生产,用完销毁- 快照只读 → 从里面取数据天然安全;读的是旧块,不触发数据搬移,很便宜。
snapdir=hidden(默认)时.zfs不出现在ls里但路径可用;设成visible只是方便浏览(有视觉噪音与误删尝试,但删不掉)。- 铁律:数据库不能单文件回退(表空间 / redo / 字典互相引用)→ 只能整集回退 + redo / WAL 重放;或在克隆体里取完整数据目录做恢复后再导回。
六、一致性口径(按层)
- 容器:宿主的原子快照 = 文件级一致(同一 TXG)。
- 数据库:把该 DB 的全部文件放进同一个 dataset(数据 + redo / WAL + 配置)→ 原子快照等价于拔电源,而 PG 的 WAL 重放 / InnoDB 的 redo 恢复本就为拔电源而设计 → 无需额外 FLUSH,快照即合法备份基元。反之,redo 与数据分属两个 dataset 时快照不在同一 TXG,恢复会失败(最易踩)。
- 虚拟系统:宿主裸快照 = 崩溃一致;要应用一致用
virsh snapshot-create-as --quiesce(走 guest agent 做 fsfreeze)。宿主与客户机不要两层都管。 - 跨 dataset 回滚没有原子性 → 统一批次命名 + 回滚前停掉全部写入者 + 按批次整组回滚。回滚本身是元数据操作(秒级)。
七、备份分层:RPO / RTO 与失效模式
| 机制 | RPO | RTO | 常态代价 | 能救什么 |
|---|---|---|---|---|
| 15 分钟快照 | 15 min | 秒级 | ≈零性能 + 空间租金 | 误删、误改、跑错脚本、逻辑坏数据 |
| zfs send 异地 | 1h~1d | 分钟~小时 | 带宽 + 遍历 IO | 池损坏、盘掉、单机丢失 |
| 冷备(离线介质) | 天~周 | 小时~天 | 人工 + 介质 | 全灭、勒索、跨地域 |
- 快照不是备份:同池快照随池一起死。它防的是「手滑」,而手滑概率远高于灾难级 0.01%——用灾难概率衡量快照,会把它的价值算低一个数量级。
- 该不该叠加看失效模式是否重叠,不是看「每个都要付费」。三者失效模式几乎不重叠 → 值得叠加。
- 常态付费的是镜像 vdev、实时异地复制、加密那一档;快照是按需付费(空间租金)。
- 定频率原则:按「能容忍丢多少分钟的工作」定,不按性能余量(余量几乎不吃)。空间估算 = 日改动量 × 保留天数 × 1.2~1.5(碎片余量);只有高改动的 DB dataset 需要认真算。
八、机制目录的处理(.zfs 与备份软件的隐藏目录)
| 目录 | 里面放什么 | 处理策略 |
|---|---|---|
| .zfs/snapshot(ZFS) | 历史数据本体(只读,零额外空间) | 备份 / 同步必须显式排除 |
| _gsdata_(GoodSync) | 状态账本(指纹、同步历史、删除标记) | 同步任务里不能排除;也不能当业务数据备份或交付 |
- 不排除
.zfs的后果很隐蔽:rsync -a/cp -a/ tar 会把整棵快照当普通目录拷走,备份体积与递归规模失控。 - 算空间不要用
du:用zfs list -t snapshot -o name,used dataset/data与zfs list -o name,used,usedbysnapshots,usedbydataset dataset/data。 - 凡「机制目录」,在做备份 / 导出 / 交付清单时必须显式决定排除还是保留,不能靠工具默认行为。
九、与本架构(NixOS + 虚拟化)交界的四个坑
- qcow2 压在 ZFS 上 = 双重 COW:虚拟磁盘改用 zvol(volblocksize 取 16K 与 qcow2 cluster 对齐)或 raw 镜像;
chattr +C是 Btrfs 专属,ZFS 上没有对应物。 - 活跃数据目录不跨虚拟化边界共享:9p 的锁是客户端本地语义(基本一定放行)、virtiofs 可能转发但不保证 → 两个实例会同时写同一份数据(是必然损坏,不是可能损坏)。导出面只读、与运行面物理分开。
- rootless 容器写出的文件在宿主上落高位 UID(subuid 起始 100000+),影响属主一致性、备份与恢复的可读性;用
--userns=keep-id、卷后缀:U,或规范subUidRanges。 - 挂载依赖必须显式声明:systemd 单元要写
unitConfig.RequiresMountsFor = "/dataset/...",否则挂载点未就绪时会先在根文件系统上建目录并写数据(事后被挂载盖住,看起来像「数据丢了」)。