首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >备份文件还在,为什么恢复不出来:备什么、多久一次、怎么验证

备份文件还在,为什么恢复不出来:备什么、多久一次、怎么验证

原创
作者头像
用户10345225
发布于 2026-11-02 12:25:42
发布于 2026-11-02 12:25:42
1690
举报

凌晨两点机器起不来,备份目录里躺着一个三天前的压缩包,大小正常。解压、导入、起服务,站点回来了——然后发现订单表少了两万行。当初只 dump 了业务前缀那几张表,插件自己建的表在库的另一头,没人记得。

这类事故的共同点不是没备份,是没人验证过备份能恢复成什么。下面按「备什么 → 多久一次 → 怎么证明能恢复」写,脚本可以直接跑。

先定两个数字:能丢多少、能停多久

这两个数字定了,频率和预算才有依据,不然「每天备份一次」只是习惯。

  • RPO:能接受丢多久的数据。订单每分钟都在进来,RPO 就是分钟级;一个企业站一周发一篇文章,RPO 是 7 天也没事。
  • RTO:能接受停多久。这个数字决定你是「解压导入」还是得有热备和切流方案。

RTO 只能靠演练测出来,不能拍脑袋。第一次认真做一次从零恢复,绝大多数团队测出来的时间是自己估计的两三倍——找密钥、找配置文件、对数据库版本,都在这段时间里。

备份清单按「丢了能不能重建」分级

按内容分类没意义(代码、数据库、图片……),按「丢了之后能不能重新造出来」分才有意义。

级别

判断标准

典型内容

频率

保留

A

丢了不可重建

数据库、用户上传文件、证书私钥、第三方服务的密钥

按写入量,通常每日或更密

日 14 / 周 8 / 月 6

B

可重建但费时

应用代码、nginx 与 php 配置、systemd unit、cron、环境变量文件

每次变更(放 Git,另外随包带一份)

永久

C

重新装就有

依赖包、缓存、日志、临时文件

不备份

—

落到一台典型的 Web 服务器上,A 级和 B 级具体是这些东西:

  • MySQL / MariaDB 全库,含存储过程、触发器、事件、视图
  • 上传目录(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 的 compose 文件、docker volume 里的数据
  • 域名解析记录(导出 zone 文件,或者至少截图存档)

容易漏的几处:数据库里的存储过程没有被 --routines 带上;上传目录被 .gitignore 排除,谁也没单独备;cron 里跑着的那条对账脚本只存在 crontab 里;证书是 certbot 自动续的,私钥丢了重签要等 DNS 生效。

一次备份要产出什么

一个合格备份包的产物是五样东西,缺一样都会在恢复时卡住:数据文件、文件归档、清单(这次备份是什么环境做的)、行数记录、校验和。清单是关键,恢复的人往往不是备份的人。

代码语言:bash
复制
#!/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,或者接受短暂锁写。文件和数据不在同一个时间点上——上传目录多几个文件没关系,少文件才有事,写入频繁的站点用文件系统快照,或者备份前把站点切到只读几十秒。

恢复验证:从「能启动」到「内容对得上」

只验证到服务能起来,等于什么都没验证。恢复脚本要把包解开、导入临时库、拿数据跟备份时的记录比。绝不碰生产库。

代码语言:bash
复制
#!/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 上是全表扫描,大表别在业务高峰跑;跨大版本恢复时它可能对不上,那时候退回「行数 + 抽样」就行,不用为了对齐校验和去改数据。

行数和校验和都对上,还差一步抽样:拿几条业务上认得的记录,在生产库和还原库里各查一遍金额、状态、时间。

代码语言:sql
复制
-- 两边各跑一次,数字必须一样
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。

演练排期和「备份没跑」的告警

没有演练的备份等于没有备份,但也不用每周全量演练。分级来:

  • 每周:自动解包 + 导入临时库 + 行数比对,跑完删库。上面那个脚本挂 cron 就够。
  • 每月:人工做一次到临时机器/临时容器的完整恢复,含应用层走查,记录耗时。
  • 每季度:从零开始,只给一个备份包和一份 RESTORE.md,让没参与建设的人恢复一遍。写不明白的地方就是文档的漏洞。
代码语言:bash
复制
# /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 里任务被注释掉、磁盘满了、密码过期,日志都安安静静。加一条新鲜度检查:

代码语言:bash
复制
#!/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 <告警地址>。关键是告警要有人认领,发到一个没人看的频道等于没告警。

迁移时才暴露的几个坑

恢复和迁移是两件事:恢复到同版本同架构,坑少一半;换机器、换系统、换发行版,下面这些一定会遇到。

  • 引导方式。新机器是 UEFI 还是 BIOS,[ -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 卷。备份时在容器里打包才能保住属主:
代码语言:bash
复制
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 | head
  • 大小写敏感。从大小写不敏感的系统迁到 Linux,Logo.PNG 和 logo.png 会打架,图片 404 往往出在这。
  • 数据库版本与字符集。先把 dump 导入同版本实例,再在原实例上升级,别跨版本直接导。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 删除。

目录
  • 先定两个数字:能丢多少、能停多久
  • 备份清单按「丢了能不能重建」分级
  • 一次备份要产出什么
  • 恢复验证:从「能启动」到「内容对得上」
  • 演练排期和「备份没跑」的告警
  • 迁移时才暴露的几个坑
  • 站点是别人搭的,交接时该拿到什么
  • 保留、加密、异地
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档