Administrator
发布于 2024-11-23 / 6199 阅读
108

一次误删数据的恢复全过程

周五下午 16:42,37 万行订单没了

11 月 15 号周五,下午四点四十二,我正准备收拾东西下班,监控群里炸了。

[16:42:07] 告警:order_center 慢查询数 5 分钟内 0 → 143
[16:42:31] 告警:order_center QPS 从 3800 跌到 41
[16:43:02] 同事(企业微信):"哥,我是不是把订单表删了"

他执行的是一条数据清理脚本。脚本里有一句:

DELETE FROM t_order WHERE create_time < '2024-01-01';

但他在客户端里只选中了 DELETE FROM t_order 这一截执行,WHERE 条件没带上。全表 37.2 万行,一秒钟清空。

前 10 分钟:先止损,别急着恢复

我做的第一件事不是查怎么恢复,是防止二次破坏。这个时候最怕的是有人"试着修一下"把现场搞乱。

  1. 摘流量。在网关把写订单的接口熔断,读接口也降级(返回缓存)。这时候订单数据已经不对了,让用户继续下单只会产生更多脏数据。
  2. 锁库。给数据库加全局只读,防止 binlog 继续写入干扰恢复:
mysql> SET GLOBAL super_read_only = ON;
mysql> SET GLOBAL read_only = ON;
  1. 立刻备份 binlog。这是最关键的一步。binlog 是有过期策略的(我们配的是 binlog_expire_logs_seconds = 604800,7 天),而且如果主库继续写入,新的事件会覆盖或滚动。我把当前所有 binlog 文件先拷走:
$ mysql -e "SHOW BINARY LOGS;" | awk 'NR>1{print $1}' > binlog_list.txt
$ mkdir -p /data/rescue/binlog && cd /var/lib/mysql
$ cat binlog_list.txt | xargs -I{} cp {} /data/rescue/binlog/
$ ls -lh /data/rescue/binlog/ | tail -5
-rw-r----- 1.1G  mysql-bin.004317
-rw-r----- 1.1G  mysql-bin.004318
-rw-r----- 412M  mysql-bin.004319

一共 47 个文件,48 GB。

确认前提条件:binlog 格式

能不能精确闪回,取决于两个参数:

mysql> SELECT @@binlog_format, @@binlog_row_image;
+-----------------+--------------------+
| binlog_format   | binlog_row_image   |
+-----------------+--------------------+
| ROW             | FULL               |
+-----------------+--------------------+

看到 ROWFULL,我心里有底了。

  • binlog_format=ROW:记录的是每一行的实际数据变化,不是 SQL 语句,可以精确反向生成。
  • binlog_row_image=FULL:DELETE 事件里记录了被删行的完整字段值。如果是 MINIMAL,只记录主键,那被删的数据就真的找不回来了,只能靠全量备份。

这两个参数是我们去年做数据库规范时统一配的,没想到在这儿救了命。

定位误删的时间点和 binlog 位置

mysql> SHOW BINARY LOGS;
+------------------+-----------+
| Log_name         | File_size |
+------------------+-----------+
| mysql-bin.004318 | 1073741864|
| mysql-bin.004319 | 431082644 |
+------------------+-----------+

$ mysqlbinlog --base64-output=decode-rows -v \
    --start-datetime='2024-11-15 16:41:00' \
    --stop-datetime='2024-11-15 16:43:00' \
    /var/lib/mysql/mysql-bin.004319 | head -60

翻出来的片段:

#241115 16:42:03 server id 1001  end_log_pos 88234123 CRC32 0x9a3f1c22
### DELETE FROM `order_center`.`t_order`
### WHERE
###   @1=100238471                 /* LONGINT meta=0 nullable=0 is_null=0 */
###   @2='ORD20240315123456'       /* VARSTRING(256) */
###   @3=10086                     /* LONGINT */
###   @4=3                         /* TINYINT  status */
###   @5=129900                    /* LONGINT  金额,分 */
###   @6='2024-03-15 12:34:56'     /* DATETIME */
###   ...
### DELETE FROM `order_center`.`t_order`
### WHERE
###   @1=100238472
...

数据都在。误删的准确范围是 mysql-bin.004319 的 position 88234123519087213,共 372418 个 DELETE 事件。

恢复:两条路,我选了第二条

方案 A:全量备份 + binlog 前滚(慢,但稳)

我们每天凌晨 3 点有一次 XtraBackup 全量备份。流程是:拿昨晚的备份恢复到一台临时实例,再把 binlog 从备份时间点回放误删前一刻。

# 1. 解包备份
$ xtrabackup --prepare --target-dir=/backup/full/20241115
# 2. 恢复到临时实例(37 分钟)
$ xtrabackup --copy-back --target-dir=/backup/full/20241115 --datadir=/data/tmp3307
# 3. 回放 binlog 到误删前一秒
$ mysqlbinlog --start-position=... --stop-position=88234123 \
     mysql-bin.004318 mysql-bin.004319 | mysql -h127.0.0.1 -P3307

这条路的问题是。全量恢复 37 分钟,回放 16 小时的 binlog 又花了 52 分钟,加起来一个半小时,业务停摆太久。而且它恢复的是"昨晚 3 点 + 增量",得保证 binlog 完整无缺。

方案 B:binlog 反向生成(闪回)

既然 binlog 里完整记录了被删的行,那就把 DELETE 事件反过来生成 INSERT。这是几分钟能搞定的事。

用的是开源的 binlog2sql(Python)和 my2sql(Go 版,快很多)。我用 my2sql:

$ ./my2sql -user root -password xxx -host 127.0.0.1 -port 3306 \
    -mode file \
    -local-binlog-file /data/rescue/binlog/mysql-bin.004319 \
    -start-pos 88234123 \
    -stop-pos 519087213 \
    -work-type rollback \
    -databases order_center \
    -tables t_order \
    -output-dir /data/rescue/rollback
