备份可用性校验:从原子写入到对账

一、为什么备份最怕的不是「没备份」,而是「备份不知道坏没坏」

做站久了都会遇到一个阶段:备份脚本写好了,crontab 也配上了,每天凌晨三点磁盘上多出一个几十兆的压缩包,运维心里就踏实了。这个踏实是假的,因为「有文件」和「能恢复」之间隔着至少四道关卡,而绝大部分人的备份从来没走完这四道。

第一道是传输是否完整:文件写到一半网络断了、磁盘满了、进程被 OOM 杀了,都会留下一个看起来正常但内容截断的包。tar、mysqldump 在写出的过程中被中断,退出码会是非零,但如果脚本没检查退出码(或者被 || true 吞掉),你会发现那个包小了几百兆,而它被当成「今天的备份」。

第二道是内容是否一致:数据库在做逻辑备份时业务还在写入,如果没加一致性参数,导出的 SQL 里表 A 是十点零一分的快照、表 B 是十点零三分的,恢复之后外键指向不存在的行、计数器算不平。这种包能解开,也能导入,但它不是一个时间点的真实状态。

第三道是能否真正还原:导入到另一台机器,可能因为字符集、时区、sql_mode、存储引擎版本差异而失败;可能成功了但某些表是空的;可能全部成功但站点打开是空白页,因为附件目录没跟着恢复。

第四道是是否有证据:以上都对,但没有任何一份记录能证明「上周三凌晨那次备份被完整地解压过一次并且通过了校验」。等到真的需要时,你面对的是一堆没有信誉度的文件。

这篇文章讲的就是把第四道关卡补上:不是讲怎么设计备份策略(那是另一件事),而是讲怎么让「备份是好的」这件事变成一个有数字、有日志、能告警的事实。核心是三件互相补位的东西:写入时的原子性、定期的完整性校验、以及独立于备份机的健康记录。

二、第一层防线:让坏备份不可能「伪装成好备份」

如果备份脚本能保证「只要是那个文件名,内容就一定完整」,后面所有校验的压力都会小很多。实现这一点的核心是原子写入 + 退出码检查 + 大小突变告警。

原子写入的做法是:先写到临时文件,校验通过后再 mv 到最终文件名。mv 在同一文件系统内是原子操作,所以「最终文件名存在」这个事实本身就等价于「写入过程完整结束」。这样一来,下游任何程序(包括监控)只要看到最终文件,就可以放心认为它不是半截货。

#!/bin/bash
# /usr/local/bin/db-backup.sh —— 原子写入 + 退出码检查
set -Eeuo pipefail
trap 'echo "FAILED at line $LINENO" >> /var/log/backup.log' ERR

DEST=/srv/backup/db
STAMP=$(date +%F_%H%M)
FINAL="$DEST/blog-$STAMP.sql.gz"
TMP="$FINAL.part"

mkdir -p "$DEST"
# --single-transaction 保证 InnoDB 一致性快照;--quick 避免全表读进内存
mysqldump --single-transaction --quick --routines --triggers \
          --default-character-set=utf8mb4 blog | gzip -9 > "$TMP"

# 退出码已经在 set -e 下自动检查,这里再补一道内容层面的粗检
gzip -t "$TMP"                       # 压缩流完整性
[ "$(stat -c %s "$TMP")" -gt 1048576 ] || { echo "too small" >&2; exit 3; }

# 关键:通过所有检查后才变成「正式文件」
mv "$TMP" "$FINAL"
echo "$(date -Is) OK $FINAL $(stat -c %s "$FINAL")" >> /var/log/backup.log

这里有两个细节值得说:

  • gzip -t 会完整解压一遍 CRC 校验,但不会输出内容,是判断「压缩流有没有截断」最便宜的方法。对几 GB 的包它会花掉几秒到十几秒,完全可以接受。
  • set -Eeuo pipefail 里的 pipefail 很关键:上面是管道 mysqldump | gzip,如果不加 pipefail,mysqldump 失败而 gzip 正常结束时,整条管道的退出码是 0,你会得到一个完整的、装了一半数据的压缩包。这个坑极其常见。

第三件事是大小突变告警。备份文件的体积在一个稳定业务里是非常稳定的:正负 10% 以内。如果今天比昨天小了 40%,几乎一定有问题(表被误删、导出参数被改、某个库没导出来)。如果大了 3 倍,也值得看一眼(有人灌了测试数据,或者 binlog 被误当成数据)。这个判断不需要任何外部系统,只要拿今天和昨天的文件大小比一下。

