备份与 rsync 同步:增量、快照与恢复演练

讲解 rsync 的增量同步与尾斜杠语义、排除规则与删除策略、硬链接快照与轮转、校验与完整性检查、异地加密同步与保留策略,以及恢复演练的完整流程。

1. 备份的基本要素

一句话总结: 备份方案要先回答三个问题——备份什么、能容忍丢多少、多久能恢复;没有恢复演练的备份,只是「一份尚未验证的副本」。

1.1 三个必须回答的问题

问题对应指标影响的设计
能丢多少数据RPO(恢复点目标)同步频率、是否增量
多久必须恢复RTO(恢复时间目标)全量还是快照、是否本地保留
备份到哪里容灾等级是否异地、是否加密
#!/usr/bin/env bash
set -euo pipefail

# 备份前的体检:先确认源存在,避免备份出空目录
SRC=${SRC:-/var/lib/app}
[ -d "$SRC" ] || { echo "source missing: $SRC" >&2; exit 1; }

# 记录源的大小与文件数,作为事后比对的基线
du -sh "$SRC"; find "$SRC" -type f | wc -l

「备份了一个空目录却报告成功」是备份事故的第一名:挂载点没挂上、路径写错、权限不足,都会让 rsync 同步了 0 个文件并返回 0。

一句话总结: 备份脚本必须显式校验「源非空」,否则挂载失败会被静默当成「成功备份了空数据」。

1.2 3-2-1 原则

3 份副本、2 种介质、1 份异地。落到脚本上就是三条流水线:/backup/local/ 本地快照(快速恢复)、/backup/offsite/ 异地同步(容灾)、/backup/archive/ 归档(合规与长期保留)。

2. rsync 增量同步

一句话总结: rsync 的核心是「只传差异」:它先在两端比较文件列表,再用滚动校验算法只传输变化的部分,所以首次全量、之后增量。

2.1 尾斜杠语义

这是 rsync 最经典的坑:源路径末尾有没有斜杠,含义完全不同。

# 有斜杠:把 src 的内容复制到 dst  → 结果 /backup/dst/file1
rsync -a /data/src/ /backup/dst/

# 无斜杠:把 src 目录本身放进 dst  → 结果 /backup/dst/src/file1
rsync -a /data/src /backup/dst/
# 用 --dry-run 先确认行为,这是最省事的自查手段
rsync -av --dry-run /data/src/ /backup/dst/

记住口诀:源加斜杠是「拷贝里面的东西」,源不加斜杠是「拷贝这个目录」;目标端有没有斜杠不影响结果。

一句话总结: 源路径尾斜杠决定「拷内容还是拷目录」,养成先 --dry-run 再执行的习惯。

2.2 常用选项组合

# 最常用的备份组合
rsync -aHAX --numeric-ids --partial --info=progress2 /data/src/ /backup/dst/
选项作用
-a归档模式,等于 -rlptgoD
-H保留硬链接关系
-A保留 ACL
-X保留扩展属性(xattr)
--numeric-ids按 uid/gid 数字同步,不做名字映射
--partial中断后保留半截文件,下次续传
--info=progress2显示整体进度而非逐文件刷屏
# 跨主机时走 SSH,并指定密钥与端口
rsync -aHAX -e 'ssh -p 2222 -i ~/.ssh/backup_key' \
  --numeric-ids /data/src/ backup@host:/backup/dst/

# 慢链路加压缩(CPU 换带宽,本机同步不要加 -z),并限速
rsync -az --bwlimit=5000 /data/src/ remote:/backup/dst/

-z 只在网络传输时才有意义;本机或高速内网同步加 -z 反而浪费 CPU。--bwlimit 的单位是 KB/s。

3. 排除规则与删除语义

一句话总结: 排除规则用「模式」描述不要同步的东西,删除语义用 --delete 家族描述目标端多出来的文件怎么处理;两者都要先用 --dry-run 验证。

3.1 排除与包含

rsync -a --exclude='*.tmp' --exclude='.git/' --exclude='cache/' \
  --exclude-from=/etc/backup/exclude.txt /data/src/ /backup/dst/
