备份恢复演练:让每周的备份真的能还原

一、为什么备份会「看起来成功,其实不可用」

几乎每个站长都有过这样的经历:脚本跑了三年,每天凌晨两点一行 "backup done" 写进日志,磁盘上确实躺着几个几十 GB 的 tar 包,直到某天硬盘挂掉、或者误删了一张表,才发现这些包根本解不开,或者解开以后数据库起不来。

这不是备份工具的问题,而是备份链路里有一段从来没人验证过。备份实际上有五个环节:产生源数据 → 打包/压缩 → 传输或落盘 → 保留与轮转 → 恢复。绝大多数站点只跑通了前三环,第四环靠 crontab 里一句 find 删除旧文件凑合,第五环从来没跑过。于是「恢复」这个唯一真正重要的环节,成了唯一没有测试过的环节。

本文讲的是一次完整的恢复校验实践:从设计校验用的旁路环境、写一个真正会报错的恢复脚本,到把校验结果变成一个可以每天看一眼的数字。整套做法在一台 2 核 4G 的个人站服务器上可以跑通,不需要额外的备份服务器。

二、先定义「可用」:四个可量化的判据

「备份能用」是一句空话,必须翻译成可以自动判定的条件。建议至少定这四个:

  • 完整性:压缩包能通过校验(gzip/tar/zstd 自身的完整性校验),且文件数量、总字节数与源目录的偏差在允许范围内。
  • 可解性:能完整解压到临时目录,过程中没有任何一个文件报错。
  • 可启动性:解出来的数据能让服务真正跑起来。对 MySQL 来说就是能启动 mysqld 并执行一次全库 COUNT;对网站来说就是 PHP 能读到文件、Nginx 能返回 200。
  • 新鲜度:最近一次成功恢复演练距今不超过 N 天(建议 30 天),且本次恢复的数据快照时间与预期时间差在一天内。

前两条是静态检查,后两条是动态检查。只做前两条的站点最多只能保证「文件在」,而线上事故里最常见的其实是「文件在但数据不对」,比如 mysqldump 只导出了一半就被 kill、或者数据库在 dump 过程中被写入了不一致的状态。

三、旁路环境:不碰线上的最小验证空间

恢复校验最容易出的事故是「为了验证备份,把线上目录覆盖了」。所以第一件事是把验证环境隔离出来:独立的目录、独立的端口、独立的数据目录,最好还能用独立的系统用户。

如果是整站备份,可以解压到一个固定路径下:

mkdir -p /data/restore_test/site
tar -xzf /data/backup/site-20261104.tar.gz -C /data/restore_test/site

如果是数据库,则不要用默认的 datadir,用 --datadir 指到临时目录,端口也换一个,比如 3307,避免和线上的 3306 抢 socket(socket 文件也要一起改,否则会连到线上实例,这是很常见的一次「意外」):

mysqld --defaults-file=/data/restore_test/my-test.cnf \
       --datadir=/data/restore_test/mysql \
       --port=3307 \
       --socket=/data/restore_test/mysql.sock \
       --skip-networking=0 \
       --log-error=/data/restore_test/mysqld.err &

配置文件里另外要注意三件事:innodb_buffer_pool_size 可以调小,恢复校验不需要性能;tmpdir 要指向有足够空间的盘;lower_case_table_names 必须和线上一致,否则在区分大小写的环境下恢复出来的表名会报「表不存在」,而这种问题只有真正跑过恢复才会暴露。

四、恢复脚本:每个失败都必须让脚本退出非零

校验脚本的价值在于「失败时让人知道」,所以最忌讳的写法是每一步后面都跟着 || true,或者用 tail 把 stderr 吞掉。下面是一个可直接改用的骨架:

#!/usr/bin/env bash
set -Eeuo pipefail
trap 'echo "FAILED at line $LINENO" >&2' ERR

BK=$1                       # 备份文件路径
WORK=/data/restore_test
LOG=$WORK/verify.log
: > "$LOG"
exec >> "$LOG" 2>&1

echo "== $(date -Is) 开始校验 $BK"
test -s "$BK" || { echo "备份文件不存在或为空"; exit 1; }

# 1 完整性
gzip -t "$BK"
echo "OK 压缩包完整性"

# 2 可解性
rm -rf "$WORK/mysql"
mkdir -p "$WORK/mysql"
tar -xzf "$BK" -C "$WORK/mysql"
echo "OK 解压 $(find "$WORK/mysql" -type f | wc -l) 个文件"

# 3 可启动性
mysqld --defaults-file=$WORK/my-test.cnf \
       --datadir=$WORK/mysql --port=3307 \
       --socket=$WORK/mysql.sock --log-error=$WORK/mysqld.err &
PID=$!
for i in $(seq 1 60); do
  mysqladmin --socket=$WORK/mysql.sock ping >/dev/null 2>&1 && break
  sleep 1
done
mysql --socket=$WORK/mysql.sock -N -e "SELECT COUNT(*) FROM 你的库.文章表" \
  || { echo "启动后查询失败"; kill $PID; exit 1; }