#!/bin/bash
# 与上一个备份比较体积
set -Eeuo pipefail
DEST=/srv/backup/db
latest=$(ls -1t "$DEST"/blog-*.sql.gz | head -1)
prev=$(ls -1t "$DEST"/blog-*.sql.gz | sed -n 2p)
l=$(stat -c %s "$latest"); p=$(stat -c %s "$prev")
awk -v l="$l" -v p="$p" -v f="$latest" 'BEGIN{
  r=(l-p)/p*100;
  printf "%s size=%d prev=%d diff=%.1f%%\n", f, l, p, r;
  if (r < -20 || r > 100) { print "SIZE ANOMALY"; exit 1 }
}'

这三件事做完,「坏备份伪装成好备份」的概率会下降一个数量级。但注意:它只能排除「写入环节的坏」,排除不了「内容本身有问题的坏」。一个导出工具参数写错的脚本,每次都产出百分之百一致、大小稳定的错误备份。所以还需要第二层。

三、第二层防线:定期做「解压 + 导入 + 对账」三步校验

只做 gzip -t 是不够的:它只验证压缩流没坏,不验证里面的 SQL 能不能执行、数据是不是那个业务库。真正的校验必须把包解开并灌进一个数据库。

最省钱的做法不是每次都灌进生产库,而是灌进一个独立的校验实例。为了不干扰生产,我在同一台机器上跑一个只用 3307 端口、独立 datadir、独立 socket 的 MySQL 实例,内存限制在 256M(用 systemd 的 MemoryMax),只用来验证备份。这个实例平时不开,校验脚本启动它、导入、核对、然后关掉。

#!/bin/bash
# /usr/local/bin/backup-verify.sh —— 每周日跑一次
set -Eeuo pipefail
FILE=${1:-$(ls -1t /srv/backup/db/blog-*.sql.gz | head -1)}
SB=3307
SOCK=/tmp/mysql-verify.sock
DATA=/srv/verify-data

start_instance() {
  mysqld --no-defaults --datadir="$DATA" --port=$SB --socket="$SOCK" \
         --skip-networking=0 --innodb-buffer-pool-size=128M \
         --log-error=/tmp/verify-mysqld.err --daemonize
  for i in $(seq 30); do
    mysqladmin --socket="$SOCK" ping >/dev/null 2&>1 && return 0
    sleep 1
  done
  echo "instance not up" >&2; return 1
}

start_instance
mysql --socket="$SOCK" -e "DROP DATABASE IF EXISTS verify; CREATE DATABASE verify CHARACTER SET utf8mb4"
# 关键:导入失败的退出码必须冒出来(mysql 对部分错误不返回非零,故用 --force 关闭)
mysql --socket="$SOCK" --default-character-set=utf8mb4 verify < <(gunzip -c "$FILE")

这里有个大坑:mysql 客户端在遇到某些错误(比如单个 INSERT 失败)时不会返回非零退出码。如果只依赖退出码,你会以为导入成功了。稳妥做法有两种:一是导入后主动对账(下面马上讲),二是导入时加 --show-warnings 并把 stderr 收集起来,用关键字扫描(ERROR)判失败。前者的覆盖率更高,因为它直接回答「数据在不在」。

对账是校验的核心,「导入成功」只是及格线。对账要挑三类指标:行数、最大 ID、以及一个业务相关的聚合值。

# 对账:备份实例 vs 线上
mysql --socket="$SOCK" -N -e "
  SELECT 'posts', COUNT(*), MAX(cid) FROM verify.typecho_contents WHERE type='post' AND status='publish';
  SELECT 'comments', COUNT(*) FROM verify.typecho_comments;
  SELECT 'options', COUNT(*) FROM verify.typecho_options;
  SELECT 'hop_checksum', SUM(CRC32(CONCAT_WS('|',cid,title,LEFT(text,200)))) FROM verify.typecho_contents;
" > /tmp/verify-from-backup.txt

mysql -N -e "
  SELECT 'posts', COUNT(*), MAX(cid) FROM blog.typecho_contents WHERE type='post' AND status='publish';
  SELECT 'comments', COUNT(*) FROM blog.typecho_comments;
  SELECT 'options', COUNT(*) FROM blog.typecho_options;
  SELECT 'hop_checksum', SUM(CRC32(CONCAT_WS('|',cid,title,LEFT(text,200)))) FROM blog.typecho_contents;
" > /tmp/verify-from-live.txt

