MySQL 误删数据怎么找回:clone 物理备份 + binlog 跳过误删事务实测,--stop-datetime 会把误删后 40 个事务一起丢掉
在 MySQL 8.0.46 测试库(orders 100 万行自造数据)上完整演练一次误删找回:DELETE 漏了条件删掉 157553 行,误删后业务又写了 40 个事务。clone 本地物理备份 0.35 秒;从备份起恢复实例、用 mysqlbinlog --exclude-gtids 只跳过误删那 1 个事务重放,0.8–1.7 秒,40 个事务一个不丢;另一条路是从 binlog 行镜像把 157553 行拼成 INSERT 直接插回源库,约 1 秒,两条路 CHECKSUM 一致。踩坑 4 个:本地 clone 不记 binlog 位点、--stop-datetime 丢后续事务、--skip-gtids 报 1062 或把建库语句重放一遍、GTID 留洞后挂从库会把误删重新拉回来。附只读就绪度 SQL 和可复跑脚本。
MySQL 8.0.46 实测(测试库、自造数据):一条漏了 status 条件的 DELETE 删掉 157553 行(本想删 15756 行),之后业务又提交了 40 个事务。我用 clone 本地物理备份(100 万行,354ms、194MB)起一个恢复实例,mysqlbinlog --exclude-gtids=<误删事务 GTID> 重放 binlog,0.78–0.88 秒(脚本复跑 1.7 秒),orders 恢复到 1007000 行、payments 205000 行,误删后的写入一个不少。只用 --stop-datetime 停在误删前,40 个事务全丢。不恢复整库也行:从 FULL 行镜像里把 157553 行拼回 INSERT,插回源库约 1.1 秒,CHECKSUM 与整库恢复结果一致。
适用对象
- 误删后业务往往还在写。只用 --stop-datetime 停在误删前,误删之后的事务也一起丢了:本次 40 个事务(2000 单 + 2000 笔支付)全没了。开了 GTID 的库,用 --exclude-gtids 只跳过误删那一个事务。
- 恢复实例从哪个 GTID 接着重放,不用记 binlog 位点:物理备份起来后 SELECT @@gtid_executed 就是备份点,重放时已执行过的 GTID 会被自动跳过。本地 clone 的 clone_status 里 BINLOG_FILE、GTID_EXECUTED 都是空的。
- 别加 --skip-gtids:从备份点之前开始重放会报 ERROR 1062 主键冲突;从文件开头重放则把 DROP/CREATE DATABASE 也执行一遍,原 GTID 全部变成新 UUID。
- 跳过的那个 GTID 会留下一个洞。恢复实例以后要挂成从库,先用空事务把洞补上,否则 AUTO_POSITION 会把误删事务从源库拉回来再执行一次,本次没有任何报错,orders 又变回 849447 行。
- binlog_row_image=FULL 时,删掉的整行都在 binlog 里,可以不恢复整库、直接拼 INSERT 插回;MINIMAL 下 DELETE 只记主键,这条路走不通。
我造的误删现场:备份之后写了 81 个事务,误删之后又写了 40 个
恢复演练最容易做假的地方,是误删之后就停手。真实情况是删完往往过几分钟才有人发现,这期间订单、支付还在进来。所以我在误删前后都安排了正常写入,GTID 编号也一并记下来,后面每一步都按 GTID 核对。
| 时间 | 动作 | GTID | 数据量 |
|---|---|---|---|
| 10:25:41 | CLONE LOCAL DATA DIRECTORY 物理备份,354ms,194MB | 备份点 1-8 | orders 1000000 / payments 200000 |
| 10:25:42–57 | 50 个事务各插 100 单、1 个事务改 2000 单状态、30 个事务各插 100 笔支付 | 9-89 | orders 1005000 / payments 203000 |
| 10:25:57 | DELETE FROM orders WHERE created_at < '2026-02-01'(漏了 AND status = 9),985ms | 90 | 删掉 157553 行,本想删 15756 行 |
| 10:26:00–07 | 误删后业务继续:20 轮,每轮插 100 单 + 100 笔支付 | 91-130 | orders 849447 / payments 205000 |
先找到误删是哪个 GTID:按时间窗看事件头,再解码确认行数
我先用 mysqlbinlog --start-datetime='2026-10-23 10:25:55' --stop-datetime='2026-10-23 10:26:00' 只看事件头,56ms 就看到一串 Delete_rows: table id 108,前面的 SET @@SESSION.GTID_NEXT 是 ...:90。时间窗不确定时,就把整个文件解码:mysqlbinlog -v --base64-output=decode-rows binlog.000002 用了 1597ms,输出 189MB、1058 万行文本,再用 awk 按 GTID 统计 ### DELETE FROM `shop`.`orders` 的行数,:90 恰好 157553 行。
顺带记下两个位置:GTID 事件在 # at 37857838,下一个事务 :91 从 # at 42809860 开始,事务长度 transaction_length=4952022 字节。要注意事件头上的时间是语句开始时间 10:25:57,真正提交是 original_commit_timestamp 里的 10:25:58.522737,按时间截断时差这 1.5 秒就可能截错。
本地 clone 的 clone_status 里没有 binlog 位点,那从哪接着重放?
按以前用 xtrabackup 的习惯,我去找备份点的 binlog 文件和位置。CLONE LOCAL 完成后查 performance_schema.clone_status:STATE=Completed,但 BINLOG_FILE 是空的、BINLOG_POSITION=0、GTID_EXECUTED 也是空的,下载版脚本复跑一次还是这样。
开了 GTID 就不需要这个位点。把 clone 目录复制一份、起一个恢复实例(cp 86–184ms,启动 1.4–1.9 秒),SELECT @@gtid_executed 返回 ...:1-8,和 mysql.gtid_executed 表里的 1–8 一致,就是备份点。之后把 binlog 从文件开头整段喂给恢复实例,1–8 号事务会因为已执行而被自动跳过。
另一个小坑:同一个目录不能 clone 两次,第二次报 ERROR 1007 (HY000): Can't create database '/opt/mysql/backup/clone-1'; database exists。定时备份脚本要每次换目录名。
--exclude-gtids 只跳过误删那 1 个事务:0.8 秒重放完,40 个后续事务一个不少
命令就一行:mysqlbinlog --exclude-gtids='<uuid>:90' binlog.000002 binlog.000003 | mysql -S <恢复实例>。多个 binlog 文件放在同一条 mysqlbinlog 命令里一次喂进去,不要拆成多条分别执行。
r1 用了 883ms,r3 从位置 1788 起重放用了 783ms,下载版脚本复跑(binlog 更多)1723ms。重放后 gtid_executed 是 ...:1-89:91-130,orders 1007000 行(= 849447 + 157553)、payments 205000 行。我又对没被误删的部分(created_at >= '2026-02-01')在源库和恢复实例上各算一遍逐行哈希 BIT_XOR(CRC32(CONCAT_WS(...))),两边都是 849447 行、2791325613,一致。
| 恢复实例 | mysqlbinlog 参数 | 耗时 | orders / payments | 误删后 40 个事务 |
|---|---|---|---|---|
| r1 | --exclude-gtids=...:90 | 883 ms | 1007000 / 205000 | 都在 |
| r2 | --stop-datetime='2026-10-23 10:25:57' | 871 ms | 1005000 / 203000 | 全丢(after-accident 订单 0 行) |
| r3(第二次) | --start-position=1788 --exclude-gtids=...:90 | 783 ms | 1007000 / 205000 | 都在 |
| 下载版脚本 | --exclude-gtids=...:384(pitr_lab) | 1723 ms | 1007000 / 205000 | 都在 |
恢复实例里的 payments 行数(误删后又写了 2000 笔)
源库现状
205000
--exclude-gtids
205000
--stop-datetime
203000
MySQL 8.0.46,测试库、自造数据。
--stop-datetime:网上教程的标准答案,丢了误删后的 40 个事务
很多恢复教程写的是:备份 + mysqlbinlog --stop-datetime=误删时间 重放。我在 r2 上照做,停在 10:25:57,结果是 orders 1005000、payments 203000,gtid_executed 停在 ...:1-89。误删本身是避开了,但 10:26:00 之后进来的 2000 单和 2000 笔支付都不在。
这种结果只适合整库回到过去、之后的数据另外补的场景。生产上更常见的需求是只撤销那一条语句,其他写入都留着。没开 GTID 时可以用 --stop-position 到误删前、再用 --start-position 从下一个事务开头接着放,两段重放;开了 GTID 就直接 --exclude-gtids,不容易算错位置。
--skip-gtids:要么 ERROR 1062,要么把建库语句也重放一遍
有人会在重放报 GTID 相关错误时加上 --skip-gtids。我在 r3 上从备份点之前的位置 1788(:7,也就是装 100 万行的那个大 INSERT)开始加 --skip-gtids 重放,第一条就报 ERROR 1062 (23000) at line 7852: Duplicate entry '1' for key 'orders.PRIMARY':没有 GTID 去重,备份里已有的行被再插一次。去掉 --skip-gtids、同一个起点,783ms 正常完成。
更隐蔽的是另一种情况。我第一次在 r3 上从文件开头加 --skip-gtids 重放,居然没报错,orders 也是 1007000:因为这个测试 binlog 恰好从 DROP DATABASE IF EXISTS shop 开始,它把克隆来的库删掉、从零重建了一遍,用了 6454ms。gtid_executed 也变成了恢复实例自己的新 UUID 1-129,原来的 GTID 全部对不上。生产库的 binlog 不会从建库开始,这样跑多半是前一种报错。
跳过的 GTID 是个洞:恢复实例挂成从库,误删会被重新拉回来
恢复完我想把 r1 挂成源库的从库,追上之后再切换。CHANGE REPLICATION SOURCE TO ... SOURCE_AUTO_POSITION=1 之后 START REPLICA,Replica_IO_Running / Replica_SQL_Running 都是 Yes、Last_Error 为空,看起来一切正常;但 Retrieved_Gtid_Set 是 ...:90:131-132,orders 又变回 849447 行,1 月的数据 0 行。自动定位发现从库缺 :90,就从源库把误删事务拉过来执行了。
处理办法是在恢复实例上用空事务占住这个 GTID:SET GTID_NEXT='<uuid>:90'; BEGIN; COMMIT; SET GTID_NEXT='AUTOMATIC';。我在 r4 上重放后先补空事务、再挂从库,Retrieved_Gtid_Set 为空,orders 保持 1007000、1 月数据 157553 行。
| 实例 | 挂从库前 gtid_executed | 挂从库后 | orders | 有没有报错 |
|---|---|---|---|---|
| r1(不补) | ...:1-89:91-130 | Retrieved ...:90:131-132 | 849447(误删又执行了一遍) | 没有,IO/SQL 线程都是 Yes |
| r4(补空事务) | ...:1-89:91-130,再补 :90 空事务 | Retrieved 为空 | 1007000 | 没有 |
不恢复整库:从 binlog 行镜像把 157553 行拼回 INSERT,约 1 秒
整库恢复要起实例、要磁盘,恢复完还得把数据导回生产。如果只是一个 DELETE 删多了,ROW 格式加 binlog_row_image=FULL 时,被删的每一行完整内容都在 binlog 里。我用 mysqlbinlog --include-gtids='<uuid>:90' -v --base64-output=decode-rows 只解出这一个事务,用一个 40 行的 Python 脚本把 ### DELETE FROM ... ### WHERE @1=... @6=... 转成每批 1000 行的 INSERT。
实测:解析 1055–1757ms,生成 8.2–8.4MB 的 SQL;插回源库 1078–1109ms。插回后源库 orders 1007000 行,CHECKSUM TABLE orders 为 3554064467,和走整库恢复的 r4 完全一样,payments 也一样(43693882)。下载版脚本复跑一轮两边也一致(1375833456)。
这条路有前提,我只验证了本次这种最简单的情况:表没有外键、触发器,被删的行之后没人再用同一主键插入或修改。字符串里有引号、二进制、JSON 等类型时,40 行脚本不一定能正确转义,本次没测,用前先在恢复实例上试。
binlog_row_image=MINIMAL 时,DELETE 只记主键
为了确认这条路的前提,我在会话里 SET binlog_row_image=MINIMAL,删了 id 为 2、3 的两行,再解码那个 binlog:每行只有 ### WHERE ### @1=2,没有其他列,脚本只能拼出 (2) 这种残缺的 VALUES。也就是说,MINIMAL 下 binlog 仍然能做整库时间点恢复,但没法从里面把整行拿回来。这两行我从恢复实例 r4 里取出来插回源库,CHECKSUM TABLE orders 回到 3554064467。
所以我做体检时会先看 binlog_format、binlog_row_image、binlog 保留天数(binlog_expire_logs_seconds,本次默认 2592000 秒即 30 天)和最近一次物理备份时间,这几项都在下面的只读 SQL 里。
这次演练各步骤耗时:能直接填进 RTO 记录表
100 万行、194MB 的测试库,从发现到恢复实例可用,机器时间不到 10 秒。生产库的数据量和 binlog 量大得多,真正的耗时要用自己的库演练出来;我更关心的是步骤有没有漏:确认误删 GTID、确认备份点、重放、核对行数和哈希、处理 GTID 洞。
| 步骤 | 第 1 轮 | 脚本复跑 | 说明 |
|---|---|---|---|
| CLONE LOCAL 物理备份 | 354 ms / 194MB | 760 ms / 316MB | 复跑时实例里多了一个库 |
| 定位误删 GTID | 56 ms(时间窗)/ 1597 ms(全解码) | 3978 ms(全解码 + 统计) | 解码输出 189MB 文本 |
| 复制 clone 目录 | 86–184 ms | 328 ms | cp -a |
| 启动恢复实例 | 1444–1852 ms | 1657 ms | |
| --exclude-gtids 重放 | 783–883 ms | 1723 ms | |
| 行镜像拼 INSERT + 插回 | 1757 + 1109 ms | 1055 + 1078 ms | 不起恢复实例 |
我做误删找回的顺序
- 01
1
先止损
停掉误操作的任务或账号;不要 FLUSH、PURGE binlog,确认 binlog 保留期覆盖到备份点。
- 02
2
定位 GTID
按时间窗看事件头,再解码按 GTID 统计 DELETE/UPDATE 行数,确认误删事务和行数。
- 03
3
选路线
只删多了、行镜像 FULL:拼 INSERT 插回;DDL 误删表或范围不清:从物理备份起恢复实例重放。
- 04
4
核对
恢复实例和源库对比行数、逐行哈希或 CHECKSUM TABLE,确认误删后的事务都在。
- 05
5
收尾
恢复实例要挂从库就先补空事务;记录每步耗时进 RTO 表。
在自己的库上先查:这次误删能不能找回
下载区的只读 SQL 只有 SELECT,跑一遍能回答:binlog 开没开、是不是 ROW + FULL、保留几天、GTID 范围和 gtid_purged、clone 插件在不在、上一次 clone 的状态、谁有 DELETE/DROP 权限。第 5 段查最近影响行数 ≥ 1 万的语句,依赖 events_statements_history_long,这个 consumer 默认关闭,我的测试实例上就是空结果。
演示脚本会建 pitr_lab、做 clone、误删、起第二个 mysqld(默认端口 3399),最后把误删的行插回源库,只能在测试实例上跑。
本节判断
- log_bin=0:没有 binlog,只能回到最近一次备份,之后的数据全靠业务补。
- binlog_row_image 不是 FULL:仍可整库时间点恢复,但不能直接从 binlog 拿回整行。
- gtid_purged 比最近一次物理备份的 GTID 还新:备份和 binlog 接不上,这个备份已经不能用于时间点恢复。
- binlog 保留天数比备份间隔短:同上,备份周期中间的误删可能找不回。
觉得有用?转给正在做这件事的同事或朋友
参考依据
以下来源用于确认市场趋势、政策背景和术语边界;具体落地方案仍以客户的数据范围、权限和交付目标为准。
常见问题
MySQL 误删数据能恢复吗?
开了 binlog 就有办法。本次 MySQL 8.0.46 测试库误删 157553 行,用 clone 物理备份加 mysqlbinlog --exclude-gtids 跳过误删事务重放,或者从 FULL 行镜像拼 INSERT 插回,两种方法都完整找回,CHECKSUM 一致。
mysqlbinlog 恢复时用 --stop-datetime 还是 --exclude-gtids?
只想撤销误删、保留之后的写入,用 --exclude-gtids。本次只用 --stop-datetime 停在误删前,误删后的 40 个事务(2000 单 + 2000 笔支付)全丢了。
clone 备份之后 binlog 从哪个位置开始重放?
开了 GTID 就不用找位置:恢复实例启动后 SELECT @@gtid_executed 就是备份点,从 binlog 开头整段重放,已执行的 GTID 会自动跳过。本地 clone 的 clone_status 里 BINLOG_FILE 和 GTID_EXECUTED 是空的。
恢复用的 binlog 重放报主键重复 1062 怎么办?
先看命令里是不是加了 --skip-gtids。本次加了它从备份点之前开始重放,立即报 ERROR 1062;去掉后同一起点 783ms 正常完成。
恢复出来的实例能直接挂成从库吗?
先把跳过的 GTID 用空事务补上。本次没补就挂从库,复制没有任何报错,却把误删事务从源库拉回来又执行了一遍,orders 变回 849447 行。