备份这件事最尴尬的地方在于:它在做对的时候完全没有存在感,只在做错的时候才被想起来。而「做错」往往不是备份命令执行失败,恰恰是命令全部返回 0、日志里一片 green,等到真要恢复的时候才发现那个文件从三周前就再没变过。
这篇讲的是备份的校验,但不是讲「记得看一眼备份文件在不在」这种常识,而是讲怎样让校验本身变成一个能在凌晨三点自动报警、并且报得准的机制。核心就一句话:文件存在不等于内容完整,内容完整不等于能恢复。三层各有各的校验手段。
一、第一层:文件真的被写完了吗
最常见的静默失败是「文件生成了,但只有一半」。mysqldump 管道接 gzip 的场景尤其危险:mysqldump 中途因为连接断开、锁等待超时或者磁盘写满而退出,gzip 会忠实地把已经收到的部分压缩完成并正常退出,所以管道里最后一个进程的退出码是 0。这种情况下,备份文件看起来体积正常,gzip -t 也可能通过,但数据是截断的。
三个检查可以挡住这一类问题:
一是检查 mysqldump 的完成标记。mysqldump 结束时会在输出末尾写一行 -- Dump completed on 2026-10-26 03:00:12。用 grep 检查这一行是否存在,是判断文件是否完整最便宜的手段。注意不要用管道里的退出码,改用 pipefail 或者干脆分两步执行:先 dump 到临时文件,确认标记之后再压缩。
二是检查压缩包完整性。对于 gzip,gzip -t 会解压一遍验证 CRC;对于 zstd,zstd -t;对于 xz,xz -t。这一步能抓到压缩过程中的位翻转或者写入中断,但抓不到「源数据本身就不全」。
三是检查体积是否在预期范围内。这是最粗糙但最有效的一条。备份文件的体积通常在一个稳定区间内波动,如果今天的文件只有昨天的 20%,几乎一定是出了问题。反过来,如果三天内体积几乎完全一致,也要注意,有可能是内容根本没更新。这里要避免按绝对大小判断,因为业务数据在增长,合理的做法是跟过去 7 天的中位数比,偏离超过三成就报。
DUMP_TS=$(date +%Y%m%d)
mysqldump --single-transaction --quick --routines --triggers \
--databases zz1984 > /tmp/dump_${DUMP_TS}.sql 2>/tmp/dump_${DUMP_TS}.err
if ! grep -q "Dump completed" /tmp/dump_${DUMP_TS}.sql; then
echo "BACKUP INCOMPLETE: missing completion marker" >&2
exit 1
fi这段脚本里没有 set -e,是故意的。grep 没有匹配时返回码是 1,如果开了 set -e,脚本会在这一行直接静默退出,你只看到备份没生成,却看不到任何错误信息。这是很多人写巡检脚本时反复踩的坑:以为 set -e 是保险,实际上它把诊断信息一起吃掉了。
二、第二层:checksum 与内容指纹
文件能解开、体积也正常,还能怎样更严格地验证内容?答案是给备份算一个内容指纹,并且把指纹存到与备份分离的地方。
最直接的是 SHA256。备份完成后计算一次,写进一个单独的清单文件,同时把清单推送到另一台机器或者对象存储的另一个前缀下。这样即使备份文件被人静默替换、或者存储服务端出了问题,只要清单还在,比对就能发现。这里的关键是指纹和文件不能放在同一个位置:如果攻击者或者故障能改文件,通常也能改指纹。
sha256sum dump_${DUMP_TS}.sql.gz >> /var/lib/backup/checksums.txt
# 恢复演练或定期校验时
sha256sum -c /var/lib/backup/checksums.txt --quiet || echo "CHECKSUM MISMATCH"对于单机小站,一个更实用的做法是把指纹写进 Cron 每天的日报里。看起来麻烦,实际上是一次性成本:出问题时,你不需要去追「上周那个文件到底是什么时候坏的」,因为每天的指纹都在邮件里躺着。
还有一个常被忽略的场景:增量备份链的校验。用 restic、borg 这类工具时,单个快照本身是完整的,但它依赖仓库里的其他数据块。如果仓库某个对象被删了或者损坏,单个快照的元数据依然看起来正常。restic check 会校验仓库结构,加上 --read-data-subset=5% 会随机会读一部分数据膨胀块做校验。建议每周跑一次小比例抽查,每月跑一次更大比例,而不是等出事了再跑。
三、第三层:恢复验证才是真校验
前两层验的都是「文件层面」,而备份的目的只有一个:能恢复。所以最有效的一次校验是真的恢复一次,哪怕只恢复一部分。
这件事在小站上最容易流于形式:脚本能跑通就算通过。但真实的坑往往在这一步才暴露。我见过三种典型:
权限与属主丢失。用 mysqldump 导出的 SQL 恢复之后,表属主和触发器定义可能与原库不一致。恢复脚本里如果不显式 SET NAMES utf8mb4,含 emoji 的评论内容会在导入时提示字符串过长或者直接乱码,而导入退出码仍然是 0。
依赖对象缺失。数据库备份和文件备份是两条独立的链路。恢复数据库之后,文章里的图片链接指向的文件目录如果没有一并恢复,站看起来是好的,图片全是 404。校验时要有一个「页面级」的探针:恢复后开一个临时站点,随机抓 20 个页面,检查返回码和是否包含关键元素,这比检查表行数更贴近真相。
时间窗口没算过。很多人从没量过「从零恢复需要多久」。一次恢复可能涉及导入 2GB 的 SQL、拷贝 30GB 的附件、重建索引,一跑就是四小时。如果业务的容忍窗口是两小时,那么这套备份在真实故障中是「不可用」的。把恢复耗时记下来,跟业务容忍度对比,是备份体系里最容易被跳过、也最有价值的一个数字。
四、把校验脚本写对:五个具体细节
校验脚本本身也有工程质量问题,这里列出我踩过的五个:
不要用 set -e。理由前面说过,巡检脚本里 grep、diff、cmp 都有「不匹配即返回非零」的正常语义,set -e 会让脚本在第一个不匹配处静默退出。需要严格退出的地方显式写 || exit 1。
用 df -Pk 而不是 df -h。df 对人友好的输出会折行,长设备名会把一行拆成两行,导致 awk 取列取到错误字段,脚本算出「可用空间 0」却没人发现是解析错了。-P 保证一行一个文件系统,-k 输出统一单位。
考虑已轮转的文件。日志和某些备份目录在检查时已经被 logrotate 改名成 .1 或者 .gz,如果脚本只看当前文件名,会安静地少读一批数据。日期的判断要用文件名里的日期而不是 mtime,因为 rsync、tar 解包都会重置 mtime。
留档。每天把校验结果追加到一行 CSV:日期、文件大小、指纹前八位、耗时、结果。三个月之后这行数据能告诉你备份时间的漂移、体积的增长速度,这些都比告警本身更有价值。
报警信息要带上下文。「备份失败」这条告警没有任何用。要写成「dump_20261026.sql 缺失 Dump completed 标记,大小 1.2MB(近七日中位数 340MB),参见 /var/log/backup/20261026.err」。看到这种信息,不用登录服务器就知道该做什么。
五、验证频率的建议
全量恢复演练不可能天天做,但完全不做又等于没有备份。我的建议是按周、月、季三级安排:
每周:跑一次文件层校验(完整性标记 + 体积异常 + 指纹比对),耗时几分钟,可以放在每天的低峰时段自动跑。
每月:把最近一次备份恢复到临时库,跑一遍关键表行数对比与页面探针,记录耗时。这一步大概半小时,但能发现 90% 的「三周前就坏了」的问题。
每季度:做一次「假设机器整台消失」的完整恢复演练。从空机开始,装环境、拉备份、恢复数据、验证站点。这听起来夸张,但它是唯一能验证「备份之外的东西」(配置、证书、cron、依赖版本)都齐全的方式。很多次崩溃的复盘都指向同一个结论:数据恢复了,但站点跑不起来,因为忘了备份某一项配置。
校验这件事的难点从来不在技术,而在持续。所以最优的设计是让它在没人管的情况下也能跑、能报、能留痕。文件层校验自动跑在半夜,结果汇总到日报里;恢复演练写进月历提醒;季度演练当作一次例行维护。当这三件事变成流程的一部分,备份才从「一个命令」变成「一套能力」。