Btrfs 文件系统快照备份实战:子卷规划、send/receive 增量异地与容量真相

rsync 备份和文件系统快照是两件不同的事

个人站长的备份方案通常是 rsync 定时同步到一个别的目录或者另一台机。这套方案能用,但有几个绕不过去的限制。第一,它只能备份「已经落盘的文件」,如果数据库正在写,你 rsync 到的就是一个撕裂的状态——表 A 是新的、表 B 是旧的,恢复出来不一致。第二,数据量大了之后每次全量比对很慢,增量虽然快但第一次和每次校验都要读一遍全部 inode。第三,也是最重要的:如果你的服务器被入侵,攻击者拿到 root 之后第一件事就是删掉本地备份。同一台机器上的备份等于没有备份。

文件系统级快照解决的是前两个问题。它几乎是瞬时的(不管是 1GB 还是 1TB,都在秒级以内),因为快照不复制数据,只是记录一个时间点的元数据状态。结合 send 和 receive 传送到另一台机器,还能做到增量异地备份。Linux 上能做快照的主流方案有两类:Btrfs 和 ZFS(另一类是 LVM,那个本站在前面已经单独讲过了)。

Btrfs 已经进主线内核,Debian 和 Ubuntu 的安装器都直接支持,是个人站更务实的选择。ZFS 功能更全、企业级特性多,但它是内核模块(Ubuntu 上是 zfs-linux 包),内核升级时需要 DKMS 重新编译,偶发不匹配会导致模块加载失败。下文以 Btrfs 为主线,ZFS 的差异单独说明。

先搞清楚你的盘能不能用 Btrfs

如果你已经装好系统、用的是 ext4,不要想着在线转换。虽然有 btrfs-convert 这样的工具,但它在数据量大、文件多的情况下失败率高,而且转换后文件系统的性能特征会变(CoW 引入的碎片问题),个人站的数据不值得这样冒险。正确的路径是:加一块盘、格式化成 Btrfs,把数据目录(比如 /var/www、/var/lib/mysql 的备份、上传目录)挂上去。

# 查看现有块设备与文件系统
lsblk -f
df -hT

# 格式化(⚠️ 会清空该分区所有数据,先确认设备名)
mkfs.btrfs -L data -f /dev/sdb1

# 挂载
mkdir -p /mnt/btrfs
mount /dev/sdb1 /mnt/btrfs

# 写入 fstab,用 UUID 而不是设备名(设备名可能变)
UUID=$(blkid -s UUID -o value /dev/sdb1)
echo "UUID=$UUID /mnt/btrfs btrfs defaults,noatime,compress=zstd:3 0 0" >> /etc/fstab

挂载参数里 noatime 和 compress=zstd:3 是个人站最值得加的两个。noatime 让系统不再记录文件最后访问时间,避免读操作也触发写;compress=zstd:3 对文本、日志、源码这类内容压缩率通常在 2 到 3 倍,级别 3 是压缩比和 CPU 开销的平衡点,再往上(比如 9)CPU 吃掉的时间超过省下的 IO 时间,对网站这种小文件随机读的场景反而更慢。

一个必须了解的坑:Btrfs 的 df 输出会让人困惑。它的 Size 是所有设备的总和,Used 是实际使用的数据量(不含元数据和冗余),Avail 常常小于 Size - Used。判断是否真的满了,要用 btrfs filesystem usage /mnt/btrfs,它会分别列出 Data、Metadata、System 三类空间的已分配和已使用情况。常见的「明明还剩 50GB 却写不进文件」就是 Metadata 空间耗尽——Btrfs 的元数据在文件数量极多时(比如一个目录下几十万个小文件,缓存目录很常见)会膨胀,而 df 完全看不出来。

子卷规划:让快照有意义的前提

Btrfs 的快照只能对子卷(subvolume)做,不能对整个挂载点做(虽然可以给挂载点顶层建快照,但不推荐)。所以在放数据之前,先规划好子卷结构,这决定了你以后能不能「只恢复数据库,不动网站代码」。

# 在挂载点下创建子卷(注意:顶层本身也是子卷,但一般不直接放数据)
cd /mnt/btrfs
btrfs subvolume create @www        # 网站代码
btrfs subvolume create @uploads    # 用户上传的文件,变化频繁
btrfs subvolume create @db         # 数据库数据目录
btrfs subvolume create @snapshots  # 存放快照的地方

