备份与恢复
本文范围
备份不是把文件拷走就完事了。真正要交付的是:服务器坏了、库被误删了、证书丢了、镜像拉不下来、内网现场没有外网时,你能把系统恢复起来。
所以判断一套备份方案能不能用,不是看目录里有没有 .sql.gz、.tar.gz,而是看下面几个问题能不能回答:
- 现在应该恢复哪一份备份?
- 这份备份有没有损坏?
- 恢复到哪里?
- 恢复命令怎么执行?
- 恢复后怎么看数据对不对?
- 多久能恢复?
- 最多会丢多少数据?
这里要注意,很多事故不是“完全没有备份”,而是“只有一部分备份”。比如只备了 MySQL,没有备 Nginx 配置和证书;只备了 Docker 镜像,没有备 volume;只备了 Redis 的 dump.rdb,但 AOF 还开着,恢复后 Redis 又按旧 AOF 回放了一遍。
这篇文章就按现场恢复的思路来讲。每一节都尽量按这个顺序走:
先说场景
-> 给命令
-> 看结果
-> 说坑
-> 给生产建议命令以 Linux 为主,数据库以 MySQL 8.4 LTS 为主线,Redis 按当前官方持久化方式讲,Docker 按 Docker Engine 和 Compose 的常见部署方式讲。存量旧版本可以参考思路,但正式落地前一定要在测试环境把命令跑一遍。
本文不展开完整两地三中心架构、数据库高可用选型、CDC 平台建设、对象存储生命周期治理或业务补偿系统设计。那些属于架构和平台工程范畴。这里聚焦运维侧能交付的备份、校验、恢复、演练和证据闭环。
前置条件
做备份前,先把这些问题定下来:
- 每类数据都有明确 RPO 和 RTO。RPO 是最多能丢多少数据,RTO 是最多多久恢复服务,不能只写“每天备份一次”。
- 有恢复负责人和审批链路。生产恢复、覆盖数据、回放 binlog、替换证书都必须有人确认。
- 备份账号使用最小权限,不用 root、DBA 账号或应用账号混跑;备份密钥、对象存储凭证、数据库密码要单独管理。
- 有一套临时恢复环境,能在不碰生产数据的前提下导入备份、回放日志、校验业务关键表和接口。
- 备份介质支持校验、加密、版本保留和异地存放;同机同盘只有“本地副本”价值,不能当灾备。
- 内网和离线现场已准备恢复工具包,包括数据库客户端、压缩工具、校验工具、容器镜像、脚本、证书链和离线说明。
- 恢复过程要能留下证据,包括执行人、时间、命令、备份文件、校验值、恢复耗时、验证 SQL 或接口结果。
- 已明确哪些内容不能由备份解决,例如未提交事务、外部第三方状态、人工误操作后的业务补偿和跨系统一致性修复。
第一步:先列清楚到底要备什么
很多人一说备份,第一反应就是数据库。数据库当然重要,但一个系统想恢复,光有数据库通常不够。
先在服务器上做一个清单。可以从这些目录和组件开始查:
hostname -f 2>/dev/null || hostname
ip addr
df -hT
systemctl list-units --type=service --state=running
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
docker volume ls这几条命令解决的问题很直接:先知道这台机器叫什么、有哪些磁盘、跑了哪些服务、有哪些容器和数据卷。
如果执行 docker ps 能看到 MySQL、Redis、Nginx、业务应用这些容器,就说明备份不能只看宿主机目录,还要继续查 Compose 文件、volume、bind mount。
再查 Nginx、systemd、定时任务:
nginx -T >/tmp/nginx-full-config.txt 2>/tmp/nginx-test.err || cat /tmp/nginx-test.err
systemctl list-timers --all
crontab -l
ls -lah /etc/cron.d一般需要备的对象可以按下面这张表过一遍:
| 对象 | 一般备什么 | 恢复时怎么看 |
|---|---|---|
| MySQL | 全量备份、binlog、备份账号配置、版本信息 | 能导入,表数量和核心数据对得上 |
| Redis | RDB、AOF、redis.conf、版本和数据目录信息 | 能启动,PING、DBSIZE、抽样 key 正常 |
| 文件 | 上传目录、附件目录、静态资源、导入导出文件 | 文件数量、大小、哈希和业务访问正常 |
| 配置 | Nginx、systemd、cron、Docker daemon、Compose、.env | 配置检查通过,服务能启动 |
| 镜像 | 业务镜像、基础镜像、中间件镜像、tag/digest | 离线 docker load 后能启动 |
| 证书 | fullchain、私钥、CA、续期配置 | nginx -t 通过,浏览器或客户端信任 |
| 脚本 | 部署脚本、备份脚本、恢复脚本、巡检脚本 | 能在新机器跑,日志清楚 |
| 中间件 | MQ、配置中心、注册中心、搜索引擎的数据和配置 | 按组件官方方式恢复后功能可用 |
这里要注意,.env、证书私钥、数据库备份都属于敏感文件。它们不是普通日志,不能随手传到公开仓库,也不能放到所有人都能读的目录。
把对象列出来以后,要马上把它们放进一个闭环里看。备份不是“每天生成文件”,而是从识别对象开始,经过备份、校验、异地保存、恢复演练,最后反向更新策略。少掉任何一环,事故时都会变成“有文件但恢复不起来”。
这张图的重点是后半段。只做到了 C,只是“备份脚本跑过”;走到 G,才算证明这份备份能支撑恢复。日常可以用下面几条命令抽检闭环:
find /backup/mysql/full -mindepth 2 -maxdepth 2 -type f -name '*.sql.gz' -mtime -1 -print
sha256sum -c /backup/mysql/full/20260709_020000/all-databases.sql.gz.sha256
gzip -t /backup/mysql/full/20260709_020000/all-databases.sql.gz
tar -tzf /backup/config/config-20260709_010000.tar.gz >/dev/null常见坑是只盯备份文件本身:压缩包能解,不代表数据库能导入;数据库能导入,不代表应用配置、证书、镜像和文件都能恢复。生产上要给每类对象写清楚负责人、保留周期、恢复顺序和验收命令,尤其是内网和离线环境,恢复依赖包也要跟备份一起进入闭环。
第二步:建一个像样的备份目录
先不要急着写复杂脚本。我们先把目录和权限建好。
sudo groupadd -r backup 2>/dev/null || true
sudo useradd -r -g backup -s /usr/sbin/nologin backup 2>/dev/null || true
sudo mkdir -p /backup/{mysql,redis,files,config,docker,certs,logs}
sudo chown -R root:backup /backup
sudo chmod 0750 /backup执行后看一下:
ls -ld /backup /backup/mysql /backup/redis正常应该能看到目录属主是 root,属组是 backup,权限类似 drwxr-x---。这个权限的意思是:不是谁登录服务器都能把备份文件拿走。
脚本里建议统一加上:
set -euo pipefail
umask 077这两行很重要。set -euo pipefail 可以让脚本遇到失败尽早退出,umask 077 可以让新生成的备份文件默认只有当前用户能读写。
备份文件命名建议用时间戳:
ts="$(date +%Y%m%d_%H%M%S)"
mkdir -p "/backup/mysql/full/$ts"不要用 new.sql.gz、bak.tar.gz 这种名字。过一周你就不知道它是哪天、哪台机器、哪个库的备份了。
一个比较舒服的目录长这样:
/backup/
mysql/
full/20260709_020000/all-databases.sql.gz
binlog/
redis/
6379/20260709_030000/dump.rdb
files/
uploads/20260709_040000.tar.gz
config/
20260709_010000.tar.gz
docker/
images/
volumes/
compose/
certs/
20260709_010000.tar.gz.gpg
logs/每个备份目录里建议放一个 manifest.txt:
backup_dir="/backup/config/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$backup_dir"
{
echo "host=$(hostname -f 2>/dev/null || hostname)"
echo "user=$(id -un)"
echo "time=$(date -Is)"
echo "kernel=$(uname -a)"
} > "$backup_dir/manifest.txt"以后恢复时,这个文件能帮你判断:这份备份来自哪台机器,什么时候做的,用什么用户做的。
第三步:先用文件备份练手
文件备份最容易理解。比如业务上传文件在 /data/app/uploads,我们先备它。
backup_file="/backup/files/uploads/uploads-$(date +%Y%m%d_%H%M%S).tar.gz"
mkdir -p "$(dirname "$backup_file")"
tar --xattrs --acls --selinux \
-czf "$backup_file" \
-C /data/app uploads这个命令做了两件事:
-C /data/app uploads表示进入/data/app后打包uploads目录,这样解压时不会带上一长串绝对路径。--xattrs --acls --selinux尽量保留扩展属性、ACL 和 SELinux 信息,生产环境更稳。
打包完不要直接收工,先验一下:
test -s "$backup_file"
gzip -t "$backup_file"
tar -tzf "$backup_file" | head
sha256sum "$backup_file" > "$backup_file.sha256"
sha256sum -c "$backup_file.sha256"这里应该看到:
test -s没有输出,表示文件存在且不是 0 字节。gzip -t没有报错,表示压缩包结构正常。tar -tzf能列出文件。sha256sum -c输出OK。
如果 gzip -t 报 unexpected end of file,一般是备份或传输过程中中断了。这种备份不能用,要去找上一份或异地备份。
恢复时先不要直接覆盖原目录。先解到临时目录看看:
mkdir -p /tmp/restore-check
tar -xzf "$backup_file" -C /tmp/restore-check
find /tmp/restore-check/uploads -type f | wc -l
du -sh /tmp/restore-check/uploads确认没问题,再正式恢复:
systemctl stop app
mv /data/app/uploads /data/app/uploads.before-restore.$(date +%Y%m%d_%H%M%S)
tar -xzf "$backup_file" -C /data/app
chown -R app:app /data/app/uploads
systemctl start app
systemctl status app --no-pager这里要注意,恢复前把旧目录改名保留下来,不要一上来 rm -rf。如果恢复包选错了,至少还有退路。
大目录用 rsync,更适合长期跑
如果上传目录很大,每天完整 tar 一份会很慢,也很占空间。这时候可以用 rsync 做增量同步。
先演练,不真正改目标目录:
rsync -aHAX --numeric-ids --delete --dry-run --itemize-changes \
/data/app/uploads/ \
/backup/files/uploads/current/这里的 /data/app/uploads/ 最后有一个 /,表示同步目录里的内容;如果不加 /,会把 uploads 这个目录本身同步过去。这是 rsync 最常见的坑之一。
确认输出符合预期,再去掉 --dry-run:
rsync -aHAX --numeric-ids --delete \
/data/app/uploads/ \
/backup/files/uploads/current/--delete 表示源目录删除的文件,目标目录也删除。这个参数很有用,也很危险。生产脚本里一般要加路径保护:
target="/backup/files/uploads/current"
case "$target" in
/backup/files/uploads/*) ;;
*) echo "bad target: $target" >&2; exit 1 ;;
esac如果想保留每天一个快照,可以用硬链接:
today="/backup/files/uploads/$(date +%Y%m%d_%H%M%S)"
latest="/backup/files/uploads/latest"
mkdir -p "$today"
if [[ -L "$latest" ]]; then
rsync -aHAX --numeric-ids --delete \
--link-dest="$(readlink -f "$latest")" \
/data/app/uploads/ \
"$today/"
else
rsync -aHAX --numeric-ids --delete \
/data/app/uploads/ \
"$today/"
fi
ln -sfn "$today" "$latest"这样没有变化的文件会用硬链接复用,节省空间。恢复时直接选某个时间点目录即可。
配置备份:别等服务起不来才想起它
数据库恢复了,Nginx 配置没了,服务照样访问不了。配置备份一定要跟数据备份一起做。
常见要备这些:
/etc/nginx/
/etc/systemd/system/
/etc/docker/
/etc/cron.d/
/opt/app/docker-compose.yml
/opt/app/.env
/opt/app/config/先导出 Nginx 最终配置:
mkdir -p /backup/config/nginx/$(date +%Y%m%d_%H%M%S)
nginx -T > /backup/config/nginx/$(date +%Y%m%d_%H%M%S)/nginx-full-config.txt
nginx -tnginx -T 会把 include 后的配置一起展开。以后排查“线上到底加载了什么配置”时,这个文件很有用。
再打包配置目录:
backup_file="/backup/config/config-$(date +%Y%m%d_%H%M%S).tar.gz"
tar --xattrs --acls --selinux \
-czf "$backup_file" \
/etc/nginx \
/etc/systemd/system \
/etc/docker \
/etc/cron.d \
/opt/app/docker-compose.yml \
/opt/app/.env \
/opt/app/config
sha256sum "$backup_file" > "$backup_file.sha256"
sha256sum -c "$backup_file.sha256"
tar -tzf "$backup_file" >/dev/null这里要注意,.env 里经常有数据库密码、token、密钥。配置备份最好加密保存。
gpg --symmetric --cipher-algo AES256 "$backup_file"
shred -u "$backup_file"恢复后这样验证:
nginx -t
systemctl daemon-reload
systemctl status app --no-pager
systemctl list-timers --all如果 nginx -t 报证书文件不存在,不要急着改配置,先去恢复证书目录。
证书备份:不只是一张 crt 文件
HTTPS 证书恢复时最容易漏三样东西:私钥、证书链、续期配置。
先看 Nginx 正在用哪些证书:
nginx -T | grep -E 'ssl_certificate|ssl_certificate_key'再检查证书内容:
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -subject -issuer -dates正常会看到证书主体、签发者和有效期。你要重点看 notAfter,也就是到期时间。
如果使用 Certbot,不能只备 /etc/letsencrypt/live。一般要备这些:
/etc/letsencrypt/live/
/etc/letsencrypt/archive/
/etc/letsencrypt/renewal/
/etc/letsencrypt/accounts/备份并加密:
cert_backup="/backup/certs/letsencrypt-$(date +%Y%m%d_%H%M%S).tar.gz"
tar -czf "$cert_backup" /etc/letsencrypt
gpg --symmetric --cipher-algo AES256 "$cert_backup"
shred -u "$cert_backup"
sha256sum "$cert_backup.gpg" > "$cert_backup.gpg.sha256"恢复后处理权限:
chown -R root:root /etc/letsencrypt
find /etc/letsencrypt -type d -exec chmod 0755 {} \;
find /etc/letsencrypt -type f -name 'privkey*.pem' -exec chmod 0600 {} \;
nginx -t
systemctl reload nginx续期也要验证:
certbot certificates
certbot renew --dry-run
systemctl list-timers --all | grep -i certbot如果是企业内网自签 CA,还要备根证书、中间证书、签发配置和客户端信任根证书的下发方式。否则服务端证书恢复了,客户端仍然会报不信任。
MySQL 备份:先把账号和 binlog 准备好
MySQL 备份不要先上来就敲 mysqldump -uroot -p。生产里一般要建单独的备份账号,用配置文件保存连接信息,避免密码出现在命令历史和日志里。
先创建备份账号:
CREATE USER 'backup_user'@'127.0.0.1' IDENTIFIED BY 'Change_me_to_real_secret';
GRANT SELECT, SHOW VIEW, TRIGGER, EVENT, LOCK TABLES, RELOAD, REPLICATION CLIENT
ON *.* TO 'backup_user'@'127.0.0.1';
FLUSH PRIVILEGES;然后在服务器上写客户端配置:
[client]
user=backup_user
password=Change_me_to_real_secret
host=127.0.0.1
port=3306保存后收紧权限:
sudo mkdir -p /etc/backup
sudo chown root:backup /etc/backup/mysql.cnf
sudo chmod 0640 /etc/backup/mysql.cnf验证账号能用:
mysql --defaults-extra-file=/etc/backup/mysql.cnf -e "SELECT VERSION()"能看到 MySQL 版本号,说明连接没问题。
接着检查 binlog:
mysql --defaults-extra-file=/etc/backup/mysql.cnf -e "SHOW VARIABLES LIKE 'log_bin'"
mysql --defaults-extra-file=/etc/backup/mysql.cnf -e "SHOW VARIABLES LIKE 'binlog_format'"
mysql --defaults-extra-file=/etc/backup/mysql.cnf -e "SHOW BINARY LOGS"
mysql --defaults-extra-file=/etc/backup/mysql.cnf -e "SHOW BINARY LOG STATUS\\G"这里要注意,MySQL 8.4 使用 SHOW BINARY LOG STATUS。旧环境里你可能见过 SHOW MASTER STATUS,新脚本不要再优先写旧命令。
binlog 的作用很现实:全量备份是昨晚 2 点的,今天上午 10 点有人误删了表,如果没有 binlog,你最多只能回到昨晚 2 点;有 binlog,才有机会恢复到误删前几秒。
生产建议:
binlog_format优先使用ROW。sync_binlog=1更安全,但要结合性能测试。- binlog 保留时间要大于你的恢复窗口。
- binlog 要异地保存,不能只放在同一块数据盘。
用 mysqldump 做全量备份
小中型库先用 mysqldump 跑通闭环。命令如下:
#!/usr/bin/env bash
set -euo pipefail
umask 077
cnf="/etc/backup/mysql.cnf"
backup_dir="/backup/mysql/full/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$backup_dir"
mysql --defaults-extra-file="$cnf" --version > "$backup_dir/mysql-version.txt"
mysql --defaults-extra-file="$cnf" -e "SHOW BINARY LOG STATUS\\G" > "$backup_dir/binary-log-status.txt"
mysqldump --defaults-extra-file="$cnf" \
--all-databases \
--single-transaction \
--quick \
--routines \
--events \
--triggers \
--hex-blob \
--source-data=2 \
--set-gtid-purged=AUTO \
| gzip -1 > "$backup_dir/all-databases.sql.gz"
test -s "$backup_dir/all-databases.sql.gz"
gzip -t "$backup_dir/all-databases.sql.gz"
sha256sum "$backup_dir/all-databases.sql.gz" > "$backup_dir/all-databases.sql.gz.sha256"
sha256sum -c "$backup_dir/all-databases.sql.gz.sha256"
du -sh "$backup_dir"这里几个参数要看懂:
--single-transaction:对 InnoDB 做一致性快照,避免长时间锁表。--quick:大表逐行读,不把结果一次性塞进客户端内存。--routines --events --triggers:存储过程、事件、触发器也一起导出。--source-data=2:把 binlog 位点写到备份文件注释里,后面做时间点恢复会用到。--hex-blob:二进制字段更稳。
执行完应该看到 .sql.gz 文件和 .sha256 文件。先看大小:
ls -lh "$backup_dir"如果 SQL 压缩包只有几 KB,而你的库明明有几十 GB,先不要高兴,八成是命令中途失败了。去看日志和退出码。
单库备份这样写:
mysqldump --defaults-extra-file=/etc/backup/mysql.cnf \
--databases app_db \
--single-transaction \
--quick \
--routines \
--events \
--triggers \
--hex-blob \
| gzip > /backup/mysql/app_db-$(date +%Y%m%d_%H%M%S).sql.gz单表备份这样写:
mysqldump --defaults-extra-file=/etc/backup/mysql.cnf \
app_db orders order_items \
--single-transaction \
--quick \
--triggers \
| gzip > /backup/mysql/app_db-orders-$(date +%Y%m%d_%H%M%S).sql.gz这里要注意,mysqldump 是逻辑备份,恢复时需要执行 SQL,库越大恢复越慢。几 GB、几十 GB 一般还能接受;几百 GB 甚至 TB 级,就要认真评估恢复时间,必要时上物理热备。
MySQL 恢复:先在临时实例练一遍
不要第一次恢复就对着生产库执行。
可以先启动一个临时 MySQL:
docker run -d --name mysql-restore-check \
-e MYSQL_ROOT_PASSWORD=Restore_check_123 \
-p 3307:3306 \
mysql:8.4等它启动:
docker logs mysql-restore-check --tail=50
mysql -h127.0.0.1 -P3307 -uroot -pRestore_check_123 -e "SELECT VERSION()"能看到版本号后导入:
backup_file="/backup/mysql/full/20260709_020000/all-databases.sql.gz"
sha256sum -c "$backup_file.sha256"
gzip -t "$backup_file"
gunzip -c "$backup_file" | mysql -h127.0.0.1 -P3307 -uroot -pRestore_check_123恢复后检查:
mysql -h127.0.0.1 -P3307 -uroot -pRestore_check_123 -e "SHOW DATABASES"
mysql -h127.0.0.1 -P3307 -uroot -pRestore_check_123 -e "SELECT COUNT(*) FROM app_db.orders"这里应该能看到业务库、核心表和合理的行数。行数不是绝对证明,但能帮你快速发现“导入了空库”“导入错库”“只恢复了结构没恢复数据”这类问题。
正式恢复前一般要做四件事:
- 停止业务写入或进入维护模式。
- 备份目标库当前状态。
- 确认恢复文件和目标实例。
- 记录开始时间,恢复完记录耗时。
正式恢复命令:
start_at=$(date +%s)
gunzip -c all-databases.sql.gz \
| mysql --defaults-extra-file=/etc/backup/mysql-restore.cnf
end_at=$(date +%s)
echo "restore_seconds=$((end_at - start_at))"恢复完不仅要看 MySQL,还要看业务:
curl -fsS http://127.0.0.1:8080/actuator/health
curl -fsS http://127.0.0.1:8080/api/orders/recent如果接口查不到数据,先别急着判定 MySQL 恢复失败。也要查应用配置、数据源地址、账号权限、缓存和搜索索引。
用 binlog 做误删恢复
误删恢复不要在生产库上瞎试。正确姿势是先恢复到临时实例,再从临时实例把需要的数据拿回来。
流程是:
停止写入
-> 确认误操作时间
-> 找到最近全量备份
-> 恢复到临时实例
-> 回放 binlog 到误操作前
-> 验证数据
-> 导出需要恢复的数据
-> 回写生产用图看会更清楚:全量备份负责把临时库拉回一个可信基线,binlog 负责把它推进到误删前一刻,最后只把受影响数据回写生产,而不是把整个临时库粗暴覆盖回去。
这条链路要先验证目标库地址,避免把命令打到生产库。回放前建议同时打印当前连接信息和 binlog 窗口:
mysql --defaults-extra-file=/etc/backup/mysql-restore.cnf -e "SELECT @@hostname, @@port, @@version"
mysql --defaults-extra-file=/etc/backup/mysql-restore.cnf -e "SHOW DATABASES"
mysqlbinlog --start-datetime="2026-07-09 10:00:00" --stop-datetime="2026-07-09 10:10:00" /var/lib/mysql/binlog.000123 | head这里的生产约束很硬:误删时间没有确认前,不要回放;没有全量基线,不要只拿一段 binlog 硬拼;没有业务验收,不要把恢复数据写回生产。binlog 保留时间也要大于你的误操作发现窗口,否则事故发现时日志已经被清理,恢复链路会断在最关键的位置。
先从备份文件里找 binlog 位点:
gunzip -c all-databases.sql.gz \
| grep -m 5 -E "SOURCE_LOG_FILE|MASTER_LOG_FILE|CHANGE REPLICATION"再用时间范围找误操作附近的事件:
mysqlbinlog --base64-output=DECODE-ROWS -vv \
--start-datetime="2026-07-09 10:00:00" \
--stop-datetime="2026-07-09 10:10:00" \
/var/lib/mysql/binlog.000123 > /tmp/binlog-window.sql
grep -n -C 20 -E "DROP TABLE|TRUNCATE TABLE|DELETE FROM|UPDATE" /tmp/binlog-window.sql用时间找范围很方便,但正式回放建议用 position。找到误删前的位置后:
mysqlbinlog \
--start-position=456789 \
--stop-position=987654 \
/var/lib/mysql/binlog.000123 \
| mysql --defaults-extra-file=/etc/backup/mysql-restore.cnf如果跨多个 binlog 文件:
mysqlbinlog --start-position=456789 /var/lib/mysql/binlog.000123 \
/var/lib/mysql/binlog.000124 \
/var/lib/mysql/binlog.000125 \
| mysql --defaults-extra-file=/etc/backup/mysql-restore.cnf如果只是恢复几行数据,不建议把整个临时实例覆盖回生产。可以从临时实例导出需要的数据:
mysqldump --defaults-extra-file=/etc/backup/mysql-restore.cnf \
app_db orders \
--where="id in (1001,1002,1003)" \
--no-create-info \
> restore-orders.sql执行前先检查 SQL:
head -80 restore-orders.sql
grep -n "DROP\\|TRUNCATE\\|DELETE" restore-orders.sql || true这里要注意,误删恢复最怕着急。越急越容易把临时库、生产库、备份文件、binlog 文件搞混。恢复脚本里一定要打印目标库地址,并让操作者二次确认。
大库用物理热备
如果库很大,mysqldump 不是不能用,而是恢复时间可能扛不住。比如备份 300GB 数据,导入 SQL 可能要很多小时,业务未必等得起。
这种场景一般考虑 Percona XtraBackup 这类物理热备工具。典型备份:
xtrabackup --backup \
--target-dir=/backup/mysql/xtrabackup/$(date +%Y%m%d_%H%M%S) \
--user=backup_user \
--password='Change_me_to_real_secret'备份完成后要 prepare:
xtrabackup --prepare \
--target-dir=/backup/mysql/xtrabackup/20260709_020000恢复时要停 MySQL,并保护原目录:
systemctl stop mysqld
mv /var/lib/mysql /var/lib/mysql.before-restore.$(date +%Y%m%d_%H%M%S)
mkdir -p /var/lib/mysql
xtrabackup --copy-back \
--target-dir=/backup/mysql/xtrabackup/20260709_020000
chown -R mysql:mysql /var/lib/mysql
systemctl start mysqld这里要注意三点:
- XtraBackup 版本要和 MySQL 大版本匹配。
- 物理备份恢复快,但跨版本迁移没有逻辑备份那么灵活。
- 物理备份也要配 binlog,否则误删恢复能力不足。
InnoDB 损坏时怎么抢救
如果 MySQL 因 InnoDB 损坏起不来,先保护现场,不要反复改数据目录。
systemctl stop mysqld
cp -a /var/lib/mysql /var/lib/mysql.broken.$(date +%Y%m%d_%H%M%S)然后从低到高尝试 innodb_force_recovery:
[mysqld]
innodb_force_recovery=1能启动后尽快导出:
mysqldump --defaults-extra-file=/etc/backup/mysql.cnf \
--all-databases \
--single-transaction \
--quick \
> emergency-dump.sql这里要注意,innodb_force_recovery 是抢数据用的,不是让你继续带病跑生产。导出来以后要重建实例,再恢复数据。
Redis 备份:先搞清楚 RDB 和 AOF
Redis 的备份不能只问“dump.rdb 在哪”。你要先看持久化配置。
redis-cli -h 127.0.0.1 -p 6379 INFO persistence
redis-cli -h 127.0.0.1 -p 6379 CONFIG GET dir
redis-cli -h 127.0.0.1 -p 6379 CONFIG GET dbfilename
redis-cli -h 127.0.0.1 -p 6379 CONFIG GET appendonly
redis-cli -h 127.0.0.1 -p 6379 CONFIG GET appenddirnameRDB 可以理解成快照,文件小,适合备份和迁移。AOF 可以理解成写命令日志,数据通常更完整,但文件更大,恢复更慢。
生产里常见做法是:RDB 做冷备和异地备份,AOF 降低故障时的数据丢失窗口。具体怎么选,要看业务能不能接受丢数据。
Redis RDB 备份
先触发后台保存:
redis-cli -h 127.0.0.1 -p 6379 BGSAVE然后等它完成:
while redis-cli -h 127.0.0.1 -p 6379 INFO persistence | grep -q 'rdb_bgsave_in_progress:1'; do
sleep 1
done
redis-cli -h 127.0.0.1 -p 6379 INFO persistence \
| grep -E 'rdb_last_bgsave_status|rdb_last_save_time'正常应该看到 rdb_last_bgsave_status:ok。
备份脚本:
#!/usr/bin/env bash
set -euo pipefail
umask 077
host="${REDIS_HOST:-127.0.0.1}"
port="${REDIS_PORT:-6379}"
backup_dir="/backup/redis/$port/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$backup_dir"
redis-cli -h "$host" -p "$port" BGSAVE
while redis-cli -h "$host" -p "$port" INFO persistence | grep -q 'rdb_bgsave_in_progress:1'; do
sleep 1
done
redis-cli -h "$host" -p "$port" INFO server > "$backup_dir/info-server.txt"
redis-cli -h "$host" -p "$port" INFO persistence > "$backup_dir/info-persistence.txt"
data_dir="$(redis-cli -h "$host" -p "$port" CONFIG GET dir | tail -1)"
dbfilename="$(redis-cli -h "$host" -p "$port" CONFIG GET dbfilename | tail -1)"
cp -a "$data_dir/$dbfilename" "$backup_dir/$dbfilename"
redis-check-rdb "$backup_dir/$dbfilename"
sha256sum "$backup_dir/$dbfilename" > "$backup_dir/$dbfilename.sha256"
sha256sum -c "$backup_dir/$dbfilename.sha256"这里要注意,BGSAVE 会 fork 子进程。Redis 内存已经快顶满机器时,fork 和写时复制可能导致 OOM。备份失败时先看:
redis-cli INFO persistence | grep -E 'rdb_last_bgsave_status|rdb_last_cow_size'
free -h
df -hRedis AOF 备份和恢复坑
先看 AOF 是否开启:
redis-cli CONFIG GET appendonly
redis-cli INFO persistence | grep -E 'aof_enabled|aof_last_write_status|aof_last_bgrewrite_status'如果 AOF 开着,恢复时 Redis 通常会优先按 AOF 加载。也就是说,你只复制了 dump.rdb,但旧 AOF 还在,恢复出来的数据可能不是你想要的。
备份 AOF 前可以先重写:
redis-cli BGREWRITEAOF
while redis-cli INFO persistence | grep -q 'aof_rewrite_in_progress:1'; do
sleep 1
done新版本 Redis 的 AOF 可能是多文件目录,不一定是一个 appendonly.aof。按配置找目录:
data_dir="$(redis-cli CONFIG GET dir | tail -1)"
append_dir="$(redis-cli CONFIG GET appenddirname | tail -1)"
tar -czf /backup/redis/aof-$(date +%Y%m%d_%H%M%S).tar.gz \
-C "$data_dir" "$append_dir"恢复 RDB 时一般这样做:
systemctl stop redis
cp -a /var/lib/redis /var/lib/redis.before-restore.$(date +%Y%m%d_%H%M%S)
install -o redis -g redis -m 0640 /backup/redis/6379/20260709_030000/dump.rdb /var/lib/redis/dump.rdb
systemctl start redis
redis-cli PING
redis-cli DBSIZE如果只想用 RDB 恢复,要确认 AOF 是否关闭或旧 AOF 是否已经处理:
redis-cli CONFIG GET appendonly
ls -lah /var/lib/redis
ls -lah /var/lib/redis/appendonlydir 2>/dev/null || trueAOF 损坏时,先复制再检查:
cp -a appendonly.aof appendonly.aof.before-fix.$(date +%Y%m%d_%H%M%S)
redis-check-aof appendonly.aof只有确认能接受尾部丢失时,再考虑:
redis-check-aof --fix appendonly.aof不要在没有备份的情况下直接 --fix。
Docker 和 Compose:镜像不是数据
Docker 备份最容易出现一个误会:我把容器 commit 成镜像,是不是数据也备份了?
一般不是。
原因要结合 Overlay2/CoW 来看。镜像层是只读层,容器运行后会多一个可写层。容器里新写的普通文件会在可写层里,但数据库、Redis 这类真正重要的数据,生产通常会挂到 volume 或宿主机目录。你 docker commit 提交的是容器可写层,不会把 volume 里的数据打进镜像。
先看容器挂载:
docker inspect mysql \
--format '{{json .Mounts}}' | jq .没有 jq 就直接:
docker inspect mysql | grep -A30 '"Mounts"'再看 Compose 最终配置:
docker compose config
docker compose ps
docker volume ls这里要备三类东西:
- Compose 文件和
.env。 - volume 或 bind mount 数据。
- 镜像 tag 和 digest。
备份 Compose:
backup_dir="/backup/docker/compose/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$backup_dir"
cp -a docker-compose.yml "$backup_dir/"
cp -a .env "$backup_dir/" 2>/dev/null || true
docker compose config > "$backup_dir/compose.config.rendered.yml"
docker compose ps > "$backup_dir/compose.ps.txt"
docker volume ls > "$backup_dir/docker-volume-ls.txt"如果 .env 有密码,后面要加密。
Docker volume 备份
对普通 volume,可以用临时容器打包。
docker run --rm \
-v mysql_data:/data:ro \
-v "$PWD":/backup \
busybox \
tar czf /backup/mysql_data-$(date +%Y%m%d_%H%M%S).tar.gz -C /data .这个命令的意思是:
- 把
mysql_data挂到临时容器的/data,只读。 - 把当前目录挂到
/backup。 - 在临时容器里执行 tar,把 volume 内容打包出来。
恢复:
docker compose stop mysql
docker run --rm \
-v mysql_data:/data \
-v "$PWD":/backup \
busybox \
sh -c 'cd /data && rm -rf ./* && tar xzf /backup/mysql_data-20260709_020000.tar.gz'
docker compose up -d mysql
docker compose logs mysql --tail=100这里要注意,数据库 volume 不建议在数据库运行时直接 tar。MySQL 用 mysqldump 或物理热备,Redis 先 BGSAVE,再备 RDB/AOF。直接冷备 volume 时,最好先停服务。
Docker 镜像备份
内网和离线环境里,恢复经常卡在“镜像拉不下来”。所以镜像也要备。
先记录镜像:
docker ps --format 'table {{.Names}}\t{{.Image}}'
docker image inspect registry.example.com/team/app:1.4.2 \
--format '{{json .RepoDigests}}' > app-1.4.2-digest.json保存镜像:
docker save registry.example.com/team/app:1.4.2 -o app-1.4.2.tar
gzip app-1.4.2.tar
sha256sum app-1.4.2.tar.gz > app-1.4.2.tar.gz.sha256离线导入:
sha256sum -c app-1.4.2.tar.gz.sha256
gunzip -c app-1.4.2.tar.gz | docker load
docker image inspect registry.example.com/team/app:1.4.2不要用 latest 当生产恢复依据。latest 是可变标签,不是版本承诺。生产至少固定 tag,关键镜像还要记录 digest。
多个镜像一起保存:
docker save \
registry.example.com/team/app:1.4.2 \
mysql:8.4 \
redis:8 \
nginx:1.30 \
-o images-20260709.tar
sha256sum images-20260709.tar > images-20260709.tar.sha256恢复后用 Compose 验证:
docker compose config
docker compose up -d
docker compose ps
docker compose logs --tail=100访问后应该看到业务健康检查正常,数据库和 Redis 容器不反复重启。如果容器一直 Restarting,先看日志,再看 volume、配置和环境变量。
脚本也要备份
很多团队有备份文件,却没有恢复脚本。真正出事时,大家临时拼命令,很容易输错路径。
备份脚本、恢复脚本、巡检脚本建议进私有 Git 仓库或内部制品库。服务器上也要备一份。
恢复脚本开头建议先检查命令:
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "missing command: $1" >&2
exit 1
}
}
require_cmd tar
require_cmd sha256sum
require_cmd mysql
require_cmd redis-cli
require_cmd docker高危路径要保护:
target_dir="${1:?target dir required}"
case "$target_dir" in
/data/app|/data/app/*) ;;
*) echo "refuse dangerous target: $target_dir" >&2; exit 1 ;;
esac正式恢复前让操作者确认:
echo "restore target: $target_dir"
echo "backup file: $backup_file"
df -h "$target_dir" /backup
read -r -p "Type RESTORE to continue: " answer
[[ "$answer" == "RESTORE" ]] || exit 1这里要注意,恢复脚本不是越自动越好。删除、覆盖、回滚这类动作要让人确认,尤其是在生产环境。
定时任务和失败告警
备份定时任务最怕静悄悄失败。cron 默认环境很干净,手动能跑的脚本,放到 cron 里不一定能跑。
cron 示例:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@example.com
0 2 * * * flock -n /var/lock/mysql-backup.lock /opt/ops/bin/mysql-backup.sh >> /var/log/backup/mysql-backup.log 2>&1
30 3 * * * flock -n /var/lock/redis-backup.lock /opt/ops/bin/redis-backup.sh >> /var/log/backup/redis-backup.log 2>&1
0 4 * * * flock -n /var/lock/files-backup.lock /opt/ops/bin/files-backup.sh >> /var/log/backup/files-backup.log 2>&1flock 是为了避免上一次备份还没跑完,下一次又开始跑。
systemd timer 更适合生产:
# /etc/systemd/system/mysql-backup.service
[Unit]
Description=MySQL backup
[Service]
Type=oneshot
User=root
ExecStart=/opt/ops/bin/mysql-backup.sh# /etc/systemd/system/mysql-backup.timer
[Unit]
Description=Run MySQL backup every day
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=10m
[Install]
WantedBy=timers.target启用:
systemctl daemon-reload
systemctl enable --now mysql-backup.timer
systemctl list-timers --all | grep mysql-backup如果没有看到 timer,说明没启用成功。看日志:
journalctl -u mysql-backup.service -n 100 --no-pager脚本里可以加失败告警:
notify_fail() {
local msg="$1"
echo "[ERROR] $msg" >&2
if [[ -n "${WEBHOOK_URL:-}" ]]; then
curl -fsS -X POST \
-H 'Content-Type: application/json' \
-d "{\"text\":\"backup failed: ${msg}\"}" \
"$WEBHOOK_URL" >/dev/null || true
fi
}
trap 'notify_fail "line=$LINENO command=$BASH_COMMAND"' ERR还要检查备份新鲜度:
find /backup/mysql/full -mindepth 2 -maxdepth 2 -type f -name '*.sql.gz' -mtime -1 | grep -q .这条命令如果没有输出且返回失败,说明最近 24 小时没有找到 MySQL 全量备份。这个就应该告警。
异地备份:不要把鸡蛋放在同一块盘上
本机备份只能解决一部分问题。磁盘坏了、机器被误删、勒索软件把文件都加密了,本机备份可能一起没了。
一个实用方案是:备份机主动拉生产机备份。
rsync -aHAX --numeric-ids --partial --info=progress2 \
backup@prod-db-1:/backup/mysql/full/ \
/backup/offsite/prod-db-1/mysql/full/为什么推荐备份机拉,而不是生产机推?因为生产机如果被入侵,攻击者不一定能直接删除备份机上的历史备份。
如果用对象存储,要注意:
- 开启版本控制或对象锁。
- 保存 sha256 校验文件。
- 配生命周期,别让成本失控。
- 定期从对象存储下载一份做恢复演练。
离线介质也可以用,但要定期抽检:
加密归档
-> 写入移动硬盘或离线存储
-> 登记时间、操作者、校验值
-> 异地保存
-> 定期抽检能否读取硬盘放柜子里不等于安全。硬盘会坏,标签会丢,人也会忘。
中国大陆、内网和离线环境
国内和内网环境,最怕恢复时临时下载依赖。你以为可以现场 docker pull mysql:8.4,结果 Docker Hub 访问不了;你以为可以临时装 rsync,结果 yum 源也访问不了。
所以离线恢复包至少准备这些:
restore-kit/
README.md
SHA256SUMS
packages/
rpms/
debs/
images/
app-1.4.2.tar.gz
mysql-8.4.tar.gz
redis-7.4.tar.gz
nginx-1.30.tar.gz
scripts/
check-env.sh
restore-mysql.sh
restore-redis.sh
restore-files.sh
config/
docker-compose.yml
nginx/
systemd/
certs/
encrypted-certs.tar.gz.gpg
docs/
restore-runbook.md进入内网前生成校验:
find restore-kit -type f -print0 | sort -z | xargs -0 sha256sum > restore-kit/SHA256SUMS进入内网后复核:
cd restore-kit
sha256sum -c SHA256SUMSDocker 镜像不要依赖公共加速器和 latest。生产更稳的是私有镜像仓库、内部制品库、离线镜像包和 digest 清单。
内网证书也要提前想好:
- 公网域名但服务在内网,可以考虑 DNS-01。
- 完全内网域名,使用企业 CA。
- 离线环境,要有离线签发、根证书分发和到期巡检。
检查证书到期:
openssl x509 -in fullchain.pem -noout -enddate恢复演练:备份的最终验收
备份文件生成了,只能说明脚本跑过。真正验收是恢复演练。
MySQL 演练:
docker run -d --name mysql-restore-check \
-e MYSQL_ROOT_PASSWORD=Restore_check_123 \
-p 3307:3306 \
mysql:8.4
mysql -h127.0.0.1 -P3307 -uroot -pRestore_check_123 -e "SELECT VERSION()"
gunzip -c all-databases.sql.gz | mysql -h127.0.0.1 -P3307 -uroot -pRestore_check_123
mysql -h127.0.0.1 -P3307 -uroot -pRestore_check_123 -e "SHOW DATABASES"Redis 演练:
mkdir -p /tmp/redis-restore-check
cp dump.rdb /tmp/redis-restore-check/
docker run --rm -d --name redis-restore-check \
-p 6380:6379 \
-v /tmp/redis-restore-check:/data \
redis:8 redis-server --dir /data --dbfilename dump.rdb
redis-cli -p 6380 PING
redis-cli -p 6380 DBSIZE文件演练:
mkdir -p /tmp/files-restore-check
tar -xzf uploads-20260709_040000.tar.gz -C /tmp/files-restore-check
find /tmp/files-restore-check -type f | wc -l
du -sh /tmp/files-restore-check每次演练至少记录:
- 用的是哪一份备份。
- 恢复到哪台机器。
- 备份文件多大。
- 恢复花了多久。
- 验证了哪些接口或数据。
- 发现了哪些坑。
RTO 和 RPO 不要靠估计,要靠演练数据。
start_at=$(date +%s)
# restore commands here
end_at=$(date +%s)
echo "restore_seconds=$((end_at - start_at))"常见事故怎么处理
误删表或误删数据
先停写,再恢复到临时实例,不要直接在生产上试反向 SQL。
暂停写入
-> 确认误删时间
-> 保存当前 binlog
-> 全量备份恢复到临时实例
-> binlog 回放到误删前
-> 导出受影响数据
-> 回写生产
-> 业务验证磁盘损坏
先换盘或挂新盘,再恢复系统配置、镜像、数据库、文件和证书。
下线故障节点
-> 新盘挂载到原路径
-> 安装相同版本组件
-> 恢复配置和证书
-> 恢复数据库和文件
-> 启动服务
-> 跑健康检查
-> 立刻补一次新备份这里要注意,恢复完成后要马上跑一次备份。否则刚恢复完的一段时间没有新备份,也是风险窗口。
备份文件损坏
先停止清理任务,避免旧备份继续被删。
systemctl stop mysql-backup.timer 2>/dev/null || true
crontab -l然后按顺序找:
- 本机上一份备份。
- 异地备份。
- 对象存储历史版本。
- binlog、AOF、文件快照。
如果只有一份备份且损坏,说明策略本身就不合格。修复不是“下次小心”,而是增加多版本、异地、校验和演练。
排障速查
| 现象 | 常见原因 | 怎么看 | 怎么处理 |
|---|---|---|---|
| 备份文件 0 字节 | 命令失败但脚本继续跑 | ls -lh、脚本日志 | 加 set -euo pipefail,检查退出码 |
| 手动能跑,cron 不跑 | PATH 或环境变量不同 | cron 日志、脚本绝对路径 | 在 cron 设置 PATH,命令写绝对路径 |
/bin/bash^M | 脚本是 Windows 换行 | file backup.sh | sed -i 's/\r$//' backup.sh |
mysqldump: Access denied | 备份账号权限不足 | SHOW GRANTS | 补最小权限,别直接用 root 硬怼 |
| MySQL 恢复很慢 | 逻辑导入、索引重建耗时 | 看磁盘 IO、导入进度 | 大库改用物理备份或并行工具 |
| 找不到误删前数据 | binlog 未开启或已清理 | SHOW BINARY LOGS | 延长保留,异地保存 binlog |
| Redis RDB 失败 | fork 失败、磁盘满、权限错 | INFO persistence、日志 | 扩容、错峰、修权限 |
| Redis 恢复后数据不对 | AOF 覆盖 RDB | 看 appendonly 和启动日志 | 明确恢复 RDB 还是 AOF |
| tar 解压报错 | 文件损坏或传输中断 | gzip -t、sha256 | 换异地备份,修传输链路 |
| rsync 后文件少了 | --delete 目标写错 | --dry-run --itemize-changes | 加路径保护和人工确认 |
| Docker 恢复后数据库为空 | 只备镜像没备 volume | docker volume ls | 恢复数据库备份或 volume |
| HTTPS 起不来 | 证书链、私钥、权限、路径问题 | nginx -t、openssl x509 | 恢复完整证书目录和权限 |
| 离线现场缺工具 | 恢复包没带依赖 | command -v | restore-kit 带 rpm/deb、镜像和脚本 |
落地工程深水区
备份恢复的深水区不在备份命令,而在“坏事真的发生时还能不能按证据恢复”:
- RPO/RTO 要靠演练实测,不靠会议承诺。每次演练都记录开始时间、恢复完成时间、数据校验点和未达标原因。
- 备份要分层:本机快照用于快速回滚,异机/异地副本用于灾难恢复,长期归档用于审计和追责。三者不能互相替代。
- 加密和密钥也要能恢复。只备密文不备密钥,或者只备证书不备私钥,事故时等于没有备份。
- 校验要自动化。文件大小正常不代表可用,至少要有
sha256sum、压缩包解压校验、数据库临时实例导入校验和关键 SQL 校验。 - 恢复脚本要有路径保护和人工确认,尤其是
rm、rsync --delete、mysql < dump.sql、覆盖 volume 这类命令。 - 备份链路要被监控。备份失败、文件过小、远端同步失败、校验失败、binlog 断档、对象存储上传失败,都应该告警。
- 权限要分离。能读生产数据的人,不应该天然拥有删除历史备份的权限;能执行恢复的人,也不应该绕过审批覆盖生产。
- 业务一致性要提前写清。数据库能恢复,不代表订单、支付、库存、消息队列和第三方状态自动一致,跨系统修复要进入应急预案。
- 复盘要进入策略。每次恢复演练或事故恢复后,都要更新备份频率、保留周期、工具包、脚本和检查清单。
生产检查清单
上线前把下面这份清单过一遍:
最后再强调一次:备份的目标不是“目录里有文件”,而是“系统真的能回来”。只要没有恢复演练,这套备份就还没有验收。
官方与参考入口
- MySQL 8.4 Reference Manual: https://dev.mysql.com/doc/refman/8.4/en/
- MySQL mysqldump: https://dev.mysql.com/doc/refman/8.4/en/mysqldump-sql-format.html
- MySQL mysqlbinlog: https://dev.mysql.com/doc/refman/8.4/en/mysqlbinlog.html
- MySQL
SHOW BINARY LOG STATUS: https://dev.mysql.com/doc/refman/8.4/en/show-binary-log-status.html - MySQL InnoDB forced recovery: https://dev.mysql.com/doc/refman/8.4/en/forcing-innodb-recovery.html
- MySQL Shell dump/load utilities: https://dev.mysql.com/doc/mysql-shell/8.4/en/mysql-shell-utilities-dump-instance-schema.html
- Percona XtraBackup 8.4: https://docs.percona.com/percona-xtrabackup/8.4/index.html
- Redis persistence: https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/
- Docker volumes backup/restore: https://docs.docker.com/engine/storage/volumes/
- Docker image save: https://docs.docker.com/reference/cli/docker/image/save/
- Docker image load: https://docs.docker.com/reference/cli/docker/image/load/
- Nginx HTTPS servers: https://nginx.org/en/docs/http/configuring_https_servers.html
- Certbot user guide: https://eff-certbot.readthedocs.io/en/stable/using.html
- rsync manpage: https://download.samba.org/pub/rsync/rsync.1
- GNU tar manpage: https://man7.org/linux/man-pages/man1/tar.1.html
