个人站备份验证实战:mysqldump/zstd/restic 与恢复演练,如何证明备份真的能恢复

备份没验证过,等于没有备份

我做了十几年个人站,见过最难受的一类事故不是被攻击,也不是硬盘坏,而是:备份脚本一直在跑、日志显示成功、备份文件也在、但真要恢复的时候发现它是坏的。或者更惨——文件在,但你不知道它对应的是什么时候的数据库,binlog 位点对不上,恢复出来的站是三个月前的。

这篇文章讲的是个人站长能落地的备份验证方法。不讨论企业级方案,只讨论一台或两台 VPS、数据库几十兆到几 G 这个量级下,如何做到「备份可恢复、恢复可验证、验证可自动」。

先把结论摆出来:一个合格的备份体系必须能回答四个问题——备份了什么、多久备份一次、怎么恢复、什么时候验证过。任何一个问题答不上来,就是没有备份。

一、先理清备份的三个层次

个人站的备份通常包含三块,它们的频率和恢复方式完全不同:

  • 数据库:变化最频繁,最重要,也最容易坏。文章、评论、用户都在这里。频率应该最高(每天甚至每几小时)。
  • 站点文件:主题、插件、上传的图片。变化中等。上传目录可能很大,需要单独处理。
  • 配置与系统状态:Nginx/PHP/MySQL 配置、crontab、SSL 证书、防火墙规则、systemd unit。这部分往往被忽略,但恢复时没有它,站跑不起来。

很多人的备份脚本只做了第一项的一半(mysqldump 但不验证),第二项用 tar 全量打包每天复制,第三项完全没有。真出事的时候,恢复数据库花了半小时,然后花两天重建环境。

二、数据库备份:先别急着上工具

最基础的 mysqldump,但有几个参数决定了备份是否「可恢复」:

mysqldump \
  --single-transaction \
  --quick \
  --routines \
  --triggers \
  --events \
  --default-character-set=utf8mb4 \
  --set-gtid-purged=OFF \
  --hex-blob \
  -u root -p'密码' 数据库名 \
  | gzip -6 > /backup/db/zz1984-$(date +%F-%H%M).sql.gz

逐个说为什么:

  • --single-transaction:InnoDB 下用一致性快照,不锁表。没有这个参数,备份期间站点会卡。
  • --quick:逐行取,不把整个结果集放进内存。表大了不加这个会 OOM。
  • --routines --triggers --events:存储过程、触发器、定时事件。默认不导出,很多人恢复完发现定时任务全没了。
  • --hex-blob:二进制字段(比如某些插件存的序列化数据)不会因字符集转换损坏。
  • --set-gtid-purged=OFF:MySQL 8 开了 GTID 时,不加这个恢复到其他实例会报错。

然后是压缩格式的选择。gzip 快但压缩率一般;zstd 是更好的选择,速度快 3~5 倍,压缩率还更高,而且支持多线程:

mysqldump ... | zstd -T4 -10 > /backup/db/zz1984-$(date +%F-%H%M).sql.zst

恢复的时候:zstd -dc backup.sql.zst | mysql -u root -p 数据库名。

对于几 G 以上的库,或者要求恢复时间在分钟级的场景,mysqldump 就不合适了,应该用物理备份。Percona XtraBackup 是免费方案里最成熟的:

xtrabackup --backup --target-dir=/backup/xtra/$(date +%F) \
  --user=root --password=密码 --parallel=4 --compress

# 恢复前必须先 prepare
xtrabackup --prepare --target-dir=/backup/xtra/2026-10-17
xtrabackup --copy-back --target-dir=/backup/xtra/2026-10-17
chown -R mysql:mysql /var/lib/mysql

注意 --prepare 这一步不能省,它对备份做崩溃恢复(回滚未提交事务、前滚已提交日志)。跳过 prepare 直接 copy-back,数据库起不来。

三、文件备份:别把整个 /var/www 每天打包一次

一个跑了三年的站,上传目录可能有几十 G。每天全量 tar 一遍,既占空间又占 IO,还会和数据库备份抢磁盘。