# 查看子卷列表
btrfs subvolume list /mnt/btrfs

为什么要分开?因为 @uploads 每天新增几百 MB 图片,如果你把它和 @www 放一起做快照,那么每次快照都会把新增的图片纳入,快照占用空间快速增长。@www 的代码可能一个月才改一次,它的快照几乎不占空间。分开之后可以给它们配不同的快照保留策略:代码保留 30 天,上传目录保留 7 天,数据库保留 14 天。

关键的一步:快照必须存在不同的子卷里,而且这个子卷不能被包含在快照范围内。如果你把 @snapshots 建在 @www 里面,那给 @www 建快照时会把快照自己也快照一遍,形成递归嵌套,几天内就能把磁盘吃光。所以 @snapshots 必须是挂载点下的独立子卷,与数据子卷平级。

另外,子卷可以用不同的挂载参数单独挂载,这对于数据库特别有用。MySQL 的 InnoDB 在 CoW 文件系统上有一个众所周知的性能问题:它假设文件是原地覆盖写的,而 Btrfs 的 CoW 会在每次写时分配新块,导致数据文件严重碎片化、随机读性能下降。解决办法有两个,一是挂载数据库子卷时加 nodatacow,二是用 chattr +C 在空目录上关闭 CoW(必须在目录为空时设置,之后创建的文件才继承这个属性)。

# 方式一:子卷单独挂载时关闭 CoW
# /etc/fstab 里加一行(注意同一文件系统的子卷可以用 subvol= 分别挂载)
UUID=xxx /mnt/btrfs/db btrfs subvol=@db,nodatacow,noatime 0 0

# 方式二:用 chattr 给目录打属性(目录必须是空的)
mkdir -p /mnt/btrfs/db && chattr +C /mnt/btrfs/db
lsattr -d /mnt/btrfs/db   # 应显示 ---------------C------

nodatacow 的代价是这个子卷失去了校验和(checksum)保护和压缩,也就是放弃了 Btrfs 最核心的可靠性特性。这是个真实的取舍:数据库自己已经有校验机制(InnoDB 有页校验和),Btrfs 的校验对它是冗余的,但性能损失是实打实的。个人站的数据库通常不大,我的建议是先试默认配置,用 fio 实际测一下随机读写,如果慢得不能接受再关 CoW,而不是一开始就无脑关掉。

建快照:只读快照与读写快照

建快照是瞬时操作,即使数据有几百 GB 也是毫秒级:

# 只读快照(-r),用于备份,不能修改,更安全
btrfs subvolume snapshot -r /mnt/btrfs/@db /mnt/btrfs/@snapshots/db-20260929-0300

# 读写快照(不加 -r),可以挂载进去修改,用于测试
btrfs subvolume snapshot /mnt/btrfs/@www /mnt/btrfs/@snapshots/www-test

建快照之前,如果目标是数据库文件,必须先 FLUSH TABLES WITH READ LOCK 或者干脆停服,否则快照里的 InnoDB 文件是崩溃一致状态(crash-consistent)——它对 InnoDB 来说是可恢复的,重启时会走 redo log 重放,通常没问题,但如果你同时快照了数据库目录和二进制日志目录,两者时间点不一致可能导致恢复出来的时间线对不上。稳妥做法是先 flush 再快照:

#!/bin/bash
# 停写的正确姿势:用 FLUSH TABLES WITH READ LOCK 保持锁,快照完再释放
mysql -uroot -p"$PW" -e "FLUSH TABLES WITH READ LOCK; SYSTEM sleep 3;" &
MYSQL_PID=$!
sleep 1
btrfs subvolume snapshot -r /mnt/btrfs/@db /mnt/btrfs/@snapshots/db-$(date +%Y%m%d-%H%M)
wait $MYSQL_PID

这里用了一个技巧:FLUSH TABLES WITH READ LOCK 加 SYSTEM sleep 3 让 MySQL 自己持锁 3 秒,我们在这 3 秒内建快照,然后 MySQL 自动释放锁。比手工在两个 mysql 会话之间传递锁要可靠得多(那种写法依赖长连接,容易出意外)。

用 send/receive 做增量异地备份

这是 Btrfs 最有价值的功能。只读快照之间可以只传差异部分:

