数据库备份能恢复才算数:恢复演练三查与每周自动演练脚本实战

备份不是把数据导出来就完了

「我已经每天备份了,为什么还是恢复不了?」—这是个人站长在做事故复盘时最常见的一句话。备份这件事有一个很反直觉的地方:它平时不产生任何价值,只有在你真正需要它的那一刻才见分晓,而那一刻往往就是你最慌乱、最没有时间慢慢试错的时候。如果备份方案平时没有经过验证,出事时才发现备份文件损坏、缺表、或者恢复脚本根本跑不通,那前面所有的「每天备份」都是自我安慰。

这篇文章不讨论该用 mysqldump 还是 xtrabackup(两者各有场景),而是聚焦一个更基础、更容易被忽视的问题:如何用最低成本验证你的备份真的可用。我会给出一个可以放进 crontab 的自动演练脚本,以及一个经常被忽略的「恢复演练三查」检查法,让你在不出事的时候就知道出事时能不能救回来。

为什么「备份文件存在」不等于「备份可用」

先看几个真实发生过的失败模式。第一种,mysqldump 进程还在跑,但磁盘已经满了,dump 文件被截断,文件大小看着不小,其实最后几张表根本没写完。第二种,备份脚本用 mysqldump ... | gzip > backup.gz 的管道写法,但中间 mysqldump 报错退出了,gzip 依然高高兴兴地把空内容压缩成一个合法的 gz 文件,退出码还是 0,于是备份脚本报告「成功」。第三种,备份是完整的,但恢复时需要先建库、再改字符集、再导入,这一套流程没有任何文档记录,出事时靠记忆操作,漏了一步就恢复出乱码。

这几种失败模式的共同点是:它们都不会在备份当天暴露问题。备份脚本每天安静地跑完、留下一个文件、不报错,直到你需要恢复的那一天才原形毕露。所以验证备份的第一原则是——用备份里的数据主动去做一次恢复,而不是去检查文件是否存在、大小是否合理。

恢复演练三查:结构、行数、抽样内容

把恢复结果和源库做比对,看三样东西就够了,不必逐行 diff(大表根本 diff 不动)。

一查结构。比对两边的表数量与建表语句。最简单的做法是导出两边的表清单和表结构做文本比较:

mysql -uroot -pPASS zz1984 -N -e "SHOW TABLES" | sort > /tmp/src_tables.txt
mysql -uroot -pPASS test_restore -N -e "SHOW TABLES" | sort > /tmp/rst_tables.txt
diff /tmp/src_tables.txt /tmp/rst_tables.txt && echo "表清单一致"

表清单一致之后,再抽查几张关键表的建表语句,确认索引和外键没有丢:

mysqldump -uroot -pPASS --no-data zz1984 typecho_contents > /tmp/a.sql
mysqldump -uroot -pPASS --no-data test_restore typecho_contents > /tmp/b.sql
diff /tmp/a.sql /tmp/b.sql && echo "表结构一致"

二查行数。逐表比对 COUNT(*)。写个小循环把两边行数打出来对比,差异超过 0 就要警觉:

for t in $(cat /tmp/src_tables.txt); do
  s=$(mysql -N -e "SELECT COUNT(*) FROM zz1984.\`$t\`")
  r=$(mysql -N -e "SELECT COUNT(*) FROM test_restore.\`$t\`")
  [ "$s" != "$r" ] && echo "行数不一致: $t 源=$s 恢复=$r"
done
echo "行数比对完成"

注意反引号转义——表名里带连字符或保留字时,不加反引号 SQL 会直接报语法错误。

三查抽样内容。结构和行数都对了,还要确认内容本身没被字符集转换搞坏。挑最新的一条内容,比对它的哈希:

mysql -N -e "SELECT MD5(text) FROM zz1984.typecho_contents ORDER BY cid DESC LIMIT 1"
mysql -N -e "SELECT MD5(text) FROM test_restore.typecho_contents ORDER BY cid DESC LIMIT 1"

