周五下午 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 分钟:先止损,别急着恢复
我做的第一件事不是查怎么恢复,是防止二次破坏。这个时候最怕的是有人"试着修一下"把现场搞乱。
- 摘流量。在网关把写订单的接口熔断,读接口也降级(返回缓存)。这时候订单数据已经不对了,让用户继续下单只会产生更多脏数据。
- 锁库。给数据库加全局只读,防止 binlog 继续写入干扰恢复:
mysql> SET GLOBAL super_read_only = ON;
mysql> SET GLOBAL read_only = ON;
- 立刻备份 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 |
+-----------------+--------------------+
看到 ROW 和 FULL,我心里有底了。
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 88234123 到 519087213,共 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 | 查询平台、BI | SELECT |
| ops_ddl | DBA 变更 | 全部,但需工单审批后才能取到密码 |
同时把 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 了,我说我也不会忘记先锁库再说话。