# /etc/backup/exclude.txt
*.log
*.pid
node_modules/
.DS_Store
# 以 / 开头表示锚定到传输根,避免误伤同名子目录
/tmp/

顺序很重要:rsync 按先匹配先生效处理规则,所以「先排除整个目录、再用 + 包含其中某个文件」是无效的,必须把 + 写在前面。

# 反面教材:先排除 cache/,再想包含 cache/keep.txt 是无效的
rsync -a --exclude='cache/' --include='cache/keep.txt' src/ dst/
# 正确顺序:先包含,再排除
rsync -a --include='cache/keep.txt' --exclude='cache/' src/ dst/

一句话总结: 规则按顺序匹配、先命中先生效,所以「例外」必须写在「通配排除」之前。

3.2 删除语义

# 目标端多出来的文件也删掉(做镜像同步)
rsync -a --delete /data/src/ /backup/dst/

# 连被排除的文件也从目标端删除(危险:可能删掉本地保留的东西)
rsync -a --delete --delete-excluded /data/src/ /backup/dst/

# 把删除延后到传输结束,减少目标端「短暂缺文件」的窗口
rsync -a --delete-delay /data/src/ /backup/dst/

# 保险丝:一次最多删这么多文件,超过就中止
rsync -a --delete --max-delete=100 /data/src/ /backup/dst/
# 先看会删什么,确认无误再去掉 --dry-run
rsync -av --delete --dry-run /data/src/ /backup/dst/ \
  | grep -E '^deleting'

--delete 只作用于传输根之下,不会删到目标端的父目录。但把源路径写错(比如源指向空目录)时,--delete 会把目标端清空——--max-delete 就是为这种情况准备的保险丝。

4. 硬链接快照

一句话总结: --link-dest 让「本次未变化的文件」以硬链接指向上一次快照,于是每份快照看起来都是完整的全量,实际只占用增量空间。

#!/usr/bin/env bash
set -euo pipefail

BASE=/backup/snapshots
SRC=/data/src
TODAY=$(date +%Y-%m-%d)
LATEST=$(readlink -f "$BASE/latest" 2>/dev/null || true)
mkdir -p "$BASE/$TODAY"

# 与上一份快照比对:没变的文件直接建硬链接,不占额外空间
if [ -n "$LATEST" ] && [ -d "$LATEST" ]; then
  rsync -aHAX --delete --link-dest="$LATEST" "$SRC/" "$BASE/$TODAY/"
else
  rsync -aHAX --delete "$SRC/" "$BASE/$TODAY/"     # 首次全量
fi