正确的分层做法:

  • 代码部分(主题、插件、配置文件,通常几十 MB):纳入 Git 管理,每天用 rsync 增量备份就够。版本控制本身就是最好的备份。
  • 上传目录(图片、附件,可能很大):用 rdiff-backup 或 restic 做增量。
  • 系统配置:用 etckeeper 纳入 Git,每次改动自动提交。

rsync + 硬链接快照是成本最低的方案,rsnapshot 就是干这个的:

# /etc/rsnapshot.conf 关键行
snapshot_root   /backup/snapshots/
retain  daily   7
retain  weekly  4
retain  monthly 6
backup  /var/www/       localhost/
backup  /etc/           localhost/
backup  /root/scripts/  localhost/

每天跑 rsnapshot daily,未变化的文件用硬链接复用,实际占用空间接近「一份全量 + 每天的变化量」。恢复时直接进对应日期的目录 copy 出来,不需要解压步骤,这一点比 tar 好太多。

restic 是另一个选择,优势是去重加密、支持 S3/B2 等对象存储做异地:

export RESTIC_PASSWORD_FILE=/root/.restic-pass
export AWS_ACCESS_KEY_ID=xxx
export AWS_SECRET_ACCESS_KEY=xxx

restic -r s3:https://s3.us-west-000.backblazeb2.com/bucket-name backup \
  /var/www --exclude '*.log' --exclude 'cache/'

restic -r s3:... snapshots
restic -r s3:... restore latest --target /tmp/restore-test

异地备份这件事,个人站长常常觉得「本地已经有备份了」。但硬盘坏、VPS 被停、机房事故,本地和异地是同时没的。哪怕只是把每周的数据库备份传到一个对象存储,也比完全没有异地强。

四、配置备份:最容易被忘掉的部分

etckeeper 会把你对 /etc 的所有改动自动提交到 Git:

apt install etckeeper
# 安装时选 git
etckeeper init
etckeeper commit "initial"
# 之后每次 apt 操作会自动 commit
# 手工改动后手动提交
etckeeper commit "改 nginx client_max_body_size"

备份时只需要 git bundle create /backup/etc-$(date +%F).bundle --all,把整个 /etc 的历史打成一个文件。恢复时 git clone /path/to/etc.bundle /etc-restore。

除了 /etc,还有几个地方必须单独记住:

  • crontab -l 的输出(用户级定时任务不在 /etc 里)
  • docker compose 文件和 docker volume 的内容
  • 各类应用的密钥文件:SSH 私钥、SSL 证书私钥、应用 config.php
  • DNS 解析记录(导出一份 zone 文件或截图,别只依赖服务商后台)

建议做一个 /root/restore-notes.md,写清楚每台机器装了什么、数据在哪、怎么恢复。人脑在三小时的紧张恢复过程中是不可靠的。

五、验证:这才是本文的核心

备份文件存在、大小正常、gzip 能解开,这三件事都不能证明备份可用。真正的验证必须是「恢复到一个干净环境,看它能不能跑起来」。

轻量验证(每天跑,成本低):

