凌晨两点的定时备份,天天在跑、日志天天在写、备份目录天天生成——直到需要恢复的那一刻,才发现最后一个能用的恢复点停在 33 天前。
这是一套跑了很久的 CentOS 7 + MySQL 5.7 + Percona XtraBackup 2.4 组合:每周一次全量、每日增量、qpress 压缩、凌晨 cron 自动执行,看上去是教科书级的配置。但从 8 月 7 日起的每一次备份都失败了,而失败只安静地躺在日志文件里——没有任何推送、任何告警。根因是备份窗口撞上了 MySQL 5.7 免 redo 的在线 DDL,XtraBackup 主动中止;旧备份脚本的几个设计缺陷,又把”一次本可以重试的自中止”放大成了”静默断裂一个月”。
本文完整复盘这次事故,并给出一套翻修后的生产级备份脚本:基准有效性校验、DDL 窗口自动重试、半成品清理、链式维护、钉钉结果推送、本机防呆——外加一个很多人不知道的大坑:xtrabackup 的 --host 参数只是”协调端”,要备份的数据永远从本机磁盘复制。
一、事故现场:备份在跑,链早断了
先把环境和事实钉死:
| 项目 | 配置 |
|---|---|
| 操作系统 / 数据库 | CentOS 7 / MySQL 5.7.26 |
| 备份工具 | Percona XtraBackup 2.4.29(适配 MySQL 5.7) |
| 备份策略 | 每周一次全量 + 每日增量,凌晨 02:00 cron 执行 |
| 压缩 | --compress(qpress),并行 4 线程 |
| 存储结构 | /data/backup 下 base_日期_时间(全量)与 inc_日期_时间(增量) |
巡检发现的事实同样工整:
| 检查项 | 结果 |
|---|---|
base_2026-07-31_020001 |
有效(目录完整、有 xtrabackup_checkpoints) |
| 08-07 之后的每一次备份 | 全部失败,目录是半成品或干脆没有生成 |
| 失败通知 | 无——旧脚本没有任何推送机制,失败只写日志 |
| 净效果 | 可恢复点停在 7 月 31 日,RPO 缺口 33 天 |
失败日志里反复出现同一段三行报错:
1 | InnoDB: Last flushed lsn: 636724032437 load_index lsn 4307682970222 |
这里先给出第一个关键认知:这不是 XtraBackup 的 bug,也不是数据库损坏,而是一次保护性中止。PXB 检测到自身无法保证一致性时,宁可放弃也不产出一份”看起来成功、恢复时才发现是坏的”的备份。真正的问题在于:中止之后没有任何人知道,也没有任何机制把它救回来。
二、根因:免 redo 的在线 DDL 为什么会中止物理备份
2.1 XtraBackup 的一致性靠什么
物理备份不是原子快照。备份一个几十 GB 的库要十几分钟,这期间数据页一直在变——xtrabackup 一边拷贝数据文件,一边持续追着复制 redo log,恢复(prepare)阶段用 redo 把”不同时刻拷出来的页”回放到同一个一致点。一句话:数据文件 + 覆盖备份窗口的 redo = 一致性。
2.2 MySQL 5.7 的”优化 DDL”恰恰绕开了 redo
5.7 对 ALTER TABLE ADD INDEX 的 bulk 阶段、OPTIMIZE TABLE 等 inplace 操作做了优化:批量构建索引数据时不写 redo(几十万页的索引数据写 redo 既慢又占空间),改靠 page cleaner 把脏页刷盘保证持久性。
平时这没有任何问题。但备份窗口里撞上它,PXB 的前提就被打破了:
sequenceDiagram
autonumber
participant X as xtrabackup
participant D as MySQL 5.7
X->>D: 建立连接 开始拷贝数据文件
D-->>D: 凌晨维护任务执行免 redo 在线 DDL
Note over D: bulk 阶段修改的数据页不写 redo
X->>X: 检测到存在 redo 覆盖不到的脏页
X->>X: 主动中止并提示 Retry the backup operation
日志里 Last flushed lsn(已刷盘的 LSN)与 load_index lsn(免 redo 操作推进到的 LSN)之间的巨大落差,就是”有一段页修改没有落在 redo 里”的直接证据。此时若强行继续,产出的备份在 prepare 阶段必然无法自洽——所以 PXB 选择中止。
2.3 结论与工程对策
- 库本身无损:DDL 不写 redo 是官方设计,靠刷盘保证持久性,不影响业务数据;
- 重试即可:DDL 结束、脏页刷盘完成后重跑备份就能成功——我们在低峰期手动重试验证过;
- 两个长期对策:① 备份窗口与维护任务错峰(本文的脚本后来改到 04:40 执行,DDL 任务仍在 02:00 档);② 让脚本具备”识别这种失败并自动等待重试”的能力,见第四节。
三、旧脚本的四个设计缺陷:把”一次中止”放大成”33 天断裂”
拿到生产的脚本和它更早的版本做 diff,定位到四个缺陷。它们单独看都不致命,叠加起来就是”静默断裂一个月”:
| # | 缺陷 | 后果 |
|---|---|---|
| 1 | 过期清理的 find -regex 写法在实际方言下永远匹配不到任何目录 |
保留策略形同虚设,历史目录无限堆积 |
| 2 | 增量基准选择不做有效性校验(ls -1dt ... | head -1 直接取最新) |
任何一个半成品目录都会被选为基准,之后的增量连环失败 |
| 3 | 备份失败即 exit 1 |
脚本尾部的清理段是死代码——失败路径永远走不到清理 |
| 4 | 失败不清理半成品、无任何通知 | 磁盘堆积坏目录 + 无人知晓,失败 33 天没人发现 |
第 1 条值得展开,因为它非常隐蔽。旧代码:
1 | find "$BACKUP_BASE_DIR" -maxdepth 1 -type d \ |
GNU find 的 -regex 默认是 emacs 方言——在 emacs 正则里,量词必须转义(\+ 才是”一个或多个”,裸 + 是字面量加号)。于是 [0-9_-]+ 的真实含义是”字符类里的一个字符,后面跟一个字面量 +“。目录名里永远不会有 +,这条 find 从上线第一天起就没删过任何东西。这也是我后来重构时干脆不用 regex、直接 glob 的原因:
1 | find "$BACKUP_BASE_DIR"/base_* "$BACKUP_BASE_DIR"/inc_* -maxdepth 0 -type d \ |
glob 由 shell 展开,没有方言问题;-maxdepth 0 保证只作用于 base_*/inc_* 本身。
四、翻修设计:让链条”断不了、断了也知道”
翻修后的整体决策流程:
flowchart TD
S["脚本启动"] --> V{"BACKUP_HOST 是本机地址?"}
V -->|"否"| X["拒绝执行并推送告警"]
V -->|"是"| L["扫描 base 目录找最新有效全量"]
L --> N{"有有效全量且间隔小于 7 天?"}
N -->|"否"| F["全量备份"]
N -->|"是"| I["从新到旧挑选增量基准"]
I --> Y{"基准完整且链根仍在?"}
Y -->|"否 跳过并记日志"| I
Y -->|"是"| INC["增量备份"]
F --> R{"成功?"}
INC --> R
R -->|"失败且日志含重试提示"| W["清理半成品 等待后重试"]
W --> R
R -->|"成功"| C["清理过期备份与孤儿增量"]
R -->|"最终失败"| C
C --> D["钉钉推送结果并以退出码汇报"]
4.1 有效性校验:xtrabackup_checkpoints 是唯一的”完整体”标志
xtrabackup 只在成功收尾时才写 xtrabackup_checkpoints(内含 backup_type = full-backuped / incremental 行)。失败、中断的半成品目录里没有这个文件。于是它就是最可靠的有效性判据:
1 | is_valid_backup() { |
选全量基准时从新到旧扫描,第一个通过校验的才用;半成品自动跳过并记日志。一个坏目录从此只能损失它自己那一次,毒不到后面的增量。
4.2 增量基准:还要校验”链根”
增量目录有效还不够——它的链根全量必须还在。选择逻辑从新到旧扫全部目录:候选若是 inc_*,就反查比它更早的最新有效 base_* 是否存在,不存在说明链根已丢,这个增量恢复了也拼不出完整链条,跳过继续找。这条与 4.6 的链式清理配合,保证被选为基准的目录一定属于一条完整活链。
4.3 DDL 窗口自动重试
第二节结论说”重试即可”,那就让脚本自己重试。判据就是日志里的官方提示串:
1 | if grep -q "Retry the backup operation" "$LOG_FILE" && [ "$ATTEMPT" -le "$RETRY_TIMES" ]; then |
默认重试 2 次、间隔 30 分钟——足够等一个凌晨 DDL 窗口结束加脏页刷盘。窗口内第 1 次撞上、第 2 次成功,是翻修后最常见的形态。
4.4 失败半成品清理
任何一次最终失败(含重试耗尽),都把半成品目录删掉,同时把 rm -rf 约束在备份根目录之下,防止配置错误时误删别处:
1 | case "$TARGET_DIR" in |
4.5 失败不中断维护,退出码汇报
旧版”失败即 exit”让清理段变成死代码。翻修后全程只置 EXIT_CODE=1 不退出,过期清理照常执行,最后统一 exit "$EXIT_CODE"——cron 侧能拿到非零码,清理也不会因为一次失败停摆一个月。
4.6 链式清理:孤儿增量
链式备份有一个容易被忽略的维护点。恢复一个增量需要”链根全量 + 之前所有增量”按序应用,所以链根全量被过期清理后,挂在它链上、比它更晚的增量全部失效——这些目录留着既占磁盘,又会在人工选链恢复时误导判断。
磁盘上识别它们的方法:凡是比现存最老全量更早的增量,链根必然已被清理(增量不可能早于自己的链根,而比它早的全量都已不在了):
1 | OLDEST_BASE=$(ls -1dt "$BACKUP_BASE_DIR"/base_* 2>/dev/null | tail -1) |
注意这里方向别说反:以”被删的全量”为参照,失效的是它链上更晚的增量;以”现存最老全量”为参照,待清理的是比它更早的增量。两种说法描述的是同一批目录,参照物一换,”更早/更晚”就互换——写文档时值得多看一眼。
4.7 钉钉结果推送:把静默失败变成到人告警
这次事故最大的教训不是根因,而是33 天没人知道。翻修版内置了钉钉机器人推送,成功失败都推,加签模式只依赖 openssl + curl,无需任何第三方客户端:
1 | # 签名 = base64(hmac_sha256(secret, "timestamp\nsecret")),再 URL 编码 |
推送文本带服务器、备份类型、目录、耗时、日志路径;推送失败只记日志、绝不影响备份流程与退出码。
五、最大的坑:--host 只是协调端,数据永远来自本机磁盘
翻修过程中还踩到一个更有普遍价值的坑,单独成节。
5.1 现象:连的是测试库,备份出来 47GB 生产数据
给一台机器部署脚本时,连接信息(host/账号/密码)误填成了另一套环境的。执行后一切”正常”:日志显示连的是那台小库,钉钉推送里写的也是它——但看备份产物,47GB,分明是本机这套大库的体量;那台小库的全量备份才 2.8GB。连的 A 库,备份出来的是 B 库的数据?
5.2 原理:xtrabackup 根本不通过 MySQL 连接搬数据
对比一下两类备份工具:
| mysqldump 等逻辑备份 | xtrabackup 物理备份 | |
|---|---|---|
| 数据通道 | MySQL 协议连接(走网络) | 本地文件系统直接读 |
--host 决定什么 |
备份谁的数据 | 只决定跟哪个实例协调 |
| 能否备份远程库 | 可以 | 不能(要远程得配 ssh/stream) |
xtrabackup 的连接只做三件事:版本检查、备份锁(FTWRL / backup locks)、取 LSN 与 binlog 位点等元数据。要复制的数据文件,永远从运行脚本这台机器的本地 datadir 路径读取:
flowchart LR
subgraph SERVER["运行脚本的备份服务器"]
SCRIPT["mysql_backup.sh"] -->|"控制面 MySQL 协议连接"| CONN["xtrabackup 协调端"]
SCRIPT -->|"数据面 直接读本地文件"| FILES["本机 datadir 数据文件"]
FILES --> OUT["备份产物 target-dir"]
end
CONN -.->|"只提供锁 LSN 位点等元数据"| DB["host 指向的实例 可以是另一台机器"]
所以 host 填错环境时:复制的是本机的数据文件(47GB 的生产数据),协调的却是另一台实例。日志里其实留有证据——recognized server arguments 这一行打印在 version_check Connecting 之前,它读的是本机 /etc/my.cnf(里面 32G 的 buffer pool 配置正是这台大库机器的体量),此时还没连任何库。
5.3 为什么这种备份”看似成功、实则不可恢复”
- 备份锁加在了错误的实例上,本机实例在备份窗口内照常写入、无人协调;
- LSN、binlog 位点等元数据全部来自错误的实例,与本地数据文件对不上;
- 数据页复制与 redo 跟随发生在本地文件上,所以这种错配不必然立刻报错——exit 0 与”可恢复”之间没有任何必然联系。
结论:只要发现 host 指向了别的机器,这次备份无论成败一律按无效处理。
5.4 判别实验与防呆
现场判别一条命令就够:在跑脚本的机器上 hostname -I,对比 host 是不是本机地址。
但靠人记得检查不叫工程,翻修版把检查写进了脚本——启动时拿 BACKUP_HOST 与 127.0.0.1 / localhost / hostname -I 列出的本机所有网卡 IP 比对,对不上直接拒绝执行并推送告警:
1 | if [ -n "$BACKUP_HOST" ]; then |
设计上放行”本机任意网卡 IP”(连自己的内网 IP 语义上仍是本机实例),只拒绝真正指向其他机器的情况;校验在任何备份动作之前,不产生半成品。备份脚本的 BACKUP_HOST 应当固定写 127.0.0.1,从根上消除跨环境连错的可能。
六、恢复侧:链式应用原理
脚本产出的备份链按下面方式恢复(配套恢复脚本已自动化,这里讲清原理):
flowchart LR
A["全量 prepare 加 --apply-log-only"] --> B["按序应用增量1 仍加 --apply-log-only"]
B --> C["按序应用增量2 仍加 --apply-log-only"]
C --> D["最后一次 prepare 不加参数"]
D --> E["copy-back 回数据目录并 chown mysql"]
两条铁律:
- **中间每一次 prepare 都加
--apply-log-only**:只前滚、不回滚,保持备份处于”可继续追加增量”的状态; - 链上最后一次 prepare 不加:回滚未提交事务,把数据推到一致点。
注意事项:增量链必须完整连续,链中任何一个增量缺失,其后的增量全部失效;操作前停 MySQL、清空数据目录;建议全程在临时副本上进行,原备份目录不动,失败还能重来;恢复后修正数据目录属主再启动。任何备份方案,只有经过恢复验证才算有效——建议每季度在测试环境演练一次完整恢复。
七、完整脚本
以下是翻修后的完整脚本(敏感项已替换为占位符)。依赖仅 xtrabackup、openssl、curl:
1 |
|
八、部署清单与验证
新机器部署/升级旧脚本时按这个顺序过一遍:
- 传脚本:Windows 上编辑过的脚本先
dos2unix,再bash -n过语法,chmod 700(内含密码与钉钉密钥,不给其他用户读权限); - 回环连通预检:
mysql -h127.0.0.1 -P3306 -u<user> -p -e "select 1"确认本机实例监听回环、账号授权了本机来源;不行就退而填本机内网 IP(防呆同样放行); - 手动跑一次全量:低峰期执行脚本,确认日志出现”备份成功”、
xtrabackup_checkpoints生成、钉钉收到成功推送——三个信号齐了才交给 cron; - 错峰:cron 时间与已知的维护任务(DDL、批处理)拉开距离,脚本内的自动重试是兜底而不是常规路径;
- 异机保存:本机磁盘只是第一道防线,定期把
/data/backup下的目录 rsync 到其他机器,防范服务器本身故障; - 恢复演练:每季度在测试环境做一次完整链式恢复。备份的价值只在恢复的那一刻兑现,没有恢复验证过的备份等于没有备份。
小结
- **重新定义”备份成功”**:不是 cron 跑了、日志写了,而是——目录带
xtrabackup_checkpoints、失败能推送到人、并且真的演练恢复过; - 静默失败是最危险的失败:这次事故的根因(DDL 撞窗)其实无害,致命的是 33 天无人知晓——结果通知和退出码是备份脚本的一等公民,不是可选项;
- 物理备份工具先分清数据面与控制面:
--host只是协调端,数据永远来自本机datadir。填错 host 不会报错,只会产出一份看似成功的废备份——所以把”只连本机”写进脚本做硬校验,防呆优于文档; - **链式备份的维护成本在”链”不在”份”**:基准有效性、链根完整性、孤儿增量清理,任何一环失守,单份备份再成功也拼不出恢复点;
- 正则方言是隐形雷区:
find -regex默认 emacs 方言下裸+是字面量,一条”看起来对”的清理规则可以安静地失效几年。能用 glob 解决的事就别用正则。