ln -sfn "$BASE/$TODAY" "$BASE/latest"              # 指向最新快照
du -sh --exclude=latest "$BASE"/* | sort -h        # 查看每份快照的独有数据量

关键约束:--link-dest 的路径必须是目标端的路径(相对目标根或绝对路径都行),且只有在文件「属性完全一致」时才会建立硬链接。

# 验证硬链接是否生效:同一文件的链接数应该大于 1
stat -c '%h %n' "$BASE/latest/some/big/file.bin"

一句话总结: 硬链接快照让「每日全量」只花增量成本,但一旦有人对快照做原地修改,就会破坏所有引用同一 inode 的快照。

4.2 轮转与保留

#!/usr/bin/env bash
set -euo pipefail

BASE=/backup/snapshots
KEEP_DAILY=7

# 保留最近 7 份每日快照,其余删除
mapfile -t snaps < <(find "$BASE" -maxdepth 1 -type d -name '20*' | sort)
count=${#snaps[@]}
if [ "$count" -gt "$KEEP_DAILY" ]; then
  for old in "${snaps[@]:0:$((count - KEEP_DAILY))}"; do
    echo "pruning $old"; rm -rf -- "$old"
  done
fi

轮转顺序必须是「先建新快照、再删旧快照」:反过来的话,中间一旦失败就会既没有新的、又删掉了旧的。

# 安全写法:先完成同步与校验,确认成功后再轮转
rsync -aHAX --delete --link-dest="$LATEST" "$SRC/" "$BASE/$TODAY/" \
  && ln -sfn "$BASE/$TODAY" "$BASE/latest" \
  && prune_old_snapshots

5. 校验与完整性检查

一句话总结: rsync 的默认校验是「大小 + 修改时间」,要更严格就用 --checksum;跨系统长期保存还需要自己生成校验和清单。

5.1 同步校验

# 1. 空跑 + 逐项列出差异(--dry-run 不实际传输)
rsync -aHAX --delete --dry-run --itemize-changes /data/src/ /backup/dst/
# ">f+++++++++" 表示新增,">f.st......" 表示内容与时间都变了

# 2. 按内容校验(更严格,但代价高)
rsync -aHAX -c --dry-run --itemize-changes /data/src/ /backup/dst/

# 3. 同步后用统计摘要确认「传了多少、删了多少」
rsync -aHAX --delete --stats /data/src/ /backup/dst/ | tail -20

-c(--checksum)会读取两端所有文件内容计算校验和,代价很高,只适合小数据量或关键数据的事后复核,不要作为日常选项。

一句话总结: 日常同步靠「大小 + 时间」就够快,关键数据的事后复核再用 -c 或独立校验和清单。

5.2 清单与校验和

#!/usr/bin/env bash
set -euo pipefail
SNAP=/backup/snapshots/latest

# 生成清单:路径 + sha256
find "$SNAP" -type f -print0 | sort -z | xargs -0 sha256sum > "$SNAP.manifest.sha256"

# 事后验证(在快照目录下执行,路径才对得上)
(cd "$SNAP" && sha256sum -c "$SNAP.manifest.sha256" --quiet) && echo "integrity OK"

# 更轻量的存在性检查:比对文件数
src_count=$(find /data/src -type f | wc -l)
dst_count=$(find "$SNAP" -type f | wc -l)
[ "$src_count" -eq "$dst_count" ] || echo "WARN: file count mismatch"

注意 sha256sum -c 的路径是相对于清单生成时的位置,所以要么用相对路径生成清单,要么在验证时先 cd 到快照根。

6. 异地同步、加密与保留策略

一句话总结: 异地同步的核心约束是带宽与安全:用 --bwlimit 避免打满专线、用 SSH 加密通道、用 age/gpg 做端到端加密,最后用分级保留策略控制成本。

6.1 异地同步与带宽控制

# 通过 SSH 隧道同步,限速 5MB/s,避免影响业务带宽
rsync -aHAX --numeric-ids --partial --append-verify --bwlimit=5000 \
  --exclude-from=/etc/backup/exclude.txt \
  -e 'ssh -o Compression=no -i ~/.ssh/backup_key' \
  /backup/snapshots/latest/ backup@offsite:/backup/app/

# 用 --partial-dir 让中断的传输不污染目标目录
rsync -a --partial-dir=.rsync-partial /data/src/ remote:/backup/dst/
# 无人值守场景必须限制 SSH 命令,避免密钥泄露后被滥用
# ~/.ssh/authorized_keys 中这样写:
# command="rsync --server --sender -logDtpre.iLsfxC . /backup/",no-pty,no-agent-forwarding ssh-ed25519 AAAA...

--append-verify 适合「只追加的大文件」(如日志归档);对普通文件树,--partial 更通用。

一句话总结: 异地备份的密钥要用 command= 限制成只能跑 rsync,这样即使密钥泄露也无法拿到 shell。

6.2 加密与保留

# 端到端加密:tar 打包后用 age 加密再上传
tar -C /backup/snapshots -cf - latest \
  | age -r "$(cat /etc/backup/age.pub)" \
  > /backup/encrypted/app-$(date +%Y%m%d).tar.age

# 解密验证(演练时用)
age -d -i /etc/backup/age.key app-20261001.tar.age | tar -tf - | head
#!/usr/bin/env bash
# 分级保留:日备保留 7 份、周备 4 份、月备 12 份
set -euo pipefail
BASE=/backup/archive

# 每日保留 7 天
find "$BASE/daily"   -maxdepth 1 -type d -mtime +7   -exec rm -rf -- {} +
# 每周保留 4 周
find "$BASE/weekly"  -maxdepth 1 -type d -mtime +28  -exec rm -rf -- {} +
# 每月保留 12 个月
find "$BASE/monthly" -maxdepth 1 -type d -mtime +365 -exec rm -rf -- {} +

保留策略必须写成「同时满足天数与份数」:只按天数删,遇到备份失败会出现「天数到了但没新备份」的空窗;只按份数删,则长期停摆时会把历史全删光。

7. 恢复演练

一句话总结: 恢复演练是把备份真正变成「可用资产」的唯一手段:定期在隔离环境还原、校验内容、记录耗时,才能确认 RTO 是否达标。

7.1 演练脚本

#!/usr/bin/env bash
set -euo pipefail

SNAP=${1:-/backup/snapshots/latest}
DRILL=/tmp/restore-drill-$$
trap 'rm -rf "$DRILL"' EXIT
mkdir -p "$DRILL"
start=$(date +%s)

# 1. 还原(硬链接会被解开成独立文件)
rsync -aHAX "$SNAP/" "$DRILL/"

# 2. 校验文件数
src_files=$(find "$SNAP" -type f | wc -l)
dst_files=$(find "$DRILL" -type f | wc -l)
[ "$src_files" -eq "$dst_files" ] || { echo "FAIL: count mismatch"; exit 1; }

# 3. 抽样验证关键文件的校验和
for f in etc/app.conf var/lib/app/db.sqlite; do
  [ -f "$SNAP/$f" ] || continue
  a=$(sha256sum "$SNAP/$f" | cut -d' ' -f1)
  b=$(sha256sum "$DRILL/$f" | cut -d' ' -f1)
  [ "$a" = "$b" ] || { echo "FAIL: $f differs"; exit 1; }
done

echo "drill OK: $dst_files files, $(( $(date +%s) - start ))s"   # 实测 RTO
# 用 systemd timer 每月跑一次演练,结果写进日志:
# journalctl -u backup-drill.service --since "30 days ago"

7.2 常见恢复陷阱

# 陷阱一:忘了权限与属主(恢复后服务起不来)——-a 已含权限与属主
rsync -aHAX --numeric-ids /data/src/ /backup/dst/

# 陷阱二:恢复到已有数据的目录,叠加出「混合状态」
# 正确做法:恢复到全新目录,校验通过后再切换
mv /var/lib/app /var/lib/app.old && mv "$DRILL/app" /var/lib/app

# 陷阱三:硬链接快照里的文件被误改,导致所有快照一起坏
chmod -R a-w /backup/snapshots/2026-10-01   # 快照目录设为只读

# 陷阱四:备份脚本自身失败却返回 0
# 用「同步 + 校验 + 记录标记」三段式,任一步失败都让流水线失败
rsync -aHAX --delete "$SRC/" "$DST/" \
  && rsync -aHAX -c --dry-run --itemize-changes "$SRC/" "$DST/" \
  && touch "$DST/.backup-ok-$(date +%Y%m%d)"

最后一条 .backup-ok-* 标记文件是很多团队验证「备份是否真的跑过」的抓手:监控系统只要检查「最近 25 小时内是否出现了新的标记文件」即可。

8. 总结

环节要点
目标先定 RPO/RTO,再决定频率与形态
前置校验源必须存在且非空,否则「成功备份了空目录」
尾斜杠源带斜杠拷内容,不带斜杠拷目录本身
选项-aHAX --numeric-ids --partial 是备份基线
排除规则先命中先生效,例外要写在通配排除之前
删除--delete 配 --max-delete 做保险丝,先 --dry-run
快照--link-dest 硬链接实现「全量外观、增量占用」
轮转先建新、后删旧,失败时不会两头空
校验日常靠大小与时间,关键数据用清单加校验和
异地SSH 加密 + --bwlimit 限速 + command= 限制密钥
保留天数与份数双重条件,避免空窗或全删
演练定期还原到隔离目录,实测 RTO 并记录

备份的可靠性不来自「脚本写得多复杂」,而来自「每一步都可验证」:源非空、传输有摘要、快照有校验、轮转有顺序、恢复有演练。当这套流程被 systemd timer 定时拉起、失败时以非零退出码触发告警,数据安全才从「但愿没事」变成「有据可查」。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战