单机用Btrfs:子卷、快照与清理

为什么要在单机上分一块 Btrfs

提到 Btrfs,很多站长的第一反应是「那不是给 NAS 和多盘阵列用的吗」。实际上,在单台 VPS 上,Btrfs 最有价值的三个特性——快照、子卷、在线校验——恰好解决的正是个人站长最常见的三个痛点:升级前没备份、日志和站点数据混在一个目录、磁盘悄悄出错却毫无察觉。

ext4 当然也能用,配 LVM 快照也能实现类似效果,但 LVM 快照是全卷的,容量预估麻烦,回滚之后整个卷的所有文件都回去了;Btrfs 的快照是按子卷的,可以只回滚网站目录而不动数据库目录,粒度细一档。

这篇讲的是单机场景下 Btrfs 的实用部分:子卷怎么划分、快照怎么做和怎么自动清理、压缩值不值得开、以及那些踩上去才知道的坑。

先认清代价:写放大、碎片与内核版本

在讲怎么用之前,必须先讲清楚它不适合什么。Btrfs 是写时复制文件系统,任何一次修改都不是原地覆盖,而是写到新位置然后改指针。这个机制带来快照的廉价,也带来了两个直接后果。

第一个是碎片。数据库这种频繁改写同一批文件的工作负载,在 Btrfs 上跑久了会产生大量碎片,尤其是 InnoDB 的 ibdata 和 redo log,文件会碎成几百上千个 extent,随机读写性能肉眼可见地下降。所以数据库文件不该放在 Btrfs 上,或者至少要关掉数据库文件的 CoW。

第二个是写放大。同样的写入量,Btrfs 落到盘上的实际写入可能是 ext4 的一点几倍到两倍,SSD 寿命和 IOPS 都会受影响。低配 VPS 上如果 IOPS 本来就很紧张,这个差距是能感觉到的。

还有一条容易被忽略:内核版本。Btrfs 在 5.4 之前还存在若干已知问题,5.15 之后才算是真正稳定。Debian 11 自带 5.10,Debian 12 是 6.1,CentOS 7 的 3.10 上跑 Btrfs 属于自找麻烦。上线前先用 uname -r 看一眼。

子卷怎么划:按「回滚粒度」而不是按目录结构

子卷是 Btrfs 里最核心的概念,它看起来像目录,但可以独立快照、独立设配额、独立挂载选项。初学者常见的错误是按网站的目录结构一一对应地建子卷,最后建出十几个,快照脚本根本管不过来。

合理的划法是按回滚粒度来:哪些数据你会想「整体回到昨天」,就把它们放在一个子卷里。

一个个人站点的推荐布局:

# 假设整块盘挂在 /mnt/data,先在根上建子卷
mount /dev/vdb /mnt
btrfs subvolume create /mnt/@
btrfs subvolume create /mnt/@home
btrfs subvolume create /mnt/@www
btrfs subvolume create /mnt/@db
btrfs subvolume create /mnt/@logs

各自的挂载选项不同,这是关键:

# /etc/fstab 片段
UUID=xxxx /       btrfs defaults,noatime,compress=zstd:1,subvol=@      0 0
UUID=xxxx /home   btrfs defaults,noatime,compress=zstd:1,subvol=@home  0 0
UUID=xxxx /www    btrfs defaults,noatime,compress=zstd:1,subvol=@www   0 0
UUID=xxxx /var/lib/mysql btrfs defaults,noatime,nodatacow,subvol=@db   0 0
UUID=xxxx /var/log btrfs defaults,noatime,compress=zstd:1,subvol=@logs  0 0

四个决定:noatime 必须加,否则每次读文件都写一次 atime,Btrfs 上这个开销比 ext4 明显;/var/lib/mysql 用 nodatacow,数据库文件不需要 CoW 也不需要快照(数据库有自己的备份机制),关掉能避免碎片和写放大;日志开压缩,文本压缩比通常在 4 到 8 倍,很划算;网站目录开压缩,HTML/CSS/JS 都是文本,图片和视频已经被压缩过,Btrfs 会自己识别并跳过,不用手动排除。