#!/bin/bash
# /usr/local/bin/verify-db-backup.sh
set -euo pipefail
LATEST=$(ls -t /backup/db/*.sql.zst | head -1)

# 1. 完整性:zstd 流能否解到底
zstd -t "$LATEST"

# 2. SQL 结构:文件尾部必须有 -- Dump completed
if ! zstd -dc "$LATEST" | tail -5 | grep -q "Dump completed"; then
  echo "FAIL: $LATEST 不完整(缺少 Dump completed)" >&2
  exit 1
fi

# 3. 内容抽查:能否解析出建表语句数量
TABLES=$(zstd -dc "$LATEST" | grep -c "^CREATE TABLE")
if [ "$TABLES" -lt 5 ]; then
  echo "FAIL: 只找到 $TABLES 张表,疑似备份不完整" >&2
  exit 1
fi

echo "OK: $LATEST  表数=$TABLES  大小=$(du -h "$LATEST" | cut -f1)"

这个脚本能拦住 80% 的「备份其实早就坏了」的情况:磁盘满导致截断、mysqldump 中途被杀、gzip 写到一半 IO 错误。

重量验证(每周或每月跑,成本高但最可靠):真正恢复到临时数据库,跑几条业务查询,比对行数。

#!/bin/bash
# /usr/local/bin/restore-drill.sh
set -euo pipefail
LATEST=$(ls -t /backup/db/*.sql.zst | head -1)
TESTDB="restore_test_$(date +%s)"

echo "== 恢复到 $TESTDB =="
mysql -uroot -p"$MYSQL_PWD" -e "CREATE DATABASE $TESTDB CHARACTER SET utf8mb4"
zstd -dc "$LATEST" | mysql -uroot -p"$MYSQL_PWD" "$TESTDB"

echo "== 数据一致性检查 =="
SRC=$(mysql -uroot -p"$MYSQL_PWD" -N -e "SELECT COUNT(*) FROM zz1984.typecho_contents WHERE type='post'")
DST=$(mysql -uroot -p"$MYSQL_PWD" -N -e "SELECT COUNT(*) FROM $TESTDB.typecho_contents WHERE type='post'")
echo "源站文章数: $SRC   恢复后文章数: $DST"
if [ "$SRC" != "$DST" ]; then
  echo "FAIL: 行数不一致!" >&2
  exit 1
fi

echo "== 抽查最近一篇文章能读出 =="
mysql -uroot -p"$MYSQL_PWD" -N -e "SELECT title FROM $TESTDB.typecho_contents ORDER BY cid DESC LIMIT 1"

mysql -uroot -p"$MYSQL_PWD" -e "DROP DATABASE $TESTDB"
echo "PASS"

注意最后一定要 drop 掉测试库,否则磁盘会被慢慢吃满——这个坑我自己踩过,两个月后服务器磁盘 100%。

文件备份的验证更简单,关键在于「打开看看」:

# rsnapshot 验证:确认目录结构完整、抽样文件可读
DAY=$(ls /backup/snapshots/daily.0/localhost/var/www -d)
find "$DAY" -type f -name "*.php" | head -5 | while read f; do
  php -l "$f" >/dev/null || echo "语法错误: $f"
done
du -sh "$DAY"

# restic 验证:built-in 校验
restic -r s3:... check --read-data-subset=5%

restic check --read-data 会真正读回数据并校验哈希,是验证去重库是否损坏的标准方法。--read-data-subset=5% 表示只抽查 5%,每周轮换,一个月能覆盖全库。

六、保留策略:3-2-1 在个人站的落地

经典的 3-2-1 原则是:3 份副本、2 种介质、1 份异地。个人站可以简化为:

  • 副本 1:线上数据库和文件(这不是备份,但算一份)
  • 副本 2:本地磁盘 /backup,保留最近 7 天每日 + 4 周每周
  • 副本 3:异地对象存储,只保留每周一份,成本每月几块钱

保留策略要跟磁盘容量算清楚。假设数据库压缩后 200MB,7 天每日 = 1.4G,4 周每周 = 800M,总共 2.2G。df -h /backup 留出至少三倍余量。

清理用 find,注意别把正在写的文件删掉:

# 删除 14 天前的每日备份
find /backup/db -name "*.sql.zst" -mtime +14 -delete
# 保留每周日的备份 8 周
find /backup/db -name "*.sql.zst" -mtime +56 -delete

更稳妥的做法是 rsnapshot 自带的 retain 配置,它按轮转规则管理,不会误删。

七、恢复演练:写一份能照着做的文档

真正出事的时候,人是慌的。所以必须有一份「照着抄就能恢复」的文档。内容至少要包括:

  1. 新服务器的系统版本、需要装的包列表
  2. Nginx / PHP-FPM / MySQL 配置从哪恢复
  3. 数据库恢复命令(完整的一条,含密码来源)
  4. 文件恢复命令(包括权限和属主设置)
  5. SSL 证书从哪来、怎么重新签发
  6. DNS 怎么切、切完怎么验证
  7. 恢复后必须检查的 5 个 URL

一个容易忽略的细节:文件权限。用 rsync 或 tar 恢复后,属主经常变成 root,导致 PHP 无法写缓存目录、上传目录。恢复文档里必须有一条:

chown -R www-data:www-data /var/www/site
find /var/www/site -type d -exec chmod 755 {} \;
find /var/www/site -type f -exec chmod 644 {} \;
chmod -R 775 /var/www/site/usr/uploads /var/www/site/var

还有 MySQL 8 的一个坑:恢复后如果 /var/lib/mysql 的属主不对,systemctl start mysql 会直接失败,日志里报 Permission denied。恢复流程里必须有 chown -R mysql:mysql /var/lib/mysql。

八、把验证做成定时任务

最后一件事:把上面这些串起来,用 cron 自动化。我的配置是这样的:

# crontab -e
# 每天 03:10 备份数据库
10 3 * * * /usr/local/bin/backup-db.sh >> /var/log/backup.log 2>&1
# 每天 03:40 备份文件(rsnapshot 增量)
40 3 * * * /usr/sbin/rsnapshot daily >> /var/log/rsnapshot.log 2>&1
# 每天 05:00 轻量验证,失败发邮件
0 5 * * * /usr/local/bin/verify-db-backup.sh || echo "BACKUP VERIFY FAILED $(date)" | mail -s "备份验证失败" you@example.com
# 每周日 04:00 异地同步
0 4 * * 0 /usr/local/bin/offsite-sync.sh >> /var/log/offsite.log 2>&1
# 每月 1 日 06:00 恢复演练
0 6 1 * * /usr/local/bin/restore-drill.sh >> /var/log/drill.log 2>&1
# 每月 2 日清理过期备份
0 4 2 * * find /backup/db -name "*.sql.zst" -mtime +56 -delete

关键点是验证失败要能通知到你。个人站长没有值班系统,最简单的是邮件 + 手机推送。如果服务器发不出邮件(很多 VPS 屏蔽 25 端口),可以用 curl 打一个自建的通知服务,比如 ntfy:

curl -d "备份验证失败 $(hostname) $(date)" https://ntfy.sh/你的私有topic

九、几个血泪教训

教训一:备份和源数据在同一块盘上。某次磁盘满了,MySQL 写入失败,备份脚本也写失败,两边一起没。备份必须放另一块盘或另一个挂载点,最差也要放异地。

教训二:备份脚本没有 set -e。mysqldump 失败了,脚本继续往下执行,最后把半截文件当成功备份,还把旧的成功备份按保留策略删掉了。所有备份脚本开头都要 set -euo pipefail,且 mysqldump 要用 --result-file 写临时文件,成功后再 mv。

教训三:从没试过恢复。这个最普遍。第一次恢复演练的时候,我发现备份里少了 --routines,导致某个插件的存储过程全丢;还发现 tar 打包时排除了 *.log,而某个插件把配置写在 storage.log 里。

教训四:没记录 binlog 位点。如果只恢复昨天的全备,中间一天的评论就没了。要保留 binlog,并记录每个备份对应的 binlog position,才能做时间点恢复。MySQL 8 默认开 binlog,但要确认保留天数够:

SHOW VARIABLES LIKE 'binlog_expire_logs_seconds';  -- 默认 2592000(30 天)

做定时备份时同时记录位点:

mysql -uroot -p -e "SHOW MASTER STATUS\G" > /backup/db/$(date +%F-%H%M).binlog-pos.txt

十、最小可行清单

如果觉得上面太复杂,先做这五件事,能挡住绝大多数事故:

  1. 每天 mysqldump + zstd,保留 14 天。
  2. 备份脚本加 set -euo pipefail,写临时文件后 mv。
  3. 每天自动跑一次 zstd -t + 尾部校验 + 表数量检查,失败发通知。
  4. 每周把最新备份同步到一个异地位置(对象存储或另一台机器)。
  5. 每月手工恢复一次到临时库,比对行数。

这五条做完之后,你就从「不知道怎么备份」变成了「知道备份可恢复」。两者的差别,在出事的那一天,是天壤之别。

Last modification:October 17th, 2026 at 10:26 pm

Leave a Comment