[2024/11/15 16:58:12] [info] binlog start to parse ...
[2024/11/15 17:02:47] [info] parse finished, total events: 372418
[2024/11/15 17:02:47] [info] rollback sql file: /data/rescue/rollback/rollback.319.sql

生成的文件内容:

$ head -3 /data/rescue/rollback/rollback.319.sql
INSERT INTO `order_center`.`t_order`(`id`,`order_no`,`user_id`,`status`,`amount`,`create_time`,...) VALUES (100238471,'ORD20240315123456',10086,3,129900,'2024-03-15 12:34:56',...);
INSERT INTO `order_center`.`t_order`(`id`,`order_no`,`user_id`,`status`,`amount`,`create_time`,...) VALUES (100238472,'ORD20240315123458',10087,3,89900,'2024-03-15 12:35:02',...);

恢复前必须先验证。我数了行数、抽查了 20 条数据、确认了 SQL 语法:

$ wc -l /data/rescue/rollback/rollback.319.sql
372418 /data/rescue/rollback/rollback.319.sql    # 和实际删除行数一致
# 抽查 id=100238471 这条在备份里的原始值,和 SQL 里的一致 ✓

执行恢复

先解除只读,然后分批导入。37 万行一次性导入会有长事务和锁等待,我按 5000 行一批切分:

mysql> SET GLOBAL super_read_only = OFF;

$ split -l 5000 -d -a 4 rollback.319.sql part_
$ for f in part_*; do
    mysql --default-character-set=utf8mb4 order_center < $f
    echo "$f done, $(date +%T)"
  done
part_0000 done, 17:08:12
part_0001 done, 17:08:15
...
part_0074 done, 17:14:38

6 分 26 秒,37.2 万行全部回灌。

校验:不能只看行数

行数对上只是第一步。我做了三层校验:

-- 1. 行数
mysql> SELECT COUNT(*) FROM t_order;
+----------+
| 3724186  |       -- 和误删前的监控快照一致(误删前 3724186,删后 3351768)
+----------+

-- 2. 数据完整性:检查是否有字段为 NULL 的异常行
mysql> SELECT COUNT(*) FROM t_order WHERE order_no IS NULL OR user_id IS NULL;
+----------+
|        0 |
+----------+

-- 3. 抽样对账:拿误删前后各 1000 条订单的金额总和
mysql> SELECT SUM(amount) FROM t_order WHERE id BETWEEN 100238471 AND 100239470;
-- 和昨天的离线报表数字一致 ✓

另外还做了一件重要的事:检查误删期间有没有新写入。因为 DELETE 之后到我们锁库之间有几分钟,可能有新订单插进来。这些新订单用的是被删的 id 吗?不会(自增 id 不回退),但可能有对已删订单的更新操作产生脏数据。

mysqlbinlog --start-position=519087213 --stop-position=... mysql-bin.004319 \
  | grep -c "UPDATE\|INSERT"
17

17 条,人工看了一遍,都是支付回调对已删订单的状态更新。这 17 条对应的订单现在恢复了,状态被改成了"已支付但没有订单记录",我们手工修正了。

17:52 恢复完成,摘除熔断,历时 1 小时 10 分钟。

复盘:这次运气好在哪,以及堵的三个漏洞

运气好的地方

  • binlog_format=ROW + binlog_row_image=FULL,否则只能走全量恢复,至少两小时。
  • binlog 保留了 7 天,文件都还在。
  • 发生在周五下午,距离当天业务高峰已过。

堵上的三个漏洞

一、开启 sql_safe_updates。这是 MySQL 自带的安全开关,不带 WHERE 或者 WHERE 不走索引的 UPDATE/DELETE 会被直接拒绝。

mysql> SET GLOBAL sql_safe_updates = 1;
mysql> DELETE FROM t_order;
ERROR 1175 (HY000): You are using safe update mode and you tried to update
a table without a WHERE that uses a KEY column

生产环境默认开启,需要全表操作时用 SET SESSION sql_safe_updates=0 临时关掉。这个改动上线后拦截过 4 次误操作。

二、应用账号禁止 DDL 和无条件 DML。之前我们的应用账号权限是 ALL PRIVILEGES,太粗。重新梳理后的权限矩阵:

账号用途权限
app_rw应用读写SELECT, INSERT, UPDATE, DELETE(带 WHERE 由 sql_safe_updates 保证)
app_ro查询平台、BISELECT
ops_ddlDBA 变更全部,但需工单审批后才能取到密码

同时把 DBA 操作接入了工单系统,DELETE 超过 1 万行、任何 DDL 都要双人审批。

三、备份策略从"一天一次"改成"一天全量 + 每 5 分钟 binlog 归档"。之前 binlog 只存在本地盘,如果整台机器挂了就全没了。现在:

# 每 5 分钟同步一次 binlog 到对象存储
*/5 * * * * /usr/local/bin/binlog_archive.sh >> /var/log/binlog_archive.log 2>&1

并且每周做一次真实的恢复演练——在测试环境拿备份恢复一遍,验证备份是有效的。没验证过的备份等于没有备份,我们这次能快速恢复,前提是我知道 binlog 是可用的(因为演练过)。

小结

这次事故的直接原因是操作失误,但根因是生产环境缺少防误删的机制。人一定会犯错,制度要假设人会犯错。

技术上记住两点:binlog 一定要开 ROW + FULL,这是闪回的前提;出事之后第一时间保住现场,停写、锁库、备份 binlog,比急着找恢复方案重要得多。

最后一点:那天我 19 点才到家。同事说他这辈子都不会忘记加 WHERE 了,我说我也不会忘记先锁库再说话

参考