那天下午三点,监控报警群炸了。作为负责核心账务系统的DBA,我盯着屏幕上的报错信息——“Rows deleted from ACCOUNTS_TRANSACTION”。不是备份,不是复制,就是字面意义上的“误删”。
如果是普通博客,这会儿该给你甩一堆“先停业务、再找备份、最后恢复”的模板了。但银行的核心账务不是普通业务,每一笔交易都是真金白银,每一秒的停机都在烧钱。所以今天,我不跟你聊理论,我带你复盘那场真实的“抢救战”,看看在分秒必争的金融系统里,数据恢复是怎么像拆弹一样一步步进行的。
一、误删发生的瞬间:为什么“普通恢复”在这里行不通
首先得弄清楚,银行环境的MySQL和普通测试库有啥区别。
普通库误删了,你可以直接停应用、拉备份、还原,几十分钟搞定。但银行的核心库是7x24小时在线的,交易高峰期每秒可能产生数万笔写入。你一旦执行DELETE FROM且忘了加WHERE,或者TRUNCATE TABLE,数据页中的记录并不会真正立即消失,但逻辑上它们已经“不可见”了。
更关键的是,银行通常有主从架构(Master-Slave)甚至MGR组复制。如果主库删了,从库可能也同步了(取决于binlog_format和同步策略)。这时候如果你直接去从库找备份恢复,可能找到的已经是“干净”的空库了。
我记得那次事件,误删发生在凌晨2:15,正是批量跑批的高峰期。运维同学为了清理测试数据,在应用服务器上临时开了一个MySQL命令行,本想truncate一张临时表,结果手抖选错了schema,执行了DELETE FROM ACCOUNTS_TRANSACTION WHERE DATE > '2023-01-01'——他本意是只删当天的,但条件写错了,实际上删了最近三个月的数据。
那一刻,整个团队的神经都绷紧了。因为我们知道,“快”不等于“盲目操作”。任何错误的恢复尝试,都可能覆盖掉恢复所需的关键二进制日志(Binlog)数据,导致彻底无法挽回。
二、黄金救援原则:第一件事做什么?
很多新手(甚至一些老手)的第一反应是:“赶紧找昨天晚上的全量备份恢复!”
停!这是最危险的想法。
在银行生产环境,正确的第一步不是恢复,而是“止血”和“保证”:
1. 立即暂停写入,但不一定停服务
如果误删是通过某个应用进程执行的,首先要做的不是杀掉整个MySQL进程(那会导致所有交易中断,引发更大的金融事故),而是:
- 通过防火墙或应用配置,临时阻断对该表的写入权限,或者将应用指向只读从库。
- 检查当前Binlog的position:执行
SHOW MASTER STATUS;和SHOW SLAVE STATUS;,记录下当前的binlog文件和位置点。这是你的“时间锚点”。
2. 确认Binlog的保留策略
银行的Binlog通常保留7-30天,甚至更久。你需要确认:
SHOW VARIABLES LIKE 'expire_logs_days';
SHOW BINARY LOGS;
如果Binlog还在,那就还有救。如果Binlog已经过期,那只能依赖最近的物理备份(如XtraBackup)加增量binlog。这次误删发生在凌晨,而Binlog保留期是14天,所以理论上完全在范围内。
3. 不要启动任何恢复操作,先通知
这一步很关键。你需要立刻通知:
- 业务负责人:评估影响范围,决定是否对外公告或暂停部分非核心功能。
- 安全团队:排查是否为黑客攻击(误删和删库攻击有时难以区分)。
- 上级管理层:按照银行的IT应急预案流程上报。
三、制定恢复方案:三种路径的选择
根据误删的方式和Binlog的状态,通常有三种恢复路径。我们需要根据实际情况选择最优解。
路径A:基于Binlog的闪回恢复(最快,但复杂)
如果误删操作本身也被记录在Binlog中(DELETE语句会被记录,但TRUNCATE可能被转换为DROP TABLE和CREATE TABLE),我们可以利用MySQL Binlog2SQL或binlog2python等工具,将误删操作“反向”生成恢复SQL。
核心原理
Binlog记录了所有数据变更。如果你执行了DELETE FROM table WHERE id > 1000,Binlog里会有对应的DELETE事件。我们可以解析这些事件,找到删除前的数据,然后生成INSERT语句将其写回。
实际操作步骤
- 定位误删的Binlog位置
通过排查应用日志和MySQL error log,我们锁定了误删发生的时间点:2023-10-15 02:15:30。
然后我们找到这个时间点对应的Binlog文件:
SHOW BINARY LOGS;
-- 假设找到 binlog.000123 在 02:15:00 附近
SHOW BINLOG EVENTS IN 'binlog.000123' FROM 12345678 LIMIT 20;
我们需要精确找到DELETE语句的Start_log_pos和End_log_pos。
- 使用工具解析Binlog
这里我们推荐使用mysqlbinlog工具(MySQL官方)或my2sql(阿里开源,更适合中文环境)。
以mysqlbinlog为例:
mysqlbinlog --start-datetime="2023-10-15 02:15:00" \
--stop-datetime="2023-10-15 02:20:00" \
--database=core_db \
binlog.000123 > recovered_events.sql
在生成的recovered_events.sql中,你会看到大量的DELETE语句。
- 生成反向SQL(闪回)
这一步是关键。手动生成不现实,我们用工具。推荐使用gh-ost或my2sql。
以my2sql为例(这是一个非常强大的工具,能解析Binlog并生成反向SQL):
# 安装 my2sql (假设已编译好)
./my2sql -run 1 \
--mode 2 \
--start-file binlog.000123 \
--start-pos 12345678 \
--end-pos 12350000 \
--db-name core_db \
--table-name ACCOUNTS_TRANSACTION \
--output-dir ./flashback_result
--mode 2表示生成反向SQL。--start-pos和--end-pos是你通过SHOW BINLOG EVENTS查到的误删操作的起始和结束位置。
工具会生成类似这样的文件:core_db.ACCOUNTS_TRANSACTION.flashback.2023-10-15_02-15-30.sql,里面包含大量的INSERT语句,将删除的数据恢复回来。
- 评估并执行恢复
在正式执行前,必须先在测试环境验证这些SQL的语法和逻辑。然后,在生产环境的从库上执行(如果从库是只读的,先提升为主库,或者在主库的低峰期执行)。
执行恢复SQL时,建议分批提交,避免大事务锁表:
START TRANSACTION;
-- 插入前1000条数据
INSERT INTO ACCOUNTS_TRANSACTION (...) VALUES (...), (...);
COMMIT;
路径B:基于备份的完全恢复(最稳妥,但耗时较长)
如果误删操作跨越了多个Binlog文件,或者Binlog不完整,我们可以依赖全量备份 + 增量Binlog。
操作流程
- 找到最近的全量备份
假设我们每天晚上2点做一次全量备份(使用XtraBackup)。那么最近的备份是2023-10-14 02:00:00的备份。
- 恢复全量备份到临时服务器
不要在主库上直接恢复!找一台配置相似的测试服务器,或者主库的从库(如果从库还没同步误删操作)。
# 假设备份文件在 /backup/full_backup_20231014.gz
xtrabackup --prepare --target-dir=/backup/full_backup_20231014
xtrabackup --copy-back --target-dir=/backup/full_backup_20231014
- 应用增量Binlog
从备份结束时间(2023-10-14 02:00:00)到误删发生前(2023-10-15 02:15:00)的所有Binlog,需要应用到临时服务器上。
mysqlbinlog --start-datetime="2023-10-14 02:00:01" \
--stop-datetime="2023-10-15 02:14:59" \
binlog.000120 binlog.000121 binlog.000122 | mysql -u root -p
这样,临时服务器上的数据就是误删前的最新状态。
- 提取需要恢复的表
从临时服务器中,导出ACCOUNTS_TRANSACTION表的数据:
mysqldump -u root -p core_db ACCOUNTS_TRANSACTION > transaction_backup.sql
- 导入到生产库
通过pt-table-sync工具或者手动INSERT,将数据同步回生产库。
pt-table-sync --execute h=prod_host,u=root,p=password,D=core_db,t=ACCOUNTS_TRANSACTION \
h=temp_host,u=root,p=password,D=core_db,t=ACCOUNTS_TRANSACTION
这种方法虽然稳妥,但耗时较长,且需要停机或主从切换,风险较高。
路径C:混合恢复(本次事件的实际选择)
这次事件,我们采用了路径A为主,路径B为辅的混合策略。
因为误删操作明确,Binlog完整,我们优先尝试闪回恢复。同时,我们准备了全量备份,以防闪回过程中出现意外(如Binlog解析错误、数据丢失等)。
四、实际执行中的细节与坑
坑1:Binlog格式问题
如果Binlog_format设置为STATEMENT,那么闪回恢复会比较复杂,因为SQL语句可能在不同环境下执行结果不同。银行核心库通常使用ROW格式,这更有利于精确恢复。
SHOW VARIABLES LIKE 'binlog_format';
-- 应该是 ROW
坑2:主从同步延迟
如果从库有同步延迟,那么从库上的数据可能比主库“旧”。在恢复时,我们必须确保使用的是主库的Binlog,而不是从库的。否则,恢复的数据可能不包含最近的变更。
坑3:数据一致性校验
恢复完成后,必须进行严格的数据一致性校验。这包括:
- 行数比对:恢复后的表行数是否与备份前的预期行数一致?
- 总和校验:交易金额的总和是否与备份前一致?
- 抽样核对:随机抽取100条记录,与备份数据进行逐字段比对。
我们使用了以下脚本进行快速校验:
import pymysql
# 连接生产库(恢复后)
conn_prod = pymysql.connect(host='prod_host', user='root', password='xxx', db='core_db')
# 连接备份库(恢复前的状态)
conn_backup = pymysql.connect(host='backup_host', user='root', password='xxx', db='core_db')
cursor_prod = conn_prod.cursor()
cursor_backup = conn_backup.cursor()
# 比对行数
cursor_prod.execute("SELECT COUNT(*) FROM ACCOUNTS_TRANSACTION WHERE DATE > '2023-01-01'")
prod_count = cursor_prod.fetchone()[0]
cursor_backup.execute("SELECT COUNT(*) FROM ACCOUNTS_TRANSACTION WHERE DATE > '2023-01-01'")
backup_count = cursor_backup.fetchone()[0]
print(f"生产库行数: {prod_count}, 备份库行数: {backup_count}")
if prod_count == backup_count:
print("行数一致,恢复成功!")
else:
print("行数不一致,需进一步排查!")
坑4:应用层的缓存问题
数据恢复后,应用层的缓存(如Redis)可能还是旧数据。需要清理缓存,或者让缓存自然过期,否则用户查到的交易记录可能仍然是错误的。
五、事后复盘与改进
误删事件虽然恢复了,但暴露了多个问题。我们随后做了以下改进:
- 权限收紧:生产环境的MySQL禁止直接执行
DELETE、UPDATE、TRUNCATE等高危操作。所有数据变更必须通过应用层提交,或者通过专门的运维平台申请。 - 双人复核机制:任何涉及核心表的数据变更,必须经过两名DBA的复核才能执行。
- 监控加强:增加对
DELETE、DROP等高危操作的实时监控和告警。一旦检测到这类操作,立即通知运维团队。 - 定期恢复演练:每季度进行一次数据恢复演练,确保备份和恢复流程的有效性。
六、给普通用户的建议
虽然你是普通用户,不是银行DBA,但如果你也遇到了MySQL误删的情况,可以借鉴以下步骤:
- 立即停止写入:如果可以,暂停应用对数据库的访问。
- 检查Binlog:确认Binlog是否开启,以及保留期限。
- 尝试闪回:使用
mysqlbinlog或my2sql工具,解析Binlog,生成反向SQL。 - 恢复备份:如果闪回不可行,使用最近的全量备份 + 增量Binlog进行恢复。
- 校验数据:恢复后,务必进行数据一致性校验。
记住,冷静是恢复数据的第一要素。慌乱中的误操作,往往会让情况变得更糟。
结语
数据恢复是一场与时间的赛跑,也是一场对技术和流程的考验。在银行这样的关键领域,每一次误删都可能带来巨大的经济损失和信誉风险。因此,建立完善的备份机制、严格的权限控制、以及定期的恢复演练,才是预防误删的最佳手段。
希望这次的复盘,能为你提供一些实用的参考。如果你正在面临类似的问题,记住:先止血,再分析,最后行动。
