备份这件事,大多数站长的认知停留在「cron 里跑一条 mysqldump,产物有几百兆,心里就踏实了」。真到要用的那天,才发现备份文件是空的、是半截的、或者能恢复但恢复出来的是三个月前的旧库。备份的价值不在于「有没有每天跑」,而在于「出事那一刻能不能在可接受的时间内恢复出一个可用且可信的库」。这两者之间隔着的东西,就是备份校验。
这篇文章讲的是一套能在单机上真正落地的校验体系:从备份完成标记、文件级自检、到定期恢复演练,每一层花的时间都不多,但能把「以为有备份」变成「确定有备份」。
一、先认清三种失败:没备份、坏备份、错备份
备份失败在线上表现出来的样子差别很大,但根因只有三类,处理方法完全不同。
没备份是最常见的:cron 任务写错了路径、mysqldump 权限不足、磁盘满了写不下去、脚本里某条命令失败但 `set -e` 没加所以继续往下跑。这一类的特征是文件存在但大小为 0 或者只有几十字节,或者根本连文件都没有。
坏备份是指文件看着有几百兆,但内容不完整。典型成因是 mysqldump 中途被杀(OOM killer 最爱挑它,因为它会把整表读进内存做一致性快照)、网络中断、磁盘写满后写了一半、或者用了 `--single-transaction` 但对 MyISAM 表无效导致不一致。
错备份最隐蔽:文件完整、能恢复,但恢复出来的数据是错的。比如 mysqldump 连错了库、备份的是从库而主从早已断开、备份脚本半年没改过还连着已经废弃的老实例、或者只备份了 MySQL 而漏了 uploads 目录,恢复出来数据库有了但图片全 404。
三种失败对应三种校验手段:存在性校验查文件与时间戳,完整性校验查文件结构与内容,可用性校验真的恢复一遍。只做第一层的人最多,出事也最多。
二、第一层:完成标记要比「文件存在」更严格
cron 里最常见的一句话是 `mysqldump ... > /backup/db.sql`。这条命令有两个致命问题:重定向会先创建文件再写入,所以失败时也会留下一个空文件;而 shell 的重定向不会把 mysqldump 的退出码传给外层,如果你的脚本没开 `set -o pipefail` 也没在主命令后判 `$?`,脚本整体仍然返回 0,cron 日志里一片正常。
改成先写临时文件、成功后原子移动,并在同一行里判退出码:
#!/bin/bash
set -euo pipefail
D=$(date +%F)
TMP=/backup/.tmp-$$.sql
mysqldump --single-transaction --quick --default-character-set=utf8mb4 \
--routines --triggers --events \
--databases zz1984 > "$TMP"
# 判空:小于 100KB 基本可判失败
[ "$(stat -c %s "$TMP")" -gt 102400 ]
mv "$TMP" "/backup/zz1984-$D.sql"
touch "/backup/.done-$D"注意几个参数不是可选项:`--single-transaction` 保证 InnoDB 表在同一快照点,`--quick` 避免整表读进内存(这是 OOM 的主要来源),`--routines --triggers --events` 如果不加,恢复后存储过程和事件调度会全部消失,而这一点往往几个月后才被发现。
完成标记文件 `.done-$D` 的意义在于:判断备份成功看标记,不看文件。后续任何监控、清理脚本都只认这个标记。清理脚本可以顺手加一条保护:只删除配套标记存在的旧备份,避免把唯一一份没标记但其实是好的备份删掉。
三、第二层:不开库也能做的完整性自检
文件存在且大小合理,仍然可能是半截的。MySQL 的 SQL 格式备份有一个很好的特性:mysqldump 会在结尾写一行 `-- Dump completed`,这一行只有在完整导出时才会出现。所以第一道完整性检查极其便宜:
tail -n 5 /backup/zz1984-$D.sql | grep -q 'Dump completed'
echo "exit=$?"如果这一行没有,说明导出被中断了,不管文件多大都不可用。这一条检查可以在一天之内发现绝大多数「坏备份」,成本只是一次 tail。
想更进一步,可以做结构统计:数一数 CREATE TABLE 的条数,和你预期的表数量比。表数量因为业务变动会漂移,所以不要硬编码一个数字,而是和「昨天」比:
grep -c '^CREATE TABLE' /backup/zz1984-$D.sql > /backup/.tables-$D
prev=$(ls -1 /backup/.tables-* | tail -2 | head -1)
diff <(cat "$prev") <(cat /backup/.tables-$D)正常情况下相邻两天的表数量差不超过几个;如果某天突然少了一半,多半是导出时某个库连不上或者权限变更。这个方法对「只备份了一部分库」这类错备份特别有效。
如果备份体积较大,压缩是必要的,但要养成「先校验再压缩」的顺序,并且在压缩后同样做一次可解压检查:`gzip -t file.sql.gz` 会真的把整个文件解一遍校验 CRC,几 GB 的文件也就几十秒,比事后发现压缩包损坏便宜得多。
四、检验压缩包能不能读:别只信文件大小
我见过最坑的一次事故是:备份脚本用 `mysqldump | gzip > db.sql.gz`,某天 mysqldump 因为锁等待超时被 kill,gzip 收到不完整的输入仍然正常结束,产出了一个几百兆的压缩包。`gzip -t` 校验也是通过的——因为压缩流本身完整,只是内容少了一截。这种情况下唯一能发现问题的办法是解开看结尾有没有 `Dump completed`。
所以对管道式备份,校验必须是「解压后再查结尾」,不能只查压缩包:
gzip -dc /backup/zz1984-$D.sql.gz | tail -n 3 | grep -q 'Dump completed'另一个更稳的写法是加 `set -o pipefail` 并检查 `PIPESTATUS`,这样 mysqldump 的非零退出码会传递出来:
mysqldump ... | gzip > "$TMP"
rc=("${PIPESTATUS[@]}")
[ "${rc[0]}" -eq 0 ] && [ "${rc[1]}" -eq 0 ]这两行看起来啰嗦,但它把「管道里谁来负责报错」这件事讲清楚了。很多「备份一直在跑但从没成功过」的站点,根因就是这一段。
五、第三层:恢复演练要定期真的做
前两层能排除「没备份」和大部分「坏备份」,但「错备份」只有真恢复才查得出来。恢复演练不需要在生产机上做,一个 Docker 容器或者本机另一个 MySQL 实例就够,关键是把它做成固定流程而不是一次性动作。
docker run -d --name restore-test \
-e MYSQL_ROOT_PASSWORD=test -p 3307:3306 mysql:8.0
# 等待就绪后导入
docker exec -i restore-test mysql -uroot -ptest \
-e "CREATE DATABASE zz1984"
docker exec -i restore-test mysql -uroot -ptest zz1984 \
< /backup/zz1984-$D.sql导入成功后要跑三项业务级核对,而不是只看「导入没报错」:
SELECT COUNT(*) FROM typecho_contents WHERE type='post' AND status='publish';
SELECT MAX(cid) FROM typecho_contents;
SELECT MAX(created) FROM typecho_comments;这三个数字要和线上同一天的快照对得上(post 数量差个位数正常,因为演练期间有新文章;MAX(cid) 差太多说明备份明显过旧)。顺便再抽一条最近的正文,确认中文没有变成问号——这是字符集不一致的典型表现,而 `--default-character-set` 忘写是新手最常见的漏项。
演练频率按数据重要度定:一天十几篇文章的内容站,每周一次就够;如果有订单、有用户余额,那要更密。演练结束记得 `docker rm -f restore-test`,否则几次之后磁盘就被演练容器吃满了。
六、恢复时的顺序:先停写,再恢复
演练和真实恢复的差别在于:真恢复时网站还在跑,还有人在写。如果直接 `mysql zz1984 < backup.sql`,导入期间的新写入会被覆盖,或者因为主键冲突导致导入中途失败。
正确的顺序是:先把站点切到维护页或者 Nginx 直接返回 503,停掉所有 PHP-FPM 进程避免它们重连进来继续写;确认 `SHOW PROCESSLIST` 里没有业务连接后,再导入;导入完成后清掉 Typecho 的缓存目录,让模板缓存重建;最后放流量回来。
另外,恢复大型库时 `max_allowed_packet` 和 `innodb_flush_log_at_trx_commit` 值得临时调:导入期间的写入是批量单事务,把 flush 设为 2 可以让导入快好几倍,导入完再改回来。这个取舍只在恢复窗口内成立,别写进 my.cnf 的默认值。
七、把校验接进监控:不通过就告警
所有校验最终要落到「有人知道」这一步。最省事的做法是把三层校验写进一个脚本,失败就退出非零,再让 cron 的 MAILTO 或者一个简单的 curl 通知把它送出去:
#!/bin/bash
set -euo pipefail
D=$(date +%F)
F=/backup/zz1984-$D.sql.gz
[ -f "$F" ] || { echo "BACKUP MISSING $D"; exit 1; }
[ "$(stat -c %s "$F")" -gt 102400 ] || { echo "BACKUP TOO SMALL $D"; exit 1; }
gzip -dc "$F" | tail -n 3 | grep -q 'Dump completed' \
|| { echo "BACKUP INCOMPLETE $D"; exit 1; }
# 最近一份演练记录不能超过 10 天
find /backup/.restore-ok-* -mtime +10 | grep -q . \
&& { echo "RESTORE DRILL STALE"; exit 1; }
echo "OK $D"最后一段是很多人漏掉的:校验脚本自己也要被校验。如果恢复演练停了三周,前两层校验依然天天返回 OK,人会误以为一切正常。用演练记录的时间戳做一条断言,才能保证「演练还在进行」这件事本身有监控。
八、常见误区小结
「有备份」不等于「能恢复」:前者是文件,后者是流程。恢复时间(RTO)和可接受的数据丢失量(RPO)要在事前定下来,然后再倒推备份频率和校验强度。
备份放在同一台机器上等于没备份:磁盘故障、rm -rf 事故、勒索软件都会同时干掉数据和备份。至少要有一份异地副本,用 rsync 或者对象存储同步过去,并且异地那份也要参与校验,不能只管本地。
别把 binlog 当成备份:binlog 用于时间点恢复,但它的前提是有一份基础全备。没有全备的 binlog 只能在原地重放,磁盘坏了就一起没了。
清理策略要写清楚:保留最近 7 天日备、4 周周备、6 个月月备是常见配置,但别让清理脚本把「唯一完整的那份」删掉。最简单的保护是:永远保留最近 3 份通过校验的备份,不管它们多旧。
回到最开始那句话:备份不是一项任务,是一个闭环。跑完 dump 只是闭环的第一步,标记、自检、演练、告警这四步补齐之后,才能在真的出事那天平静地打开恢复脚本,而不是对着一个 0 字节的文件发呆。