凌晨两点机器起不来,备份目录里躺着一个三天前的压缩包,大小正常。解压、导入、起服务,站点回来了——然后发现订单表少了两万行。当初只 dump 了业务前缀那几张表,插件自己建的表在库的另一头,没人记得。
这类事故的共同点不是没备份,是没人验证过备份能恢复成什么。下面按「备什么 → 多久一次 → 怎么证明能恢复」写,脚本可以直接跑。
这两个数字定了,频率和预算才有依据,不然「每天备份一次」只是习惯。
RTO 只能靠演练测出来,不能拍脑袋。第一次认真做一次从零恢复,绝大多数团队测出来的时间是自己估计的两三倍——找密钥、找配置文件、对数据库版本,都在这段时间里。
按内容分类没意义(代码、数据库、图片……),按「丢了之后能不能重新造出来」分才有意义。
级别 | 判断标准 | 典型内容 | 频率 | 保留 |
|---|---|---|---|---|
A | 丢了不可重建 | 数据库、用户上传文件、证书私钥、第三方服务的密钥 | 按写入量,通常每日或更密 | 日 14 / 周 8 / 月 6 |
B | 可重建但费时 | 应用代码、nginx 与 php 配置、systemd unit、cron、环境变量文件 | 每次变更(放 Git,另外随包带一份) | 永久 |
C | 重新装就有 | 依赖包、缓存、日志、临时文件 | 不备份 | — |
落到一台典型的 Web 服务器上,A 级和 B 级具体是这些东西:
wp-content/uploads、storage/app,看框架)/etc/nginx、/etc/php*、/etc/systemd/system/*.service、/etc/cron.*、root 的 crontab/etc/letsencrypt 整目录,含 renewal 配置).env、wp-config.php 里的盐值和数据库口令docker volume 里的数据容易漏的几处:数据库里的存储过程没有被 --routines 带上;上传目录被 .gitignore 排除,谁也没单独备;cron 里跑着的那条对账脚本只存在 crontab 里;证书是 certbot 自动续的,私钥丢了重签要等 DNS 生效。
一个合格备份包的产物是五样东西,缺一样都会在恢复时卡住:数据文件、文件归档、清单(这次备份是什么环境做的)、行数记录、校验和。清单是关键,恢复的人往往不是备份的人。
#!/usr/bin/env bash
# /usr/local/bin/backup.sh
set -euo pipefail
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
WORK="/var/backups/work/$STAMP"
DEST="${DEST:-/srv/backup}"
DB_NAME="${DB_NAME:-app}"
GPG_RECIPIENT="${GPG_RECIPIENT:?需要设置 GPG 收件人}"
CNF=/etc/backup.cnf # [client] user=backup password=... ,chmod 600
mkdir -p "$WORK"
trap 'rm -rf "$WORK"' EXIT
# 1. 数据库:InnoDB 用 --single-transaction 拿一致性快照,不锁业务表
mysqldump --defaults-extra-file="$CNF" \
--single-transaction --quick --routines --triggers --events \
--default-character-set=utf8mb4 \
"$DB_NAME" | gzip -9 > "$WORK/db.sql.gz"
# 2. 文件:--numeric-ids 保住属主,--acls --xattrs 保住扩展属性
tar --numeric-ids --acls --xattrs -czf "$WORK/files.tar.gz" -C / \
var/www/app \
etc/nginx \
etc/php \
etc/letsencrypt \
etc/systemd/system/app.service
# 3. 清单:恢复的人要知道这份包是什么环境产出的
{
echo "stamp=$STAMP"
echo "db=$DB_NAME"
mysql --defaults-extra-file="$CNF" -N -B -e "SELECT VERSION()"
uname -srm
docker version --format 'server={{.Server.Version}}' 2>/dev/null || true
} > "$WORK/MANIFEST.txt"
# 4. 备份那一刻的行数,恢复后拿它对
mysql --defaults-extra-file="$CNF" -N -B -e \
"SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA='$DB_NAME' AND TABLE_TYPE='BASE TABLE'" |
while read -r t; do
n=$(mysql --defaults-extra-file="$CNF" -N -B -e "SELECT COUNT(*) FROM \`$DB_NAME\`.\`$t\`")
printf '%s\t%s\n' "$t" "$n"
done | sort > "$WORK/rows.tsv"
# 5. 关键表再留一份校验和
for t in orders payments users; do
mysql --defaults-extra-file="$CNF" -N -B -e "CHECKSUM TABLE \`$DB_NAME\`.\`$t\`"
done > "$WORK/checksums.tsv"
# 6. 先校验,再加密
cd "$WORK"
sha256sum db.sql.gz files.tar.gz MANIFEST.txt rows.tsv checksums.tsv > SHA256SUMS
# 7. 公钥加密后落到异地
tar -C "$WORK" -cf - . |
gpg --batch --yes --quiet --encrypt --trust-model always -r "$GPG_RECIPIENT" \
> "$DEST/$STAMP.tar.gpg"
# 8. 体积突变告警:比历史中位数小 40% 通常是漏了数据(如某个目录挂载掉了)
SIZE=$(stat -c %s "$DEST/$STAMP.tar.gpg")
MEDIAN=$(find "$DEST" -name '*.tar.gpg' ! -name "$STAMP.tar.gpg" -printf '%s\n' |
sort -n | awk '{a[NR]=$1} END{if(NR) print a[int((NR+1)/2)]}')
if [ -n "${MEDIAN:-}" ] && [ "$SIZE" -lt $((MEDIAN * 6 / 10)) ]; then
echo "备份体积异常:$SIZE 字节,历史中位数 $MEDIAN" >&2
exit 2
fi两个提醒。mysqldump 的一致性只覆盖 InnoDB;库里还有 MyISAM 表就得加 --lock-tables,或者接受短暂锁写。文件和数据不在同一个时间点上——上传目录多几个文件没关系,少文件才有事,写入频繁的站点用文件系统快照,或者备份前把站点切到只读几十秒。
只验证到服务能起来,等于什么都没验证。恢复脚本要把包解开、导入临时库、拿数据跟备份时的记录比。绝不碰生产库。
#!/usr/bin/env bash
# /usr/local/bin/restore-verify.sh <备份包>
set -euo pipefail
ARCHIVE=$1
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# 1. 传输完整性
gpg --batch --quiet --decrypt "$ARCHIVE" | tar -C "$TMP" -xf -
cd "$TMP" && sha256sum -c SHA256SUMS
# 2. 导进临时库,DISTINCT 一个独立库名
mysql -e 'DROP DATABASE IF EXISTS verify_db'
mysql -e 'CREATE DATABASE verify_db CHARACTER SET utf8mb4'
gunzip -c db.sql.gz | mysql --default-character-set=utf8mb4 verify_db
# 3. 行数比对(对的是备份当时记下的 rows.tsv,不是现在的生产库)
mysql -N -B -e "SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA='verify_db' AND TABLE_TYPE='BASE TABLE'" |
while read -r t; do
n=$(mysql -N -B -e "SELECT COUNT(*) FROM \`verify_db\`.\`$t\`")
printf '%s\t%s\n' "$t" "$n"
done | sort > rows_restore.tsv
diff -u rows.tsv rows_restore.tsv && echo "行数一致"
# 4. 关键表校验和
cut -f1 checksums.tsv | while read -r t; do
mysql -N -B -e "CHECKSUM TABLE \`verify_db\`.\`$t\`" | cut -f2
done > ck_restore.txt
cut -f2 checksums.tsv | diff -u - ck_restore.txt && echo "校验和一致"两个坑。CHECKSUM TABLE 在 InnoDB 上是全表扫描,大表别在业务高峰跑;跨大版本恢复时它可能对不上,那时候退回「行数 + 抽样」就行,不用为了对齐校验和去改数据。
行数和校验和都对上,还差一步抽样:拿几条业务上认得的记录,在生产库和还原库里各查一遍金额、状态、时间。
-- 两边各跑一次,数字必须一样
SELECT COUNT(*), SUM(total_amount), MAX(created_at)
FROM orders WHERE created_at >= '2026-10-01';
-- 抽一条具体订单,人工看一眼
SELECT id, status, total_amount, created_at FROM orders WHERE id = 102411;最后是把应用接上去:改 hosts 指向临时实例,走一遍登录、下单、图片加载、邮件发送。这几步能过,才算「能恢复」。把整个过程的耗时记下来,那就是你真实的 RTO。
没有演练的备份等于没有备份,但也不用每周全量演练。分级来:
RESTORE.md,让没参与建设的人恢复一遍。写不明白的地方就是文档的漏洞。# /etc/cron.d/restore-drill
0 4 * * 0 root /usr/local/bin/restore-verify.sh "$(ls -1t /srv/backup/*.tar.gpg | head -1)" >> /var/log/restore-drill.log 2>&1比「备份失败」更容易出事的是「备份根本没跑」。cron 里任务被注释掉、磁盘满了、密码过期,日志都安安静静。加一条新鲜度检查:
#!/usr/bin/env bash
# /usr/local/bin/check-backup-freshness.sh
set -euo pipefail
NEWEST=$(find /srv/backup -name '*.tar.gpg' -printf '%T@\n' | sort -n | tail -1)
AGE=$(( $(date +%s) - ${NEWEST%.*} ))
[ "$AGE" -lt 93600 ] || { echo "最新备份已是 $((AGE/3600)) 小时前" >&2; exit 1; }告警走 systemd 比 cron 邮件可靠:给备份服务写一个 OnFailure=alert@%n.service,失败和超时都会触发;cron 任务则包一层 || curl -fsS <告警地址>。关键是告警要有人认领,发到一个没人看的频道等于没告警。
恢复和迁移是两件事:恢复到同版本同架构,坑少一半;换机器、换系统、换发行版,下面这些一定会遇到。
[ -d /sys/firmware/efi ] && echo uefi,跟源机器不一致就得先修引导再谈数据。系统起不来时先看引导和分区表,不要急着重装。fstab 里的 UUID。磁盘换了 UUID 就变,blkid 重新对一遍,否则开机直接进 emergency mode。rsync -aHAX --numeric-ids 或 tar --numeric-ids,别用普通 cp -r,uid 全变成当前用户,PHP 写上传目录直接 permission denied。docker run --rm -v app_uploads:/v -v /srv/backup:/out alpine \
tar -C /v -czf /out/uploads.tar.gz .
# 恢复后比对属主,容器里的 www-data 在宿主上可能没有同名用户
docker run --rm -v app_uploads:/v alpine ls -ln /v | headLogo.PNG 和 logo.png 会打架,图片 404 往往出在这。sql_mode、排序规则、时区不一致,会以「查询结果莫名其妙」的形式出现。eth0 变 ens18,静态 IP 和防火墙规则一起失效。跨品牌换手机、换系统那类迁移,能带走的东西本来就有限:应用数据靠应用自己的导出,系统级的配置、授权、缓存基本带不走。与其指望一键搬迁,不如先确认「哪些数据有官方导出入口」,把能导的先导出来——这跟服务器备份是同一个道理,先分清 A 级和 C 级。
WordPress 站尤其容易在这里吃亏:插件自己建的表不在 wp_ 前缀下,wp-config.php 里的盐值没人单独存,上传目录被排除在代码仓库外。接手一个别人搭的站,验收动作是在临时库上把恢复验证完整跑一遍,别只看文件在不在。清单可以按这几项对:全库 dump(含所有前缀的表)、uploads、证书私钥、cron、DNS zone 导出、MANIFEST.txt、RESTORE.md。合同里写一句「交付时提供一份可恢复的备份包和恢复步骤文档」,比事后找人问便宜得多。
如果这个站还没开始做,一个可选的做法是把搭建交给外面:AumCreate 的模板代部署是 $399,客户给 logo、公司介绍文字、产品图、产品表和主机登录信息,他们把模板装到客户自己的主机上、每一页换成客户的内容,材料收齐后 5 个工作日上线,先给预览链接、含一轮修改;域名和主机由客户自己付给第三方,不收年费。这套交付里不含备份脚本和恢复演练,那部分还是按上面的清单自己补,签之前可以到 Setup 页 对一遍交付边界。做 App、要人天天代运营的活他们不接。
备份包里通常同时有数据库和私钥,裸放在对象存储上等于把钥匙挂在门口。用 GPG 公钥加密,私钥离线保存,恢复时才拿出来。
放在哪,老规矩:至少两份介质、一份不在同一台机器、最好一份不在同一个账号下。同一块盘上的快照、同一个云账号里的另一个存储桶,在误删和勒索软件面前都不算异地。
保留策略按 RPO 和存储成本定,常见的组合是日备留两周、周备留两个月、月备留半年。多留几代的意义在于:数据被逻辑写坏之后,你可能过了三周才发现,那时候最近的备份里也已经是坏数据了。
最后,备份包里放一份 RESTORE.md:依赖什么版本、密钥在哪、先启哪个服务、验证跑哪条命令。这份文档和备份包一起加密,一起异地。真正做恢复的人多半不是写脚本的人,文档比脚本更救命。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。