diff /tmp/verify-from-live.txt /tmp/verify-from-backup.txt || echo "MISMATCH"

行数反映了「有没有少表、少行」;最大 ID 反映了「是不是很早以前的快照」(备份选错了文件);而那行 CRC32 校验和反映了「内容是不是同一份」。为什么要算内容校验和而不只算行数?因为行数完全一样但内容不同的情况真实存在:比如一次误操作把某张表清空后又重新插入了同样行数的错误数据。校验和能把这种问题抓出来。

注意这个对账在备份刚做完时跑才有意义(此刻线上和备份应该几乎一致)。如果备份是昨天凌晨的,而今天写了几十篇文章,行数必然不同。所以采用的策略是:每周做一次「做完备份立刻校验」的联动任务,让对账时刻的差异可控。

四、第三层防线:独立于备份机的健康记录

前面的校验跑在备份机上,这带来一个隐藏问题:如果备份机本身出问题(磁盘坏、机器被回收、域名解析变了),校验也会停,而你收不到任何信号——因为校验和备份一起消失了。这一层要靠「外部视角」解决。

最轻量的外部视角是一个 Heartbeat 式的被动告警:备份脚本成功时往一个第三方端点发一个 HTTP 请求(很多免费服务支持,也可以用自己的另一台机器),端点如果在一定周期内没收到请求,就给你发邮件。这样「备份机整体消失」也会变成告警,而不是沉默。

# 备份成功后 ping 一次外部端点(URL 换成自己的)
curl -fsS -m 10 --retry 2 "https://hc-ping.com/your-uuid-here" >/dev/null || \
  echo "$(date -Is) heartbeat failed" >> /var/log/backup.log

如果没有外部服务,退一步的做法是让备份机往一个不在这台机器上的位置写状态文件:比如把最近一次成功的日期写进 DNS 的 TXT 记录、写进对象存储的一个小文件、或者推到 GitHub 的一个私有仓库。然后由你自己(或另一台机器上的脚本)定期读它。关键判据是:这个记录必须不依赖备份机存活。

第三层的另一件事是区分「备份成功」和「备份有效」。很多团队的监控只告「备份脚本以 0 退出」,这只能证明脚本跑完了。真正有效的指标是三个:

  • 新鲜度:最近一次成功备份距今多少小时。阈值按 RPO 定,日备份的站建议 30 小时告警。
  • 验证新鲜度:最近一次通过「解压 + 导入 + 对账」的校验距今多少天。建议 10 天告警。
  • 异地副本新鲜度:最近一次成功同步到异地(对象存储或另一台机器)距今多少小时。
# 三个新鲜度一次性算出来
now=$(date +%s)
f_last=$(date -r "$(ls -1t /srv/backup/db/blog-*.sql.gz | head -1)" +%s)
v_last=$(date -r /srv/backup/.last_verify_ok +%s 2>/dev/null || echo 0)
r_last=$(date -r /srv/backup/.last_remote_ok +%s 2>/dev/null || echo 0)
printf "backup %.1fh  verify %.1fd  remote %.1fh\n" \
  "$(awk -v a=$((now-f_last)) 'BEGIN{print a/3600}')" \
  "$(awk -v a=$((now-v_last)) 'BEGIN{print a/86400}')" \
  "$(awk -v a=$((now-r_last)) 'BEGIN{print a/3600}')"

三个数字里最容易被忽略的是「验证新鲜度」。备份天天成功、校验三个月没跑过,是一种非常舒适的假安全感。把验证也做成一个 touch 出来的时间戳文件,就能纳入监控,成本几乎为零。

五、异地副本:为什么本地备份不算备份

前面所有讨论都默认「备份文件还在」。而真实的事故里最常见的一种是:机器本身没了——服务商封停、硬盘故障、误操作 rm -rf、勒索软件加密了整个目录。这些情况下,和生产数据放在同一台机器或同一个服务商面板里的备份,会一起消失。

所以异地副本是独立的一层,判断它是否成立的标准只有一条:生产机完全不可用时,你能否从这个副本恢复全部数据。常见但不够的做法包括:同一块云厂商的对象存储(账号被封会一起丢)、同一台机器的另一个分区(盘坏了一起坏)、同城另一台机器但用同一个服务商面板(面板被停全会丢)。真正合格的异地要满足「不同的服务商或不同的物理地点」至少一项。

