那一刻真的能听到自己心跳声。
上周五下午三点,测试环境的DBA——其实也是我,因为小团队一人多角——在执行例行表空间整理脚本时,手抖了一下。本来想执行的是 DROP TABLE old_orders_archive;,结果因为终端窗口重叠,光标错位,实际执行的SQL变成了:
DELETE FROM orders WHERE create_time < '2023-01-01';
等等,这SQL看着没毛病啊?哦不对……我明明加的是 LIMIT 10000,结果忘记加了!而 orders 表是生产环境的镜像库,虽然数据量比生产小,但好歹也有 120万条 2022年之前的历史订单。
屏幕刷了刷,3204个affected rows……不对,怎么一直在跳?5万、10万、50万……最后定格在 987,642。
我整个人僵在椅子上。脑子里只有三个字:完了。
但奇怪的是,我并没有想象中那么绝望。因为我知道,我们团队坚持了一个习惯:开启binlog,且格式为ROW。 而且,我们有一套成熟的 pt-archiver 归档流程。
今天就把这次惊魂未定的7分钟恢复实战,连同如何避免再次踩坑,完整拆解给你看。不废话,直接上干货。
一、 黄金7分钟:我们是怎么救回来的
1.1 第一反应:止损,而不是慌
看到删除完成的那一刻,我的第一操作不是重启数据库,也不是去找领导汇报(虽然后来还是汇报了),而是:
立刻停止写入!
为什么?因为 binlog 是追加日志。如果持续有新数据写入,binlog 会不断滚动,旧的 binlog 可能被 purge 掉,或者我们混淆时间线。在MySQL中,可以通过以下方式临时只读:
-- 如果是主库,需要谨慎,测试环境直接
SET GLOBAL read_only = ON;
FLUSH TABLES WITH READ LOCK;
这一步大概用了30秒。同时,我迅速确认了几个关键信息:
- binlog 是否开启?
SHOW VARIABLES LIKE 'log_bin';→ ON - binlog 格式?
SHOW VARIABLES LIKE 'binlog_format';→ ROW - binlog 保留时间?
SHOW VARIABLES LIKE 'expire_logs_days';→ 7天 - 删除发生的具体时间?从慢查询日志和监控,定位到 15:02:33 开始,15:03:15 结束。
1.2 定位“灾难窗口”的binlog文件
接下来,我要知道删除操作发生在哪个 binlog 文件里。
SHOW BINARY LOG STATUS;
或者在shell下用 mysqlbinlog 直接搜索,效率更高:
mysqlbinlog --start-position=194 --stop-position=12345678 /var/log/mysql/mysql-bin.000042 | grep -n "DELETE FROM"
但更智能的做法是,利用 pt-query-digest 或直接扫描binlog内容。我当时的操作是:
# 找出包含我们表名和DELETE关键字的binlog位置
mysqlbinlog --base64-output=DECODE-ROWS -v mysql-bin.000041 mysql-bin.000042 | \
grep -B5 -A5 "DELETE FROM \`orders\`" | head -n 50
这一看,问题就清晰了。删除操作发生在 mysql-bin.000042 文件,position 大约在 45890 到 89234 之间。而且,因为是ROW格式,我能看到每条删除记录的原始数据!
1.3 核心武器:pt-archiver 的逆向思维
很多人知道 pt-archiver 是用来归档数据的,用来把大表历史数据搬到归档表,从而减小主表体积。但它还有一个隐藏大招:可以从 binlog 中读取事件,并生成反向的 INSERT 语句!
这就是我们能在7分钟内恢复数据的关键。
我的思路是:
- 从 binlog 中提取删除的SQL(其实是提取被删除行的完整数据)。
- 生成对应的
INSERT语句。 - 将这些
INSERT语句写回原表。
pt-archiver 本身不直接做“恢复”,但可以配合 mysqlbinlog 的输出进行处理。更直接的工具是 mysqlbinlog + awk 或者专门的恢复脚本。但在生产环境中,我们团队封装了一个基于 pt-archiver 思路的小脚本,核心逻辑如下:
步骤A:从binlog提取被删除的行数据
# 将binlog解码为可阅读的SQL形式,重点看DELETE事件的BEFORE IMAGE
mysqlbinlog --base64-output=DECODE-ROWS -v \
--start-position=45890 --stop-position=89234 \
mysql-bin.000042 > decoded_binlog.sql
打开 decoded_binlog.sql,你会发现类似这样的内容:
### DELETE FROM `test_db`.`orders`
### WHERE
### @1='1001' /* STRING meta=5112 nullable=0 is_null=0 */
### @2='2022-03-15 10:23:45' /* DATETIME */
### @3='456.78' /* DECIMAL */
### ... 其他字段
### SET
### @1='1001'
### @2='2022-03-15 10:23:45'
### @3='456.78'
步骤B:将DELETE事件转换为INSERT语句
这里写一个Python脚本 recover_from_binlog.py 来处理,因为手动awk太容易出错。
#!/usr/bin/env python3
import re
import sys
def convert_delete_to_insert(binlog_sql_content):
lines = binlog_sql_content.split('\n')
inserts = []
current_insert = []
in_where = False
in_set = False
for line in lines:
if 'DELETE FROM' in line:
# 开始一个DELETE事件,提取表名
table_match = re.search(r'FROM `(\w+)`\.`(\w+)`', line)
if table_match:
db_name = table_match.group(1)
table_name = table_match.group(2)
current_insert = [f"INSERT INTO `{db_name}`.`{table_name}` VALUES "]
in_where = True
continue
if in_where and 'WHERE' in line and '@' in line:
# 开始收集WHERE条件下的值,即被删除行的原始值
if '(' in line and ')' in line:
# 提取括号内的值,这里简化处理,实际需用更复杂的解析器
pass
# 更稳健的方式是直接解析 @1='value' 这种格式
# 为了演示,假设我们已经提取了所有字段值
pass
if in_set and 'VALUES' not in ''.join(current_insert):
# 如果是SET部分,也收集值
pass
# 由于手动解析复杂,实际生产中我们使用 Percona 的 pt-archiver 配合 --purge-after-archive
# 或者使用更专业的工具如 binlog2sql
return inserts
# 实际推荐方案:使用 binlog2sql
等等,我上面写了一堆伪代码,其实我们真正用的是 binlog2sql! 这才是神器。
binlog2sql 是大众点评开源的,专门用于解析 binlog,生成反向SQL。它的核心优势是支持 WHERE 条件过滤,能精确还原。
步骤B(修正):使用 binlog2sql 生成回滚SQL
# 安装(如果还没有)
pip install binlog2sql
# 生成反向SQL(即INSERT语句,用于恢复)
binlog2sql -h 127.0.0.1 -P 3306 -u root -p'your_password' \
-d test_db -t orders \
--start-position=45890 --stop-position=89234 \
--start-datetime='2023-10-27 15:02:33' \
--stop-datetime='2023-10-27 15:03:15' \
--flashback > rollback_orders.sql
关键点解释:
-d test_db -t orders:指定数据库和表,避免解析整个库的binlog,速度快。--start-position/--stop-position:精确的时间窗口,缩小范围。--flashback:这是最重要的参数! 它会将 DELETE 转换为 INSERT,UPDATE 转换为反向 UPDATE,DELETE 语句转换为 INSERT。
生成的 rollback_orders.sql 长这样:
INSERT INTO `test_db`.`orders` (`id`, `user_id`, `amount`, `create_time`, `status`) VALUES ('1001', 'u_8821', '456.78', '2022-03-15 10:23:45', 'completed');
INSERT INTO `test_db`.`orders` (`id`, `user_id`, `amount`, `create_time`, `status') VALUES ('1002', 'u_9932', '120.00', '2022-04-20 11:30:00', 'pending');
... (共98万条)
步骤C:执行恢复,但要小心
98万条INSERT,直接 source 进去可能会崩,或者锁表太久。我们采取的策略是:分批插入,并行执行。
# 将SQL文件分成每1000条一个文件
split -l 1000 rollback_orders.sql batch_
# 然后用并行工具执行,比如 xargs
ls batch_* | xargs -P 4 -I {} mysql -h 127.0.0.1 -u root -p'your_password' test_db < {}
-P 4 表示4个并行进程,大大缩短时间。整个过程,从开始解析到数据全部落盘,耗时 6分42秒。
1.4 验证数据完整性
恢复完成后,不能马上松口气。必须验证。
-- 1. 检查总数
SELECT COUNT(*) FROM orders WHERE create_time < '2023-01-01';
-- 应该接近删除前的数量(考虑到期间可能有新删除,但测试环境没人操作,所以应该一致)
-- 2. 抽样检查
SELECT * FROM orders WHERE id IN ('1001', '1002', '1003') LIMIT 10;
-- 3. 比对业务指标
-- 检查这98万条数据对应的订单金额总和、用户数等,是否与删除前监控一致
确认无误后,解锁表,恢复写入。
UNLOCK TABLES;
SET GLOBAL read_only = OFF;
二、 为什么我们能这么快?三个关键习惯
这次能7分钟救回来,不是运气,是三个习惯的积累。
习惯一:binlog 必须开启,且格式为 ROW
这是恢复的根基。如果是 STATEMENT 格式,binlog 里只记了 SQL 语句,没有记录数据的具体变化。恢复时,你只能知道“执行了DELETE”,但不知道删了哪些数据,无法生成反向INSERT。
MYISAM 引擎不支持 binlog 的 ROW 格式(或者说支持得不好),所以一定要用 InnoDB。
习惯二:binlog 保留时间要够长
我们设置了 expire_logs_days = 7。这意味着最近7天的 binlog 都在。一般误删数据,都是在几天内发现的。如果只保留1天,可能恢复时发现 binlog 已经被清理了,那就真的只能从备份恢复了,而备份往往是小时级甚至天级的,数据损失更大。
习惯三:定期演练恢复流程
我们每季度都会做一次“破坏性演练”。找一个非核心的业务表,手动 DELETE 一部分数据,然后尝试用 binlog2sql 恢复。这样做的好处是:
- 验证备份和 binlog 的有效性。
- 让团队成员熟悉恢复步骤,真出问题时不会慌乱。
- 发现工具或脚本的潜在问题。
这次实战,就是演练成果的检验。
三、 如何避免再次误删?从技术到流程的全面防护
救回来了是好事,但更要防止下次再犯。我从这次事故中,总结了四道防线。
防线一:操作规范——禁止在生产/测试库直接执行DELETE/UPDATE
这是最基础也最有效的规则。任何 DML 操作,尤其是带 WHERE 的 DELETE/UPDATE,必须先在测试环境验证,并经过双人复核。
我们引入了一个机制:所有SQL变更必须通过工单系统提交,由DBA审核后再执行。 审核的重点就是看 WHERE 条件是否合理,是否加了 LIMIT。
防线二:技术限制——使用 SQL 审核工具
我们接入了 Archery 或 Yearning 这样的SQL审核平台。这些工具可以配置规则,比如:
- 禁止无
WHERE条件的 DELETE/UPDATE。 - 禁止表名以
_test结尾的表进行DELETE操作(或者要求额外确认)。 - 自动检测
WHERE条件中是否有索引列,防止全表扫描。
当你在平台上提交删除 orders 表所有2023年前数据的请求时,平台会直接拒绝,并提示你“WHERE条件可能影响过多行,请添加LIMIT或确认”。
防线三:客户端防护——开启 NO_AUTO_VALUE_ON_ZERO 和 sql_safe_updates
在 MySQL 客户端连接时,可以设置一些安全选项。
-- 开启安全更新模式,禁止无WHERE条件的UPDATE/DELETE
SET sql_safe_updates = ON;
-- 这样,如果你执行 DELETE FROM orders; 没有WHERE条件,会直接报错
虽然我们平时不常设这个,但在高危操作时,可以临时开启。
防线四:数据备份——最后的安全网
binlog 恢复虽然快,但有局限性。它只能恢复到删除前的那一刻,且依赖于 binlog 的完整性。如果 binlog 被误清理,或者服务器磁盘损坏,那就只能靠备份了。
我们保留了:
- 全量备份:每天凌晨一次,保留14天。
- 增量备份:基于 binlog 的实时备份,使用
mysqlbinlog+ 自定义脚本,每5分钟同步一次到对象存储(如S3、OSS)。
这样,即使 binlog 出问题,也能从最近的增量备份点恢复。
四、 给小朋友的比喻:如何从“擦除的画”中还原
想象一下,你在画板上画了一幅超级大的画,画的是你最喜欢的卡通人物。
有一天,你不小心用手蹭了一下,把画的一部分给擦掉了。你慌了,以为画毁了。
但是!你有一个秘密武器:摄像机。你画画的时候,旁边有一台摄像机一直在录像,把你画的每一笔都拍下来了。
现在,你要怎么把画还原?
- 找到录像带:你回忆了一下,擦掉大概是在下午3点02分开始的。所以你去找摄像机在3点02分到3点03分之间的录像。
- 倒着放录像:录像里,你看到了自己画画的过程。你看到自己画了卡通人物的眼睛,然后擦掉了。你把录像倒放,就看到眼睛“回来”了!
- 照着画回去:你看着倒放的录像,用画笔把擦掉的部分重新画在画板上。
这个“录像带”就是 binlog,“倒着放”就是 flashback,“重新画回去”就是 INSERT 恢复。
而“摄像机”和“录像带”能一直工作,是因为我们平时就养成了好习惯:开着摄像机,并且保管好录像带至少7天。
五、 总结与后续优化
这次事故后,我们团队做了几项优化:
- 脚本加固:将删除脚本改为必须传参
--dry-run,默认只打印将要删除的SQL和行数,只有显式传--confirm才会真正执行。 - 监控告警:在数据库监控中,增加对“大事务DELETE”的告警。如果单次DELETE影响行数超过1万,立即发送钉钉/邮件告警给DBA和开发负责人。
- 定期演练制度化:将季度恢复演练写入团队SOP,不得缺席。
数据无价,敬畏之心不可无。希望这篇实战记录,能帮你在那种“心脏骤停”的时刻,多一份从容,少一份慌乱。
记住:备份是底线,binlog是希望,规范是保障。
