每天几十 G 的站,怎么用「看起来存了 30 份」却只占一份的空间
个人站长做备份最常见的两种极端:一种是每天全量 tar 一份,一个月下来备份盘先满了;另一种是用增量备份(比如只存变化文件),空间是省了,可一旦要恢复某个具体日期,得从最近的全量一路往回重放,恢复过程既慢又容易出错。
其实还有第三条路,很多人没用过:rsync 的 --link-dest 硬链接快照。它能把每天的备份目录都做得「看起来是完整的一份」,用起来像全量——打开任何一天的目录,看到的都是那一天完整的文件树;但磁盘上只有真正变化的文件占新空间,没变的文件全部是硬链接,指向同一份数据。一份 20G 的站点,存 30 天的快照,可能只占 21~25G。
本文把它的原理、完整脚本、以及五个最容易踩的坑讲清楚,最后附恢复演练。
硬链接快照的原理:为什么 30 份只占一份空间
理解 --link-dest 之前,先建立三个概念:
- inode 与文件名是分离的。一个文件在磁盘上是一个 inode(存放数据块指针和元信息),文件名只是指向 inode 的一个目录项。多个文件名可以指向同一个 inode,这就是硬链接。
- 硬链接不复制数据。创建硬链接只是新增一个目录项,不改动 inode 里的数据块指针,所以几乎不占空间(只占目录项的几十字节)。
- 删除一个硬链接不等于删除数据。inode 里有个「链接计数」,只有当计数归零,数据块才会被真正回收。这是快照能成立的关键:删掉某一天的快照目录,其余天的文件通过其他链接依然完好。
rsync 的 --link-dest=DIR 语义是:当目标目录里需要写入一个文件时,如果它与 DIR 里的同名文件完全一致(内容、权限、时间戳等元数据都相同),就不要复制,直接创建一个指向上一次的硬链接;只有不一致的文件才真正复制。于是每天的备份目录都是一份「完整视图」,而新增的物理数据只有当天真正变动的部分。
完整可用的备份脚本
下面这个脚本经过实测,可以直接改成你的路径使用。核心是「按日期建目录 + 引用昨天作为 link-dest」:
#!/bin/bash
# /usr/local/bin/site-snapshot.sh
# rsync 硬链接快照备份:每天一份完整视图,只有变化部分占空间
set -euo pipefail
SRC="/var/www/html/" # 源目录(结尾必须有斜杠!)
DEST_ROOT="/backup/snapshots" # 快照根目录
KEEP=30 # 保留天数
EXCLUDE="/etc/rsync-exclude.txt" # 排除规则文件
# 日期标签用「本地时间 + 可读格式」,排序即时间顺序
TODAY=$(date +%Y-%m-%d_%H%M)
LINK_DEST="$DEST_ROOT/current" # 指向最新一份快照(用软链接)
NEW="$DEST_ROOT/$TODAY"
mkdir -p "$DEST_ROOT" "$NEW"
# 判断最新的快照目录:优先用 current 软链接,否则取目录名排序最大者
if [ -L "$LINK_DEST" ]; then
PREV=$(readlink -f "$LINK_DEST")
else
PREV=$(ls -1d "$DEST_ROOT"/*/ 2>/dev/null | sort | tail -1 || true)
PREV="${PREV%/}"
fi
# 核心 rsync 命令
RSYNC_ARGS=(
-aHAX # 归档、保hardlink、保xattr、保ACL
--delete # 删除目标里「源已不存在」的文件
--numeric-ids # 按数字 UID/GID 同步,避免用户名解析差异
--exclude-from="$EXCLUDE"
-v
)
if [ -n "${PREV:-}" ] && [ -d "$PREV" ] && [ "$PREV" != "$NEW" ]; then
RSYNC_ARGS+=( --link-dest="$PREV" )
echo "link-dest = $PREV"
else
echo "首次备份,无 link-dest"
fi
rsync "${RSYNC_ARGS[@]}" "$SRC" "$NEW/" | tail -5
# 原子更新 current 软链接(ln -sfn 保证不断链)
ln -sfn "$NEW" "$LINK_DEST"
# 清理超过 KEEP 天的旧快照
find "$DEST_ROOT" -maxdepth 1 -type d -name '????-??-??_*' \
-mtime +$KEEP -print -exec rm -rf {} +
echo "备份完成: $NEW"
du -sh --exclude='*' "$DEST_ROOT"/*/ 2>/dev/null | tail -5配合两条 cron:
# 每天凌晨 3 点做快照
0 3 * * * /usr/local/bin/site-snapshot.sh >> /var/log/site-snapshot.log 2>&1
# 每天 9 点检查备份是否成功(见后文验证一节)
0 9 * * * /usr/local/bin/verify-snapshot.sh坑一:源路径结尾的斜杠,写错就备份出一层多余的目录
这是 rsync 头号经典坑,也是备份脚本里最该背下来的一条:
rsync -a /var/www/html /backup/ # 结果:/backup/html/... (连目录本身一起拷)
rsync -a /var/www/html/ /backup/ # 结果:/backup/... (只拷目录内容)带斜杠 = 只同步目录内容;不带斜杠 = 把目录本身也同步进去。快照脚本里 SRC 必须是 /var/www/html/(带斜杠),否则每天多套一层 html/,而且第一天的错误路径会被后面的 --link-dest 逻辑一直继承,越走越乱。
坑二:--delete 与 --link-dest 的配合是有前提的
--delete 会在目标里删掉「源已经不存在」的文件。配合 --link-dest 使用时有个重要前提:它删除的是目标目录里的目录项,而不是硬链接指向的数据。所以如果某个文件在今天的快照里被删了,昨天的快照仍然能通过自己的链接访问到它——这正是快照的意义。
但反过来说,如果你误把 SRC 指向了错误的(空的)目录,--delete 会兴高采烈地把今天快照目录里的所有文件删掉。虽然昨天的快照还在,但今天就废了。防护办法有两条:
- 同步前先做一次源目录存在性和非空检查:
if [ ! -d "$SRC" ]; then echo "源目录不存在,中止"; exit 1 fi if [ -z "$(ls -A "$SRC")" ]; then echo "源目录为空,中止(防止 --delete 误删快照)"; exit 1 fi - 尤其别用
--delete配自动挂载的目录。如果 NFS/外接盘没挂上,源是空目录,一次同步就把快照清干净了。用mountpoint -q检查挂载点是更稳妥的做法。
坑三:--link-dest 认「元数据」不认内容,时间戳被改动就白搭
rsync 判断「文件是否相同、能否硬链接」时,看的是大小 + mtime(在 -t/-a 下),不是内容哈希(除非加 --checksum)。这带来两个后果:
- 好的一面:判断极快,不用读文件内容,几十 G 的目录几秒就扫完。
- 坏的一面:如果某个文件的内容变了但 mtime 没变(某些程序部署时会
touch -r保留时间戳),rsync 会认为它没变,直接硬链接旧文件——你能拿到一份错误的备份。
反之,如果内容没变但 mtime 变了(比如每天被 touch 了一下,或者从别处拷来时时间戳变了),rsync 会当作新文件完整复制,快照空间暴涨。排查这种「空间为什么没省下来」的问题,就去看是不是有一批文件的时间戳天天在变。
要彻底按内容判断,加 --checksum(-c),但代价是要读所有文件算哈希,大站会显著变慢。折中做法:日常不加,每周跑一次带 --checksum 的审计备份核对。
坑四:为了「节省空间」把 -H 去掉,等于毁掉硬链接
rsync 参数里 -a 包含 -rlptgoD,但不包含 -H(--hard-links)。这两个是不同的东西,很多人搞混:
--link-dest:控制「新快照与旧快照之间」是否复用文件(我们主动要的)。-H:控制「源目录内部本身就存在的硬链接」在目标里是否也保持硬链接关系。
举个真实场景:WordPress 上传目录里,某些图片被引用了多个硬链接;或者你用硬链接做过发布(releases/current)。不加 -H,rsync 会把每个硬链接都当成独立文件完整复制一份,备份体积成倍膨胀,而且恢复后硬链接关系丢失,可能改变程序行为。所以脚本里 -aHAX 的 H 不能省。
同理 -A(ACL)和 -X(xattr)也很重要:SELinux 上下文、某些 apparmor 标签、或者 chattr +i 属性都在 xattr 里,丢了可能导致恢复后服务起不来。
坑五:用 date 生成目录名,但排序逻辑崩了
快照目录名如果格式不统一(比如有的是 2026-9-5、有的是 2026-09-05),靠 ls | sort | tail -1 找「最新一份」就会拿错目录。一旦拿错,--link-dest 指向了一个很旧的快照,备份会退化成大量真实复制。
三条防护:
- 目录名固定用零填充 + 字典序即时间序的格式:
date +%Y-%m-%d_%H%M,永远是2026-09-27_0300这种形状。 - 不用猜最新目录,而是维护一个
current软链接(上面脚本里的做法),每次成功后ln -sfn原子切换。这样「最新一份」是明确记录的,不依赖排序。 - 目录名里加了时分(
_%H%M)之后,清理规则和find -name的通配模式(????-??-??_*)要同步改,否则清理会失效,空间越堆越多。
验证:怎么确认快照真的省了空间、也真的能用
做完备份,别只看脚本没报错就完事。三个检查缺一不可:
# 1) 物理占用 vs 表面大小:两者差距越大,说明硬链接复用得越好
du -sh --apparent-size /backup/snapshots/2026-09-27_0300 # 表面大小(像全量)
du -sh /backup/snapshots/2026-09-27_0300 # 物理占用(实际)
# 用 du 分别统计整个目录树的总物理占用
du -sh /backup/snapshots/
# 2) 确认硬链接计数:同一 inode 应该被多个快照引用,Link count > 1
stat /backup/snapshots/2026-09-27_0300/index.php | grep -i link
# 3) 确认备份完整性:与源对比,应该没有差异输出
rsync -aHAXn --delete /var/www/html/ /backup/snapshots/current/ | head -20
# -n 是 dry-run,只报告不做任何改动stat 那行的 Links: N 是最直接的证据:如果 N 等于快照天数,说明这个文件被 N 份快照共享,只占一份空间;如果 N 恒等于 1,说明硬链接没生效,去检查 --link-dest 的参数和目录名排序。
恢复演练:不演练的备份等于没有备份
快照最大的价值是恢复快。恢复某个日期的某个文件:
# 恢复单个文件
cp -a /backup/snapshots/2026-09-20_0300/wp-config.php /var/www/html/
# 整体回滚到某一天(先备份现状!)
mv /var/www/html /var/www/html.broken
rsync -aHAX --delete /backup/snapshots/2026-09-20_0300/ /var/www/html/两个必须注意的点:
- 从快照里拷文件时用
cp -a,不要直接mv硬链接过去。直接 mv 会把那个 inode 从快照里摘走,导致那一天的快照缺文件。要么cp -a(复制一份独立数据),要么明确知道自己在做什么。 - 整体回滚前先保存现场。
mv /var/www/html /var/www/html.broken这一步不是多余的,回滚本身也可能出错,留着现场才能二次排查。
演练的频率建议是每季度一次,而且必须真的从快照恢复到一个测试目录、跑起来验证,而不是「看一眼文件在不在」。备份的有效性只有恢复过才算数。
小结
--link-dest 硬链接快照的价值在于:它同时给了你全量的可用性和增量的成本。用起来像每天一份完整备份,查看、恢复都不用重放链条;占用的空间却只有真正变化的部分。
要把这套东西做稳,记住五条:源路径结尾的斜杠别写错、--delete 前先做源目录非空校验、理解 rsync 靠 mtime 判断导致的两个陷阱、-H/-A/-X 一个都不能省、以及用 current 软链接而不是目录排序来定位最新快照。最后——定期真的恢复一次,否则你拥有的只是「一堆看起来像备份的文件」。