echo "OK 数据库可启动可查询"
kill $PID; wait $PID 2>/dev/null || true
echo "== 校验通过"

几个容易忽略的细节:set -Eeuo pipefail 里的 E(errtrace)让 trap 在函数和子 shell 里也生效,否则后台进程失败时脚本会静默走到结尾;mysqladmin ping 只证明 socket 存在,不证明数据库真的能读,所以必须再执行一条真查询;kill 之后不要立刻删临时目录,留到下一次运行的开头再删,方便人上去看一眼。

五、日志轮转先于保留策略:把「删」写得比「备」更小心

备份空间的矛盾几乎永远出在保留策略上。两行 cron 就能把磁盘写满:备份脚本每天生成 8G,保留 7 天,而磁盘只有 50G。一旦磁盘满,MySQL 会拒绝写入、PHP 会话写不进去、Nginx 连日志都写不了,这时候备份脚本本身也会失败,而人往往只看到「站点 502」。

推荐三个防护动作。第一,备份前先检查可用空间,不足时立刻告警而不是硬写:

AVAIL=$(df --output=avail -k /data | tail -1)
NEED=$(( $(du -sk /var/www | cut -f1) * 2 / 1024 ))
if [ "$AVAIL" -lt "$NEED" ]; then
  echo "空间不足: 可用 ${AVAIL}KB, 预计需要 ${NEED}KB" >&2
  exit 2
fi

第二,删除旧备份用一个独立的、只按日期匹配的清理脚本,并且先干跑一次打印要删的文件,确认路径拼对了再加 -delete。网上流传的 find /data/backup -mtime +7 -delete 在路径写错一个字符时就会删掉别的东西,而 find 不会问你。第三,保留策略分两档:最近 7 天每天一份,每周日的那份额外留 8 周,这样即使一周内所有文件都被同一段坏数据污染,也能回到一个月前。

六、把校验结果变成一个数字:新鲜度告警

恢复演练一周一次甚至一月一次都合理,但告警必须每天跑一次。做法很简单:成功演练时写一个时间戳文件,每天另有一个轻量检查读这个文件,超过 30 天就通过 webhook 推到手机上。

date +%s > /data/restore_test/.last_ok

age=$(( ($(date +%s) - $(stat -c %Y /data/restore_test/.last_ok)) / 86400 ))
[ "$age" -le 30 ] || curl -s -m 10 -X POST \
  -H 'Content-Type: application/json' \
  -d "{\"msg\":\"备份恢复演练已 $age 天未成功\"}" \
  https://你的告警地址/hook

这个数字比任何日志都可靠,因为它衡量的是「最后一次真正验证过备份能用的时间」,而不是「最后一次备份脚本运行的时间」。很多站点出事后判断失误,就是因为这两个数字被混为一谈:日志说昨晚备份成功,实际上已经三个月没有一次恢复成功过。

七、备份文件的保存:加密与异地不能省

数据库备份里通常有用户手机号、密码哈希、订单信息,所以备份文件不该和应用放在同一台机器上,也不该以可见形态躺在网盘里。异地可以用 rclone 推到对象存储,加密可以在打包时就用 GPG 对称加密:

gpg --batch --yes --symmetric --cipher-algo AES256 \
    --passphrase-file /root/.bk.key \
    -o /data/backup/db-$(date +%F).tar.gz.gpg \
    /data/backup/db-$(date +%F).tar.gz
rclone copy /data/backup/db-$(date +%F).tar.gz.gpg remote:bk/site/

离线异地副本每月做一次人工下载,原因很简单:如果对象存储的密钥和服务器放在同一处,服务器被入侵就等于备份一起丢。这个动作不需要技术含量,但它是「勒索病毒/误删/服务商跑路」这三种场景下唯一还剩下的一层。

八、演练节奏与检查清单

最后把上面所有东西固化下来,按周和按月分两档执行:

  • 每周:跑一次静态校验(完整性 + 可解性),耗时通常在十几分钟内,结果写日志。
  • 每月:跑一次完整的恢复演练(含启动数据库并执行查询),同时人工打开一次恢复出来的首页截图对比。
  • 每季度:做一次「最坏情况」演练——假设服务器整机丢失,只用异地备份从零重建站点,并记录耗时。这个耗时才是真正的恢复时间,比任何估算都可信。
  • 每次改表结构、升级 MySQL/PHP 版本后,立即补跑一次演练,因为版本变化是最常见的「老备份新环境起不来的原因」。

演练记录里一定要写下具体时间、备份文件、恢复结果与耗时。半年后回头看这份记录,你会清楚地知道自己的真实风险在哪里,而不是靠「我每天都备份」这种自我安慰。

九、小结

备份的价值不在生成,而在恢复。把完整性与可解性做成脚本的硬断言、把可启动性放进旁路实例里真跑一次、再用一个「最后成功时间」的数字兜住新鲜度,三件事加起来,一个个人站也能做到「出事时敢点恢复」。这套流程不需要额外硬件,需要的只是一点纪律:让每个失败都返回非零,让删除动作先干跑,让异地副本每月真正落地一次。

Last modification:November 5th, 2026 at 12:26 pm

Leave a Comment