实现上,小站最省事的组合是:本地保留最近 7 天的完整备份(恢复快),异地保留按周打包的副本(成本低)。用 rclone 推到对象存储,加密方式有两种选择:rclone 自带的 crypt 远程(透明加密,恢复时必须用相同配置),或先用 GPG 加密再上传(更独立,但要自己管密钥)。

# GPG 对称加密后再上传(密钥存在密码管理器里,不放在这台机器上)
set -Eeuo pipefail
gpg --batch --yes --symmetric --cipher-algo AES256 \
    --passphrase-file /root/.backup-pass --output /tmp/blog.sql.gz.gpg \
    /srv/backup/db/blog-$(date +%F).sql.gz
rclone copyto /tmp/blog.sql.gz.gpg remote:my-backups/db/blog-$(date +%F).sql.gz.gpg
rm -f /tmp/blog.sql.gz.gpg
touch /srv/backup/.last_remote_ok

一个必须提前想清楚的问题:如果这台机器和你的笔记本同时没了,密钥在哪?密码文件放在同一台机器上,等于没有加密。稳妥的做法是把 passphrase 抄进一个离线的密码管理器,并且每年演练一次「只拿对象存储里的文件能不能解开」。这一步绝大多数人永远不做,直到需要的时候才发现密钥丢了。

六、完整的备份日历:日、周、月、季各做什么

把上面三层拼起来,落成一个可执行的节奏。每一项都不重,加起来每周不到半小时:

  • 每天:数据库增量/全量备份(原子写入 + 大小突变检查)、附件增量同步到异地。自动,无人工。
  • 每周:一次「备份完立刻校验」——解压、导入旁路实例、三项对账、写 .last_verify_ok。脚本自动跑,人看一眼结果。
  • 每月:人工抽一次最新备份,在本地完全按「只有这个文件」的条件做一次恢复演练(不借助任何线上环境)。同时确认密钥可用。
  • 每季度:全流程演练一次——假设生产机彻底不可用,从异地副本开始,重建到网站能打开。这一步会把「文档缺失」「权限不对」「附件路径不对」这类问题全部翻出来。

保留策略建议:日备份留 7 到 14 份,周备份留 8 周,月备份留 12 个月。关键是清理脚本必须只按日期匹配,绝不能用 ls -t | tail -n +15 | xargs rm 这种写法——一旦目录里混进别的文件(比如一个手工复制的验证副本),这条命令会删错东西。

# 只删 14 天前、且严格匹配命名模式的文件
find /srv/backup/db -maxdepth 1 -type f -name 'blog-*.sql.gz' -mtime +14 -print -delete

七、四个实战教训

教训一:退出码为 0 不代表备份可用,但退出码非 0 一定是坏的。前者需要校验补上,后者必须当成硬失败(不要 || true,不要「反正明天还会跑」)。我见过一个脚本把 mysqldump 的错误输出重定向到了 /dev/null,数据库密码改了之后,它连续产出了 40 天空文件,每天"成功"。

教训二:文件大小稳定不等于内容正确。有个站的备份每天都恰好 12MB,看起来很健康,某次恢复时发现里面少了三张核心表——因为 --databases 后的库名列表被手工编辑时删掉了三个。行数对账能立刻发现这种问题,而只看大小完全发现不了。

教训三:校验环境必须与线上尽量一致。字符集(utf8mb4 vs utf8)、lower_case_table_names、存储引擎版本差异,都会让「线上能用、校验环境导入失败」或反过来(校验通过、线上失败)。校验实例的配置要和线上同源,这一点比多跑几次校验更重要。

教训四:不要在同一时间里做备份和校验。校验导入会吃掉大量 IO 和内存,如果在业务高峰跑,会把网站拖慢。把校验放在凌晨备份完成后立即跑,或者给校验实例设 cgroup 的 io.max 限制。

八、小结

备份的可信度不是靠感觉,是靠三个可执行的事实:写入阶段用原子写入和退出码检查,让坏备份无法伪装;验证阶段用「解压 + 导入 + 对账」证明内容真的能还原;记录阶段用外部心跳和三个新鲜度指标,让「没人知道它坏了」这件事无法发生。三层里任何一层单独做都只是心理安慰,三层加起来才构成一个可以说「我们的备份是好的」的资格。

最后一句实在话:这套东西的价值不在于「用得上」,而在于让你在深夜接到电话时,能在一分钟内回答「我上次验证备份是什么时候」这个问题——而不是先打开终端,去翻三个月前的日志。

Last modification:November 5th, 2026 at 08:28 pm

Leave a Comment