两个 MD5 相同,说明这一行的内容在备份、压缩、解压、导入的全过程中没有被篡改或转码。这一步看起来多余,但在跨字符集恢复(比如源库 utf8mb4 恢复到默认 utf8 的新实例)时,它是唯一能发现乱码的手段——行数完全一致,内容已经变成问号了。

一个可以放进 crontab 的自动演练脚本

手工演练只能偶尔做,真正可靠的是让它自动跑起来。下面这个脚本的思路是:取当天最新的备份文件,恢复到一个临时库,跑完三查,把结果写进日志并清理临时库。任何一步失败就输出明确报错,方便你在日志里一眼看到。

#!/bin/bash
# /root/scripts/restore_drill.sh
set -euo pipefail
BK_DIR=/root/backup
LOG=/var/log/restore_drill.log
TMP_DB=drill_restore_$$
SRC_DB=zz1984
MYSQL="mysql -uroot -pChenJian2208045"
LATEST=$(ls -t $BK_DIR/*.sql.gz 2>/dev/null | head -1)
[ -z "$LATEST" ] && { echo "$(date) 未找到备份文件" >> $LOG; exit 1; }

echo "$(date) 开始演练: $LATEST" >> $LOG
gzip -t "$LATEST" || { echo "$(date) 备份文件损坏" >> $LOG; exit 1; }

$MYSQL -e "DROP DATABASE IF EXISTS $TMP_DB; CREATE DATABASE $TMP_DB CHARACTER SET utf8mb4"
zcat "$LATEST" | $MYSQL $TMP_DB || { echo "$(date) 导入失败" >> $LOG; exit 1; }

SRC_CNT=$($MYSQL -N -e "SELECT COUNT(*) FROM $SRC_DB.typecho_contents")
RST_CNT=$($MYSQL -N -e "SELECT COUNT(*) FROM $TMP_DB.typecho_contents")
if [ "$SRC_CNT" != "$RST_CNT" ]; then
  echo "$(date) 行数不一致 源=$SRC_CNT 恢复=$RST_CNT" >> $LOG
else
  echo "$(date) 演练通过 行数=$RST_CNT" >> $LOG
fi
$MYSQL -e "DROP DATABASE $TMP_DB"

两个要点值得强调。gzip -t 这一步是必须的,它能在几秒内发现被截断的备份,比等到导入到一半才报错要友好得多。set -euo pipefail 也是必须的,它能防止管道中某个环节悄悄失败却整体返回成功——前面提到的「mysqldump 报错但 gzip 成功」那个坑,就是靠 pipefail 抓出来的。

把这个脚本挂到 crontab 里每周跑一次,比如周日凌晨 4 点:

0 4 * * 0 /bin/bash /root/scripts/restore_drill.sh

一个月之后你回看日志,如果每周都是「演练通过」,那你就有真实的证据相信备份是可用的,而不是凭感觉相信。这比任何「我们每天都备份」的口头保证都有说服力。

演练之外:还要演练「恢复流程」本身

数据能恢复,不等于你出事时能在可接受的时间内恢复。真正的事故现场,你需要的是「几分钟内让站点重新上线」,而不是「几个小时内把数据搞清楚」。这两者的差别在于流程而非数据。所以除了数据演练,还应该演练一遍完整的上线流程:从拿到备份文件,到新机器装好 MySQL 和 PHP,到导入数据,到 Nginx 能正常回源,到域名解析切换。整个流程每一步需要多久、由谁做、命令是什么,都应该事先写好。

一个很实用的做法是把恢复流程写成一个 checklist 文档,放在备份目录旁边,标题就叫「出事时按这个顺序做」。文档里写清楚:第一步确认故障范围,第二步在备用机重建环境,第三步导入最近备份,第四步验证三查,第五步做 DNS 切换,第六步保留旧机器现场用于事后分析。第六步特别容易被慌乱中的人忽略——很多人一恢复成功就迫不及待地把坏掉的机器重装了,结果永远搞不清楚当初为什么会坏,下次还会再踩同一个坑。

备份文件放在哪:3-2-1 原则在个人站点的落地

验证备份可用之后,还要确认备份不会跟着服务器一起消失。个人站点最容易犯的错是:备份文件就放在被备份的那台机器上,甚至和数据库放在同一块盘上。一旦磁盘损坏或者服务器被回收,数据和备份同时归零。业界通用的 organizing 原则是 3-2-1:至少三份副本,两种不同介质,其中一份在异地。

对个人站长来说,完全可以低成本做到这一点。第一份留在服务器本地,方便快速恢复;第二份用 rsync 或对象存储的 CLI 工具同步到另一台机器或者另一个机房;第三份定期下载到自己的电脑或者云盘里。这里有个关键细节值得强调:异地那份必须是加密的。备份文件里包含全站数据甚至用户信息,随手上传到公开的对象存储而不加密,等于把数据库挂在互联网上。可以在备份脚本里直接用 gpgopenssl 加密后再上传:

gzip -c dump.sql | openssl enc -aes-256-cbc -pbkdf2 -pass file:/root/.bk_pass > dump.sql.gz.enc

密码文件单独保管,不和备份放在一起。这样即使备份文件泄露,没有密码也无法还原。另一个细节是保留策略:不要无限期地堆积备份,很容易把磁盘撑满。一个务实的做法是保留最近 7 天的每日备份、最近 4 周的每周备份、最近 12 个月的每月备份,用脚本自动清理更早的:

find /root/backup -name '*.sql.gz' -mtime +7 -not -name '*weekly*' -delete

删除前务必确认保留策略的边界写对了——先用 find ... -print 空跑一遍看看会删哪些文件,确认无误再去掉 -print 真正执行。这条「先空跑再执行」的规则,对所有带 -delete 的清理命令都适用,能避免一次误删所有备份的灾难。

备份与恢复的时间窗口要匹配实际承受力

还有一个很少被认真算过的指标:RPO 和 RTO。RPO 是「最多能丢多少数据」,取决于备份频率——每天备份一次,极端情况下可能丢 24 小时的数据;每小时备份一次,最多丢 1 小时。RTO 是「多久能恢复上线」,取决于恢复演练测出的实际耗时。这两个数字才是决定备份方案是否合格的标准,而不是「有没有备份」这个是非题。

对于内容站,每日全量加每小时增量是比较均衡的选择;对于有用户提交数据的站点,写入频繁,可能需要更短的间隔,比如每十五分钟做一次 binlog 归档。这里要提醒的是:备份频率越高,对服务器 IO 和存储的压力越大,凌晨全量备份时如果撞上磁盘 IO 瓶颈,可能拖慢白天的访问。所以备份任务的时间安排也要避开访问高峰,并且用 niceionice 降低它的优先级:

nice -n 19 ionice -c3 mysqldump -uroot -pPASS zz1984 | gzip > /root/backup/dump_$(date +%F).sql.gz

-c3 表示空闲 IO 优先级,只有在磁盘完全空闲时才跑,能和正常访问和平共处。这一行加不加,直接决定你凌晨的备份会不会把白天也拖慢。

把演练变成制度,而不是一次性任务

最后一个容易被忽略的点是:备份方案本身也会随之变化。你今天用 mysqldump 备份一张 100 万行的表,恢复只要几分钟;一年后这张表变成 5000 万行,恢复可能要两个小时,你的「RTO」在无声无息中从几分钟变成几小时。如果从来没有演练过,你根本不会意识到这个变化,直到某天真的需要恢复,才发现要在机房外等两小时。

所以演练不应该是一次性的验证动作,而应该成为一项定期执行的制度:每周自动跑一次数据层演练,每季度手工跑一次完整的站点恢复演练(包括换机、改解析),每次数据库结构大改之后追加一次。把演练结果记进日志,每个月扫一眼趋势——如果恢复耗时在稳步上升,那就是该考虑换备份工具或者拆分大表的时候了。备份的可靠性不是买来的,是练出来的。

Last modification:September 24th, 2026 at 08:25 pm

Leave a Comment