注意 nodatacow 的子卷也不支持压缩,这两个选项互斥,写在一起挂载会失败或者只有前者生效。

快照:廉价不等于免费,要算容量

Btrfs 快照是 O(1) 的,创建瞬间完成,不管目录多大。这正是它比 rsync 备份好用的地方——你可以放心地在每次部署前打一个快照,成本几乎为零。

btrfs subvolume snapshot -r /www /snapshots/www-$(date +%Y%m%d%H%M)
btrfs subvolume list / | tail -5

加 -r 创建只读快照,这是个好习惯:只读快照不会被误写,也不会在删除时出问题。

但「创建廉价」不代表「存储免费」。快照和原卷共享未修改的数据块,一旦原卷上的文件被改写,老数据块会被快照「钉住」,两份都要占空间。所以你保留 30 个快照,而站点目录每天变动的部分有 200M,那实际占用就是 6G,不是零。

用 df 看不出来这个问题,因为 Btrfs 的 df 报的是整卷可用空间,快照占用的部分不会单独列出。要看真实情况用:

btrfs filesystem du -s /snapshots/* | sort -k1 -h
btrfs filesystem usage /

第一个命令列出每个快照的独占大小(exclusive),这是判断哪个快照占空间的关键。第二个命令看整卷的分配情况,注意 Data 行的 unallocated 还剩多少。

自动清理:不清理的快照会吃满磁盘

Btrfs 最典型的事故是「磁盘满了但找不到大文件」,因为占用全在快照里。所以快照必须有保留策略,而且要自动执行。

最简单可用的做法是保留最近 N 个,加一份每天/每周的:

#!/bin/bash
# /usr/local/bin/btrfs-snap.sh
set -euo pipefail
SUB=$1
NAME=$(echo "$SUB" | tr '/' '_')
DEST=/snapshots/$NAME-$(date +%Y%m%d%H%M)
btrfs subvolume snapshot -r "$SUB" "$DEST"

# 保留最近 12 个,更早的删掉
btrfs subvolume list -o "$(dirname "$SUB")" \
  | awk '{print $NF}' | grep "^snapshots/${NAME}-" | sort | head -n -12 \
  | while read -r old; do
      btrfs subvolume delete "/$old"
    done

# 同步一下空闲空间统计
btrfs filesystem sync /
df -h / | tail -1

这个脚本简化了路径处理,实际用的时候建议按子卷的真实挂载点调整。挂到 crontab,每小时跑一次网站目录,每天跑一次日志目录。

删除快照本身也有讲究:btrfs subvolume delete 是异步的,命令立刻返回,实际的空间回收在后台进行,磁盘 IO 会有短暂上升。如果一次删几十个快照,最好加个 sleep 或者在低峰期执行,否则可能把磁盘 IO 打满几分钟,前台明显变卡。

另一个坑是快照不能嵌套在会被快照的子卷里。如果你把 /snapshots 建在 /www 子卷内部,那么给 /www 打快照时会把快照目录自己也快照进去,递归爆炸。快照目录必须是独立子卷,挂在根上或者单独一个 /snapshots 子卷。

回滚:换名而不是覆盖

回滚快照的正确方式是「把当前目录挪开,把快照挂上去」,而不是把快照里的文件拷回来。后者又慢又容易漏。

# 假设 /www 本身是个挂载点,回滚前先卸载
systemctl stop nginx
umount /www
mv /www /www.broken-$(date +%s)     # 注意:这是子卷,不是普通目录,要 mv 挂载点
mount -o subvol=@www,snapshot-name ...

实际操作更稳的流程是改 fstab 里的 subvol 指向:先创建一个新的可写子卷,把要把快照里的内容恢复进去,或者直接修改 fstab 让 @www 这个挂载点指向快照的名字,然后 mount -a。改 fstab 的方式在重启后依然生效,不会出现「重启后回滚失效」的情况。

# 查看子卷的完整名称(含路径)
btrfs subvolume list /
# 修改 fstab: subvol=@www -> subvol=snapshots/_www-202610281200
mount -o remount,subvol=snapshots/_www-202610281200 /www

remount 时改 subvol 在当前内核上是支持的,但最保稳的做法是 umount 后重新 mount。回滚完成后别忘了检查目录属主和 SELinux/AppArmor 上下文——快照会连权限位一起还原,但如果是跨了配置变更,应用可能因为新配置不存在而启动失败。

scrub、balance 与健康检查

Btrfs 自带数据校验,每个数据块的 csum 都存在树上,所以能做 scrub——后台读一遍全部数据,发现和校验值不符就报错。这在单盘场景下的意义不是「修复」(单盘没有冗余,读出错只能靠备份),而是提前暴露问题:一块开始出坏道的盘,scrub 能在系统崩溃之前告诉你。

btrfs scrub start -B /      # -B 前台阻塞,适合 cron 里跑
btrfs scrub status /

建议每月跑一次,放在凌晨。注意 scrub 会产生大量读 IO,跑的时候前台会明显变慢,不要放在白天。

balance 是另一回事。很多人从网上抄「btrfs balance start -dusage=50 /」当定期任务跑,这是错的。balance 的作用是重新分布数据块、回收被快照碎片化的空间,代价是海量 IO 和长时间的元数据操作,跑一次可能几个小时。正确用法是在两种情况下手工执行:一是磁盘显示大量 unallocated 但有空闲空间(chunk 分配不合理),二是删除了很多快照之后想回收空间。用 -dusage=5 这样的小比例逐步试,不要一上来 -dusage=50。

有个常见的健康检查命令值得加进月度巡检:

btrfs device stats /
btrfs filesystem usage /
btrfs scrub status /

第一条看读写错误计数,只要不是全零就说明底层在报错;第二条看元数据是否吃紧(Metadata 的 used 接近 total 会导致写失败,即使 Data 还有空间);第三条看上次 scrub 的时间和发现的错误数。

五个我踩过的坑

坑一:df 的已用空间和 du 求和差很多。这不是错觉,是快照和 CoW 的正常现象。判据永远用 btrfs filesystem usage,不要用 df 做容量规划。

坑二:在 Btrfs 上 dd 测试磁盘速度,结果很差。nodatacow 没开、压缩开着、再加上 CoW 的碎片,测出来的数字不能代表真实负载。测速前先把目标目录设成 nodatacow 并关压缩,否则结论会误导你换机器。

坑三:mount 失败但没有任何明显报错。Btrfs 在超级块损坏时会拒绝挂载,日志里可能只有一句「bad tree block」。这时候用 btrfs rescue super-recover 尝试恢复,恢复前务必先把整块盘 dd 镜像一份,任何修复操作都可能加重损坏。

坑四:把快照当作备份。快照和原数据在同一个块设备上,盘坏了、文件系统崩了,快照一起没了。快照解决的是「误删和回滚」,备份解决的是「设备故障」,两者不能互相替代。真正的备份必须异地或者至少另一个设备,用 btrfs send | ssh 到另一台机器是很好的组合。

坑五:开了 compress 之后发现写入变慢。zstd:1 的压缩在有富余 CPU 的机器上是正收益,但在已经跑满的小机器上会拖慢写入。判断标准很简单:iostat 看 CPU 的 sy(系统态)占比,如果开了压缩后 sy 明显上升且影响吞吐,就降到 zlib 或者直接关掉。

它值得上吗:一个决策清单

最后给个判断标准,如果下面几条你都满足,Btrfs 在单机上是很划算的:内核 5.15 以上;磁盘不是极度紧张的高 IOPS 场景;你会写一点脚本做快照清理;数据目录和数据库目录能分开挂载。

如果机器只有 1 核 1G、跑着 MySQL 和应用在同一块盘上、你也不想维护快照脚本,那老老实实用 ext4,把精力放在备份上更实在。文件系统是底座,不是用来炫技的,能稳定跑三年比多一个快照功能重要。

Last modification:October 28th, 2026 at 01:25 pm

Leave a Comment