# 第一次:全量发送(-z 压缩,输出到 ssh 对端)
btrfs send -z /mnt/btrfs/@snapshots/db-20260928-0300 | \
  ssh user@backup-host "btrfs receive /backup/btrfs/"

# 之后每次:带 -p 指定父快照,只传增量
btrfs send -z -p /mnt/btrfs/@snapshots/db-20260928-0300 \
  /mnt/btrfs/@snapshots/db-20260929-0300 | \
  ssh user@backup-host "btrfs receive /backup/btrfs/"

增量发送的前提是接收端已经有那个父快照。如果接收端的父快照被删了,btrfs receive 会报 ERROR: cannot find parent subvolume 并且失败。所以接收端的快照删除策略必须比发送端保守——发送端保留 14 天,接收端至少保留 21 天,否则发送端删除旧快照后,你再也无法基于它做增量,只能重发全量。

还有两个实际会踩的坑。第一,btrfs send | ssh | btrfs receive 这个管道如果中断(网络抖动、SSH 超时),传输会从头开始,几百 GB 的数据可能传几个小时然后失败。要提升成功率,用 -f 把流写到本地文件再传,或者更实用的做法是用 mbuffer 加缓冲和断点相关的处理,再或者在发送端加 pv 看进度:

btrfs send -z -p "$PARENT" "$NEW" | pv -s 5G | \
  ssh -o ServerAliveInterval=30 user@backup-host "btrfs receive /backup/btrfs/"

-o ServerAliveInterval=30 很重要,它让 SSH 每 30 秒发一个心跳包,防止长时间大数据传输被中间设备(NAT、防火墙)当成空闲连接掐掉。默认的 TCPKeepAlive yes 在应用层不管用,因为 ssh 进程本身没输出数据时 keepalive 也可能不触发。

第二,btrfs receive 收到的是只读快照,而且是父子关系链。你不能只保留最新的那个然后删掉中间所有——因为每个快照都依赖它的父快照共享数据块,删掉中间的会让后续快照占用的空间无法被释放(虽然快照本身还能读,因为 Btrfs 是引用计数)。正确的保留策略是保留一条连续的链,删就从最老的开始删。

容量真相:快照为什么比你想象的占空间

快照刚建时几乎不占空间(共享所有数据块),但之后每次对原文件的修改都会让快照多占一份旧数据的空间。理解这一点对计算需要多少磁盘至关重要。

