MySQL 误删恢复:从全量备份到时间点恢复
备份任务显示成功,只能证明生成了一个文件。只有在隔离环境里把它恢复出来、重放 binlog 到正确的位置、再用业务规则校验通过,才能证明这是一个能用的恢复方案。
先看一次演练的时间线。订单库每 20ms 写入一笔合法订单,另一个会话误执行了一条删除:
t0 全量逻辑备份完成 订单 1—1000
t1 正常写入 订单 1001—1200
t2 误删 DELETE FROM order_items WHERE order_id > 0,删掉 3,675 行 GTID …:1239
误删之前已提交订单 1201—1225;与误删同一秒的有订单 1217—1250,其中 1226—1250 在它之后
t3 误删之后的合法写入 订单 1226—1250
t4 发现问题目标不是把数据库恢复到「最新」,因为最新状态里明细已经被删了;也不是简单地恢复到 t2 之前,那样会丢掉之后的 25 笔合法订单。正确的结果是:**误删那一笔不生效,其他所有写入都在。**本文用 MySQL 8.4 自带的能力完成这次恢复,并用一个没有发生误删的对照实例证明结果正确。
一、先说结论
- 恢复 = 全量备份 + binlog 重放。 备份只能把数据带回备份那一刻,之后的写入要靠 binlog;两者缺一不可,binlog 的保留期必须覆盖备份间隔。
- 用 GTID 截断,不用墙上时间。 实测误删所在的那一秒里有 34 笔合法订单,其中 25 笔在误删之后提交;按秒截断无论怎么选都会切错。按 GTID 可以精确停在误删之前,并跳过它继续重放。
- 恢复要在全新的隔离实例上做。 不在原库上试错,也不让恢复实例连到生产网络。
- 每一层成功都不等于恢复正确。 实测损坏一个字节的备份导入时退出码为 0;缺一个 binlog 文件时重放不报错。前者靠文件校验和发现,后者靠 GTID 连续性和业务校验和发现。
- replica、延迟副本和 Clone 都不是备份。 误操作会被复制过去;延迟副本提供的是发现窗口;Clone 复制的是当前状态。
二、先定义恢复目标
| 目标 | 问题 | 本次演练 |
|---|---|---|
| RPO | 最多允许丢失哪些写入 | 0:误删之外的合法写入全部恢复 |
| RTO | 从开始恢复到业务可用要多久 | 分三段记录:可读、可校验、可切换 |
| 恢复粒度 | 实例、库、表还是业务对象 | 整个 shop 库 |
| 正确性 | 怎样证明恢复结果是对的 | 与对照实例的业务校验和逐表比对,并检查业务不变量 |
业务不变量是最容易被忽略的一项。演练使用一个小型订单模型:订单、订单明细、库存、操作标记。每笔下单是一个事务,写一张订单、三条明细、扣三次库存、记一条连续编号的标记。于是有四条可以直接用 SQL 检查的规则:
- 每张订单的金额等于它的明细金额之和;
- 每个 SKU 的库存等于初始库存减去所有明细数量;
- 操作标记的编号从 1 开始连续;
- 没有找不到订单的明细。
误删之后,source 上 1,225 张订单的金额与明细不符,100 个 SKU 的库存与明细对不上。行数和表是否存在都查不出这类问题。
三、逻辑备份、物理备份与 Clone
| 方式 | 得到什么 | 适合 | 需要注意 |
|---|---|---|---|
逻辑备份(mysqldump、MySQL Shell dump) | SQL 或数据文件 | 中小数据量、跨版本迁移、只恢复部分库表 | 恢复要重新执行 SQL、重建索引,数据量大时很慢 |
| 物理备份(MySQL Enterprise Backup、Percona XtraBackup) | 数据文件的一致性拷贝 | 大数据量、要求恢复快 | 与版本、平台关系紧密;恢复单表更复杂 |
| Clone 插件 | 一个实例当前状态的完整复制 | 快速创建 replica、搭建测试实例 | 只有当前状态,没有历史;远程 Clone 会覆盖接收端的数据 |
延迟副本(SOURCE_DELAY) | 落后固定时间的 replica | 给误操作留出发现窗口 | 窗口过去就会执行同样的误操作;它本身也可能故障 |
这些方式都能成为恢复体系的一部分,但真正的备份需要满足:保留历史版本、与生产隔离(权限和存储都分开)、定期恢复验证。本次演练使用 mysqldump:
mysqldump --single-transaction --source-data=2 --set-gtid-purged=ON \
--routines --triggers --events --databases shop > full.sql--single-transaction在一个一致性快照里导出 InnoDB 表,不阻塞写入;--source-data=2以注释形式记录备份对应的 binlog 文件和位置;--set-gtid-purged=ON写入SET @@GLOBAL.GTID_PURGED,恢复实例导入后就知道哪些事务已经包含在备份里。
备份完成后记录退出码、文件大小和 SHA-256,一起存入备份清单。binlog 也要按同样的方式归档,每个文件都记录校验和。
四、在隔离实例上恢复并按 GTID 重放
4.1 定位误操作
恢复之前先找到那笔误操作的 GTID。可以从审计日志、变更系统的记录里找,也可以直接查 binlog:
SHOW BINLOG EVENTS IN 'binlog.000004';
-- … Gtid SET @@SESSION.GTID_NEXT= '…:1239'
-- … Table_map table_id: 94 (shop.order_items)
-- … Delete_rows …演练用脚本扫描归档的每个 binlog,找到唯一一笔删除 shop.order_items 的事务 …:1239。只凭时间戳定位是不够的:误删在 56.190 秒提交,同一秒内前后都有合法订单。
4.2 导入全量备份
在一个全新的实例上:
RESET BINARY LOGS AND GTIDS; -- 导入带 GTID_PURGED 的备份前,gtid_executed 必须为空
SOURCE full.sql;导入后先做一次基线校验:演练中四张表的业务校验和与对照实例在备份时刻完全相同。这一步证明备份本身是可用的,之后出现的差异就只可能来自重放。
4.3 用复制 SQL 线程重放 binlog
MySQL 8.4 的官方镜像只带 mysql、mysqldump 和 mysqlsh,没有 mysqlbinlog。演练用的是服务端自带的办法:把归档的 binlog 文件作为恢复实例的 relay log,交给复制的 SQL 线程去应用。
- 恢复实例启动时指定
--relay-log=restore-relay-bin与--skip-replica-start; - 把归档的 binlog 依次复制为
restore-relay-bin.000001、000002……,写好索引文件,重启实例; - 指向第一个文件,只启动 SQL 线程,停在误删之前:
CHANGE REPLICATION SOURCE TO SOURCE_HOST = 'binlog-archive',
RELAY_LOG_FILE = 'restore-relay-bin.000001', RELAY_LOG_POS = 4;
START REPLICA SQL_THREAD UNTIL SQL_BEFORE_GTIDS = '…:1239';SOURCE_HOST 只是占位,从不启动 IO 线程。已经包含在备份里的事务在 gtid_executed 中,SQL 线程会自动跳过,所以可以放心地把全部归档文件交给它。
SQL 线程停下后检查:订单 1,225 笔,正是误删之前已提交的全部订单,四条不变量全部成立。
4.4 跳过误删,补回之后的写入
用一个空事务占用误删的 GTID,再继续重放:
SET GTID_NEXT = '…:1239'; BEGIN; COMMIT; SET GTID_NEXT = 'AUTOMATIC';
START REPLICA SQL_THREAD;
SELECT WAIT_FOR_EXECUTED_GTID_SET('<发现问题时 source 的 gtid_executed>', 120);
STOP REPLICA;最终结果:订单 1,250 笔,四张表的业务校验和与对照实例完全相同,GTID 集合与 source 一致(误删的那一个以空事务占位)。
这里要注意:跳过误删是否安全取决于业务。如果误删之后的事务依赖被删除的数据,例如又基于空的明细表做了汇总,那么补回的写入也可能是错的,需要业务逐笔判断。
4.5 恢复耗时
| 阶段 | 从开始恢复起 |
|---|---|
| 可读:全量备份导入完成 | 0.4 s |
| 可校验:重放完成,业务校验和比对通过 | 5.7 s |
| 可切换:只读冒烟查询完成 | 5.8 s |
演练数据只有 1,250 笔订单,这些数字不能外推。真实环境中,逻辑备份的导入时间与数据量基本成正比,重放时间与 binlog 量成正比;恢复是否在 RTO 之内,只能用接近生产规模的数据定期演练来回答。
五、恢复后怎样证明正确
演练中故意制造了两个失败路径:
备份文件损坏。 把备份文件中库存表的一个数字改掉。SHA-256 与清单不一致,恢复流程在导入前停止。如果跳过这一步强行导入,mysql 的退出码是 0,表都在、行数也对,但库存的校验和与对照不同,有 1 个 SKU 不满足库存守恒。
归档缺了一个 binlog 文件。 去掉备份之后的第一个 binlog(订单 1001—1200 所在的文件)再重放。SQL 线程没有报任何错误,把后面文件里的事务照常应用完;但 GTID 集合中缺了 …:1014-1213,GTID_SUBSET(目标集合, 已执行集合) 为 0,订单只有 1,050 笔,业务校验和与对照不同。
所以恢复后的检查要分层:
- 文件校验和:备份和每个 binlog 与清单一致;
- GTID 连续:恢复实例的
gtid_executed包含目标集合,中间没有缺口; - 业务校验和:按业务字段(不含自增 id 和时间)逐表计算,与对照或源端比对;
- 业务不变量:金额、库存、状态机、连续编号;
- 只读冒烟:应用以只读方式访问关键数据。
对照实例在真实事故中通常不存在。可以用的替代是:误操作之前的源端校验和、业务侧的对账数据,以及不依赖对照的不变量。
六、延迟副本为什么不是备份
延迟副本按 SOURCE_DELAY 推迟应用事务,给了一段「误操作已经发生、副本上还没执行」的窗口。在窗口内发现问题,可以停止它的复制,用 START REPLICA UNTIL SQL_BEFORE_GTIDS 追到误操作之前,比从全量备份恢复快得多。
但它有明确的边界:
- 窗口过去之后,误操作一样会被执行;
- 它是另一个在线实例,可能与 source 一起故障,也在同一个权限域里,能删 source 的人通常也能删它;
- 实测配置
SOURCE_DELAY = 1时,实际延迟接近 2 秒,窗口的实际长度要实测,见 MySQL 故障切换 的读策略一节。
延迟副本、replica、快照和 Clone 都可以是恢复体系的一层,但都不能替代与生产隔离、保留历史、定期演练过的备份。
七、恢复演练 Runbook
- 冻结现场:停止相关写入或把受影响的表设为只读,保存误操作前后的 binlog,不在原库上试错。
- 定位误操作:确定 GTID、影响范围和发生时间;确认之后的写入是否依赖被破坏的数据。
- 准备隔离实例:全新实例、同版本,不连接生产网络。
- 校验并恢复全量备份:先核对 SHA-256,导入后做基线校验。
- 重放 binlog:检查归档文件的连续性,停在误操作之前;按业务决定跳过它、继续重放。
- 分层校验:GTID 连续、业务校验和、业务不变量、只读冒烟。
- 评审后切换:选择整体切换、只回灌部分表,或由业务补录;记录每一步的时间。
- 复盘:记录实际 RPO、RTO 和发现的问题,更新备份策略与演练脚本。
命令要按实际使用的备份工具编写,并在演练中反复验证,而不是事故发生时第一次执行。
八、常见误区
- 「备份任务成功了,就有备份」:没有恢复验证的备份,只是一个文件。
- 「导入没报错就恢复好了」:实测损坏的备份导入退出码为 0。
- 「binlog 重放不报错就是完整的」:实测缺一个文件时重放不报错,缺了 200 笔订单。
- 「按时间点恢复到误删前一秒就行」:同一秒内前后都有合法写入。
- 「有 replica / 延迟副本 / 快照,不用单独备份」:误操作会被复制,延迟窗口会过去,它们也和 source 在同一个权限域。
- 「
innodb_flush_log_at_trx_commit=1就不会丢数据」:它保护的是崩溃恢复,挡不住误操作,见 Redo Log 与 Undo Log。
小结
误删恢复的关键是三件事:备份和 binlog 都在,并且可以证明没有损坏、没有缺口;按 GTID 精确地停在误操作之前,并按业务决定是否补回之后的写入;恢复结果用业务校验和与不变量证明正确。这些都要在隔离实例上完成,并通过定期演练把命令和耗时固定下来。
配套实验
- codesphere-labs/storage/mysql-backup-pitr:全量逻辑备份、误删与合法写入并发、隔离实例上按 GTID 重放、与对照实例比对业务校验和,以及备份损坏与 binlog 缺口两个失败路径(验证记录)
参考资料