# 查看每个快照的独占(exclusive)数据量——这才是它真正占的空间
btrfs filesystem du -s /mnt/btrfs/@snapshots/*

# 查看整个文件系统的空间构成
btrfs filesystem usage /mnt/btrfs
btrfs filesystem df /mnt/btrfs

btrfs filesystem du -s 的输出有三个数字:Total(该快照可见的总数据量)、Exclusive(只有它自己引用的数据)、Set shared(和其他快照共享的)。判断一个快照「值不值得留」,要看 Exclusive,而不是 Total。一个 20GB 的数据库快照,如果 Exclusive 只有 300MB,说明它只保存了这 300MB 的变化,非常划算;如果 Exclusive 是 18GB,说明这期间几乎整个数据库都被重写了,那这个快照的成本就很高。

极端情况:如果你有一个 30 天的保留窗口,期间数据库被全量重写了 30 次(比如每天执行一次 OPTIMIZE TABLE 或者导数据重建),那么每个快照的 Exclusive 都会接近数据库全量大小,总占用接近 30 倍数据库大小。这就是为什么数据库快照不能盲目设长保留期——要先用 du -s 实测几天,看清每份快照的真实成本,再决定保留多少个。

写一个自动清理脚本,按保留数量和总空间双重条件删除:

#!/bin/bash
# /usr/local/bin/btrfs-prune.sh
SNAP_DIR=/mnt/btrfs/@snapshots
KEEP=14          # 保留最近 14 份
MAX_MB=20000     # 快照目录最多占 20GB

cd "$SNAP_DIR" || exit 1
# 1. 按数量删除最老的(文件名带日期,字典序即时间序)
ls -1 db-* | sort | head -n -"$KEEP" | while read -r s; do
    echo "delete snapshot: $s"
    btrfs subvolume delete "$SNAP_DIR/$s"
done

# 2. 按总空间删除(从最老开始,直到低于阈值)
total=$(btrfs filesystem du -s "$SNAP_DIR" | awk '/Total/{print int($1/1024/1024)}')
ls -1 db-* | sort | while read -r s; do
    [ "$total" -le "$MAX_MB" ] && break
    btrfs subvolume delete "$SNAP_DIR/$s"
    total=$(btrfs filesystem du -s "$SNAP_DIR" | awk '/Total/{print int($1/1024/1024)}')
done

btrfs subvolume delete 是异步的(它会 fork 一个清理线程),命令立刻返回但空间释放可能滞后几分钟到几小时。如果你想确认空间真的回来了,等一会儿再跑 btrfs filesystem usage。看到 btrfs balance 相关的建议不要盲目执行——balance 会重写整个文件系统的数据块布局,对接近满的盘执行可能因为需要临时空间而失败,甚至把盘彻底塞满。只在确实有大量未使用空间碎片、且你确认盘还有 15% 以上剩余时才做,并且分批做(btrfs balance start -dusage=50 只处理使用率低于 50% 的块组)。

恢复演练:三种粒度的还原方式

快照建了不演练等于没有。三种恢复方式各有用途。

# 方式一:从快照里捞单个文件(最快最常用)
# 只读快照可以直接挂载或直接 cd 进去读
ls /mnt/btrfs/@snapshots/db-20260929-0300/
cp /mnt/btrfs/@snapshots/db-20260929-0300/ibdata1 /tmp/restore/

# 方式二:整体回滚——把子卷替换成快照内容
# 先把现有子卷改名(不要删,留着万一回滚错了)
mv /mnt/btrfs/@www /mnt/btrfs/@www-old
# 从快照建一个读写快照作为新的活动子卷
btrfs subvolume snapshot /mnt/btrfs/@snapshots/www-20260929-0300 /mnt/btrfs/@www
# 确认服务正常后再删旧的
# btrfs subvolume delete /mnt/btrfs/@www-old

# 方式三:异地恢复——把备份机上的快照发回来
ssh user@backup-host "btrfs send /backup/btrfs/db-20260929-0300" | \
  btrfs receive /mnt/btrfs/@snapshots/

方式一最安全,几乎不参与风险,适合「改错了一个文件」「误删了上传目录里的某张图」这类常见事故。方式二要注意:如果有服务正在使用这个子卷(挂载着、有进程 open 着文件),改名和替换会失败或者造成进程异常,必须先停掉 Nginx、PHP-FPM、MySQL,操作完再启动。前文提到的 /mnt/btrfs/db 如果是单独挂载的,替换子卷后还要 umount 再 mount 一次,或者直接 mount -o remount。

演练的频率建议:方式一每季度做一次(五分钟的事),方式二每半年在测试环境做一次。真正出事时你会发现,恢复流程里最耗时的从来不是技术操作,而是「我到底该恢复到哪个时间点」这个决策,以及「服务停多久能接受」的沟通。这两件事在演练中会自然暴露出来。

ZFS 的差异与选用建议

如果选 ZFS,主要差异在这几点。ZFS 的快照用 zfs snapshot pool/dataset@name 语法,没有只读/读写之分,快照天生只读。zfs send 增量用 -i 指定起始快照(而 Btrfs 的 -p 是逐级父快照),zfs send -i @old @new 直接传两个快照之间的差异,粒度更灵活。ZFS 自带 zfs-auto-snapshot 或 sanoid 这样的自动化工具,保留策略配置比手写脚本舒服。ZFS 的配额(quota、refquota)和压缩(compression=lz4,默认就开且性能极好)也更成熟。

ZFS 的代价是需要 ECC 内存才能发挥完整可靠性(它没有 Btrfs 的 scrub 之外的额外防护,但内存位翻转会污染校验和,业内普遍建议 ECC),而个人站的 VPS 基本都是非 ECC。另外 ZFS 不能缩容——加盘可以(zpool add),但减盘非常麻烦。Btrfs 的灵活性(可以任意增删设备、在线转换 RAID 级别)对个人站更友好。

我的建议:已经用 ext4 在跑、不想动系统的,加一块盘用 Btrfs,把它当「带快照能力的备份目标」而不是系统盘,风险最低收益明确。愿意重装、追求极致可靠性的,ZFS 的自动化工具链更省心。两者的共同纪律是一样的:快照 + 异地 send/receive 才算备份,只有快照不算——因为快照和原数据在同一块盘上,盘坏了两个一起没。

Last modification:September 29th, 2026 at 08:27 pm

Leave a Comment