ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL任意时间点恢复:全量备份+binlog位点重放实战

MySQL任意时间点恢复:全量备份+binlog位点重放实战

数据被误删的那一刻,绝大多数人脑子里蹦出来的第一个念头是“昨晚的备份还在吧”,第二个念头才是“但备份只能回到昨晚,中间这几个小时的单子怎么办”。这就是我今天想聊的事:MySQL 怎么把数据恢复到任意时间点。它不是某个神奇的一键按钮,而是一套由全量备份、二进制日志(binlog)、位点信息三样东西拼起来的重放机制。搞懂这套机制,你就能做到“回到今天上午 10 点 23 分 17 秒之前”,而不是只能无奈地退回昨天凌晨两点的快照。我见过太多团队备份天天在做,真出事时却发现恢复不了——不是备份坏了,是缺了链条上的某一环。这篇内容写给两类人:一类是还没经历过误删、想提前把地基打牢的;另一类是已经被误删、被误更新折腾过一轮,想知道自己错在哪的。下面全部是能直接抄作业的步骤和参数,也包括我自己踩过的坑。

1. “任意时间点”到底指什么,能覆盖哪些事故

1.1 时间点恢复的本质:全量底座加 binlog 重放

先把概念钉死。所谓“恢复到任意时间点”(Point-In-Time Recovery,业内常缩写为 PITR),标准做法只有一条路径:找到一个全量备份作为底座,然后把这个底座之后产生的所有 binlog 事件按顺序重放一遍,重放到你指定的那个时刻或者那个位点就停手。全量备份决定了下限——你最早只能回到备份那一刻;binlog 决定了上限——你能回到多近,取决于日志没被清理掉。两者之间那条缝隙,就是恢复能力。

打个比方,全量备份像是一张照片,binlog 像是一段录像。照片拍完之后的每一帧都在录像里,你想看第 37 分 12 秒的画面,就从头快进到那一帧。没有照片就没起点,没有录像就只能定格。

这里有个很多人搞混的点:时间点恢复不是“把数据改回那个时刻的样子”,而是“让数据库重新执行一遍那个时刻之前的所有变更”。所以它要求 binlog 里的每一条变更都必须是可重放的、幂等的、顺序正确的。只要中间断了一截日志,链条就断了,后面的重放全是错的。这也是为什么 binlog 保留期、同步落盘这些配置,比备份本身还容易被忽视,却往往是要命的那一环。

1.2 三类典型事故与它们的可恢复边界

不是所有事故都能靠 PITR 解决,先把边界划清楚,免得真出事时浪费宝贵时间。

事故类型典型表现能否 PITR 恢复关键前提
误删数据(DELETE)表还在,数据没了可以,恢复到删之前binlog 中能找到删除语句/行事件
误更新(UPDATE 忘加 WHERE)大批字段被覆盖可以,但需跳过该事务binlog 中能找到对应事务的起止位点
误删表 / 误删库(DROP)表结构都没了部分可以,DDL 本身无法回滚需先恢复全量,跳过 DDL 后重放数据
应用逻辑写错数据数据在,但值是错的可以,定位到对应事务跳过需要能确认是哪一批事务
磁盘损坏 / 实例崩溃数据文件不可用视备份而定备份必须完整且可校验
被恶意清空且日志一并清理日志文件缺失不能binlog 已丢失,链条断裂

看最后一行。这类情况我遇到过不止一次:出事后有人第一反应是清理现场、重启服务、清空 binlog,把最后的证据也一起抹了。事故发生后第一件事永远是备份现场,不是急着修。把当前的 binlog 目录、数据目录、错误日志全部先复制一份出来,再动手。

另外一个必须提前知道的限制:DDL(比如 DROP TABLE、ALTER TABLE)在 binlog 里是一条独立事件,它不具备“反向执行”的能力。你可以跳过它,但不能撤销它。所以如果事故是“表被 DROP 了”,正确的姿势是:先把全量备份恢复出来(那时表还在),然后重放 binlog 到 DROP 之前那一刻,再把这张表的数据单独导出来,回灌到生产。整个过程比单纯的 DELETE 恢复多两步,别指望一条命令搞定。

2. 上线前必须落地的四项配置

2.1 binlog 格式与行镜像:决定你能不能“逆向”

这是最容易被当成“默认值就行”的一项。binlog_format有三个取值:STATEMENT、ROW、MIXED。我只推荐ROW,没有商量余地。原因很实在:ROW 格式记录的是“哪一行从什么值变成了什么值”,信息完整,能精确重放,也能基于它生成反向 SQL 做闪回。STATEMENT 只记 SQL 语句,遇到NOW()、UUID()、LIMIT不带 ORDER BY 这类非确定性语句,重放结果可能和原库不一致——你在恢复环境里跑一遍,数据跟生产对不上,那才叫绝望。

配置写法:

[mysqld] binlog_format = ROW binlog_row_image = FULL log_bin = /var/lib/mysql/binlog binlog_expire_logs_seconds = 604800 sync_binlog = 1

逐条说为什么。binlog_row_image=FULL是配合 ROW 的关键,它保证每一行变更都把变更前和变更后的完整列值都记下来。如果设成 MINIMAL,只记被改的列,那你想做闪回、想把 UPDATE 还原成旧值的时候就会发现:缺列。有些字段根本不在日志里,反向 SQL 生成不出来。代价是日志体积变大,一般会涨 1.5 到 3 倍,这个空间换得值。

sync_binlog=1表示每次事务提交都把 binlog 刷到磁盘。性能上确实有损耗,尤其高并发写入场景,但对“不能丢数据”的库来说这是底线。设成 0 或者更大的值,操作系统崩溃时最后一批事务的日志可能还在页缓存里,那就真的丢了,恢复上限直接被砍掉一截。

binlog_expire_logs_seconds我给了 7 天(604800 秒)。这个值决定了“最远能回到多久以前”。你要根据自己的备份周期来定:如果每天一次全量,那 binlog 至少要留 2 天以上,留 7 天是给节假日和周末留缓冲——周五晚上的事故,周一早上才发现,这种情况一点都不稀奇。

注意:改binlog_format是动态参数,但改完之后只有新产生的日志用新格式,历史日志还是老格式。所以真要切换,得挑一个低峰期,评估好日志量增长,别在业务高峰直接改。

2.2 落盘与保留期:决定你能回到多早

上一节提到了保留期,这里展开说,因为它和“任意时间点”这四个字直接挂钩。很多人心里想的“任意时间点”其实是“任意近的时间点”,一周以前的随便哪个时刻,其实是回不去的——日志早就被轮转清掉了。

我的建议是把“备份周期”和“日志保留”当成一个整体来设计:

  • 全量备份每天一次,保留 7 到 14 份;
  • binlog 保留时间 ≥ 全量备份保留时间的 1.5 倍;
  • 全量备份和 binlog 存在不同的物理位置,不要图省事放同一块盘。

第三点是被血泪教训出来的。有一次磁盘阵列整体故障,备份文件和 binlog 在同一个挂载点下,一起没了。备份的价值在于“和生产不是同一条命”,放在同一块盘上的备份,本质上只是给自己心理安慰。

还有一个细节:log_bin的路径不要放在会被清理脚本扫到的临时目录下。我见过运维脚本定时清理/tmp和/var/log,结果某次把 binlog 目录也顺手清了,因为当初为了省事把日志指到了/var/log/mysql-bin。这种事故没有任何技术含量,但破坏力是满格的。

2.3 备份必须自带位点与 GTID 信息

这是整条链路上最致命的环节。全量备份如果没记录“这份备份对应的是哪个 binlog 文件的哪个位置”,那它和后面的日志就是两截断掉的绳子,接不上。你可能觉得可以靠备份文件的时间戳去猜,但 binlog 位点和时间戳之间不是严格对应关系,靠猜重放,轻则重复执行一部分事务,重则漏掉关键变更。

用逻辑备份工具(mysqldump 这一类)时,必须带上位点参数:

mysqldump -uroot -p \ --single-transaction \ --source-data=2 \ --flush-logs \ --set-gtid-purged=OFF \ --databases app_db > /backup/app_db_$(date +%F).sql

--source-data=2会在备份文件的头部以注释形式写入当前 binlog 文件名和位点,同时会执行一次FLUSH LOGS,让新日志从这里开始切分——这一点很关键,它保证备份的那一刻和 binlog 的切分点是对齐的。老版本 MySQL 里这个参数叫--master-data=2,作用一样。

--single-transaction是给 InnoDB 用的,靠一致性快照做备份,不锁表。但要注意:它只对事务引擎有效,如果你库里有 MyISAM 表,那部分数据的一致性没保证,这类表要么提前转引擎,要么单独安排窗口。

--set-gtid-purged=OFF是为了让备份能在普通实例上恢复,不会因为 GTID 冲突卡住。要不要带这个参数,取决于你恢复的目标环境是否已经开启了 GTID,这个后面第 4 节会细讲。

备份文件头部长这样,恢复前一定要打开看一眼:

-- CHANGE MASTER TO MASTER_LOG_FILE='binlog.000087', MASTER_LOG_POS=194;

这两个值就是你后续重放 binlog 的起点。没有这一行,整套恢复方案走不通。所以我每次验收备份策略,第一件事就是随机抽一份备份文件,head -30看有没有位点信息。

物理备份工具(做文件级热备的那类)思路类似,它会在备份目录里放一个记录位点的元数据文件,恢复前同样要确认这个文件存在且内容合理。

2.4 时钟与 server_id:最容易被忽略的两个隐性条件

说两个不太像“配置”但同样致命的点。

时钟统一。用--start-datetime、--stop-datetime按时间点恢复的时候,mysqlbinlog 比较的是 binlog 事件里记录的时间戳,而业务方告诉你的“大概是下午三点半删的”来自应用日志或者人的记忆。两边时区不一致、机器时钟漂移几分钟,就会导致切出来的区间偏了,要么漏事务,要么把误操作也包进去。我的做法是:恢复时永远优先用位点,时间只用来自缩小搜索范围。真要用时间,先date看一眼服务器时区,再确认应用日志的时区,两者对齐之后再算。

server_id 冲突。binlog 事件头里带 server_id,重放的时候如果目标实例的 server_id 和源库一样,某些场景下会出问题,尤其是走主从复制的链路。规范做法是:每个实例的 server_id 全局唯一,恢复用的临时实例单独分配一个,不要图快直接照抄生产配置。另外临时实例的log_bin建议先关掉,避免边恢复边写新日志,把磁盘撑爆。

这两条在任何文档里都排在很后面,但真出事时它们最容易让人多花两三个小时。

3. 一次误删事故的完整恢复实操

3.1 场景设定与隔离环境准备

假设现在有这么个情况:某业务库app_db,凌晨 2 点做过一次全量备份,备份头部记录的位点是binlog.000087 / 194。上午 10 点 20 分左右,有位同学执行了一条没带 WHERE 条件的 DELETE,把orders表清空了,10 点 40 分业务方发现订单查不到,报警。

第一反应不是登生产库。先把现场冻住:

# 1. 立即停掉相关写入,避免日志继续膨胀、位点继续往前跑 # 2. 把当前 binlog 全量复制一份出来,只读保存 cp -a /var/lib/mysql/binlog.* /backup/incident_20240115/ # 3. 确认现有 binlog 列表和最后一个文件 mysql -uroot -p -e "SHOW BINARY LOGS;"

复制出来这一步很多人省,觉得直接在生产机上解析就行。但解析过程要读文件、要生成 SQL,万一磁盘满了或者误操作把日志覆盖了,那就真没救了。cp -a保留时间戳,后面按时间筛事件时有用。

然后准备一个隔离的临时实例。可以是另一台机器,也可以是本机另一个端口,关键是它和生产彻底隔离,绝不能连到同一个数据目录。恢复过程会在临时实例上反复重放,一旦连错,就是把事故扩大成灾难。

# 临时实例,独立数据目录、独立端口、独立 server_id mysqld --no-defaults \ --datadir=/data/restore_tmp \ --port=3307 \ --socket=/tmp/restore.sock \ --server-id=9999 \ --skip-log-bin

--skip-log-bin关掉临时实例的日志写入,省磁盘也省得混淆。--server-id=9999跟生产错开。

3.2 第一步:把全量备份拉起来

把凌晨 2 点的那份备份导进临时实例:

mysql --socket=/tmp/restore.sock -uroot -p < /backup/app_db_20240115.sql

导入完成后,第一件事是核对位点。打开备份文件看头部那行CHANGE MASTER TO,确认位点是binlog.000087 / 194,和记录一致。这一步别跳,我见过备份文件被手工改过、位点行被删掉的案例,导进去才发现接不上。

第二件事是确认时间基准。用这个位点去 binlog 里定位它对应的时间戳:

mysqlbinlog --base64-output=decode-rows -vv \ /backup/incident_20240115/binlog.000087 \ | head -60

输出里每个事件的头部都有# at 194和#240115 2:00:03 server id ...,那个时间就是备份对应的时间点。把它记下来,它等于“恢复的起点时间”。后面所有的时间范围都从这个时间开始算。

3.3 第二步:在 binlog 里锁定误操作的那几行

接下来要找 10 点 20 分左右的那条 DELETE。先在binlog.000087里按时间范围粗筛:

mysqlbinlog --base64-output=decode-rows -vv \ --start-datetime="2024-01-15 10:15:00" \ --stop-datetime="2024-01-15 10:25:00" \ /backup/incident_20240115/binlog.000087 > /tmp/suspect.sql grep -n "DELETE FROM" /tmp/suspect.sql | head -20

如果没有命中,说明这个时间段的日志在下一个文件里,用SHOW BINARY LOGS看文件列表,逐个往后找。定位到之后,重点看几个标记:

  • # at 1234567—— 事件起始位点;
  • #240115 10:20:31 server id 1 ... GTID ...—— 事件所属的 GTID(如果开了 GTID);
  • ### DELETE FROM \app_db`.`orders`` —— ROW 格式下的具体操作;
  • Xid = 88421—— 事务 ID,标记事务结束。

一个事务在 binlog 里的结构是BEGIN→ 若干行事件 →Xid(或者 COMMIT)。要跳过的是一个完整事务,不是某一行。所以你要记两个位点:这个事务BEGIN(在 ROW 格式下对应的是 GTID 事件或BEGIN标记)之前的位点,和事务结束Xid之后的位点。

实际记录方式是这样的:

误操作事务范围: 起始位点(事务开始前):1234000 结束位点(Xid 之后): 1245000 对应时间:2024-01-15 10:20:31 影响行数:约 8.6 万行

行数也要看。8.6 万行的删除,日志里会有 8.6 万条### DELETE记录,直接cat出来会把终端刷爆,用grep -c统计就好。

这里有个细节:如果 binlog 里同一时刻有多个并发事务交叉写入,你不能简单地把“误操作前后”这段区间整段切掉,那样会连带丢掉别的会话的正常写入。必须在事务边界上切,一个事务一个事务地判断。这就是为什么我建议用位点而不是时间——时间粒度切不干净。

3.4 第三步:按 position 精确切断,跳过坏事务

有了三个位点(备份起点 194、坏事务起点 1234000、坏事务结束 1245000),重放分两段:

# 第一段:从备份位点重放到误操作之前 mysqlbinlog --base64-output=decode-rows -vv \ --start-position=194 \ --stop-position=1234000 \ /backup/incident_20240115/binlog.000087 \ | mysql --socket=/tmp/restore.sock -uroot -p # 第二段:从误操作事务结束之后,继续重放到时间点 mysqlbinlog --base64-output=decode-rows -vv \ --start-position=1245000 \ /backup/incident_20240115/binlog.000087 \ /backup/incident_20240115/binlog.000088 \ | mysql --socket=/tmp/restore.sock -uroot -p

几个要点。

--stop-position是不包含该位点的,重放到这个位点前就停。所以传“坏事务开始前”的位置,正好把坏事务排除在外。

多个 binlog 文件可以直接在命令行后面依次列出,mysqlbinlog 会按顺序解析,位点参数只对第一个文件生效,后面的文件从头解析。这个行为要留意:如果你的--stop-position落在第二个文件里,那它是不起作用的,得拆成多段执行。

重放过程中如果报错,先看错误信息。常见的是主键冲突、表不存在,这些通常意味着前面某一段漏了或者重复了。--force参数能强行跳过错误继续跑,但别在恢复流程里随手用它,它会让问题藏起来,等你发现时已经重放了几十万条错误数据。

重放完成,跑到临时实例上看一眼:

SELECT COUNT(*) FROM orders WHERE created_at >= '2024-01-15';

对比业务方给的预期数量。数量对上了,再去比对具体几条关键记录的值。

3.5 第四步:GTID 环境下的另一种切断方式

如果生产开了 GTID,事情会稍微绕一点。GTID 模式下 binlog 事件的位点仍然存在,但重放的时候 MySQL 会检查“这个 GTID 是不是已经执行过了”,如果目标实例已经执行过同一个 GTID,会被跳过——这个特性平时是好事,恢复时反而会带来意外。

两个思路。

思路一:重放时忽略 GTID 检查。在 mysqlbinlog 上加--skip-gtids,让它把 GTID 信息剥掉,只按普通事务重放。这样恢复环境里不会因为 GTID 已存在而跳过:

mysqlbinlog --skip-gtids --base64-output=decode-rows -vv \ --start-position=194 --stop-position=1234000 \ binlog.000087 | mysql --socket=/tmp/restore.sock -uroot -p

思路二:走复制通道,用空事务把坏 GTID 顶掉。这个更适合“直接把临时实例当从库追上来”的场景。做法是先把临时实例指向源库做从库,追到坏事务前停住,然后手工注入一个空事务,把这个 GTID 标记为“已执行”,再继续追:

STOP SLAVE; SET GTID_NEXT='3e11fa47-71ca-11e1-9e33-c80aa9429562:88421'; BEGIN; COMMIT; SET GTID_NEXT='AUTOMATIC'; START SLAVE;

那个 GTID 值就是第 3.3 步从日志里记下来的坏事务 GTID。空事务的意思是“这个 GTID 我认了,但什么都不做”,于是从库跳过它继续往下追。这个手法在真实恢复里非常常用,比手工切位点更稳,尤其是坏事务之后还有大量日志要追的时候。

注意:SET GTID_NEXT不带AUTOMATIC复位,会让这个会话卡在“等待下一个 GTID”的状态,后面的操作全都不执行。我第一次用的时候忘了复位,盯了十分钟以为卡住了。养成习惯,注入完立刻复位。

3.6 第五步:回灌生产与数据比对

临时实例上的数据对了,不等于可以直接覆盖生产。回灌要按影响范围来选方式。

如果只是单表数据被误删,最稳的是只导出这张表的相关数据,再导回生产:

mysqldump --socket=/tmp/restore.sock -uroot -p \ --single-transaction --no-create-info \ --where="created_at >= '2024-01-15 00:00:00'" \ app_db orders > /tmp/orders_recover.sql

--no-create-info只导数据不导表结构,避免把临时环境的表结构定义覆盖到生产。--where限定范围,别把整张表都导回去——生产上这张表在这段时间里可能又被正常写入了新数据,整表覆盖会把新数据冲掉。

导入之前,先把生产上那张表现在的内容备份一份。听起来多余,但我见过回灌脚本写错表名、把另一张表清空的案例。有备份,最多是重来一次;没备份,就是二次事故。

比对环节建议至少做三层:行数比对、关键字段的校验和比对、抽样若干条记录逐字段核对。行数对不上说明范围切错了;校验和对不上说明有字段值差异;抽样是最后兜底,防止前两层都“看起来对”但业务语义错了。

整个过程的时间预算要有数:8.6 万行的表,从开始操作到回灌完成,大概 40 分钟到 1 小时,其中大头在 binlog 解析和重放。数据量上到千万级,光重放就可能要几个小时,这时候要考虑并行解析、分批重放,而不是干等。

4. 常见故障与排查实录

4.1 恢复失败问题速查表

下面这张表是我这些年攒下来的,按“出现频率”排序。

现象大概率原因排查动作处理方式
重放时报主键冲突起点位点选早了,重复执行了已有数据核对备份头部位点与--start-position把起点调到备份位点之后
重放报“表不存在”中间跳过了 DDL 事务grep -n "CREATE TABLE|ALTER TABLE"检查日志补上 DDL 或先用全量恢复表结构
数据量对不上,少了--stop-position切早了,漏了尾部事务看最后一个重放事件的位点从漏掉的位点续跑
数据量对不上,多了坏事务没被排除,或区间重叠执行检查两段重放的位点是否重叠清空临时库重来,别在脏数据上打补丁
mysqlbinlog 输出乱码用了-vv但没加--base64-output=decode-rows检查参数补上该参数,ROW 格式必须带
按时间筛不到事件时区不一致date对比源库与解析机时区统一时区后重算时间窗口
从库追不上、卡在某个 GTID目标端已执行过该 GTIDSHOW SLAVE STATUS看Retrieved_Gtid_Set用空事务顶掉或加--skip-gtids
临时实例磁盘爆满重放产生大量 undo/redo,或忘了关 log_bindf -h、du -sh /data/restore_tmp关 binlog、加大innodb_undo空间、分批重放
备份导入报 GTID 相关错误备份带了 GTID 信息,目标环境已有冲突看备份头部是否有SET @@GLOBAL.GTID_PURGED重新导出时加--set-gtid-purged=OFF

表格里最后一行值得单独说。--set-gtid-purged=OFF这个参数,导出时不加,备份文件里就会带一段SET @@GLOBAL.GTID_PURGED='...',导入到一个已经有 GTID 执行记录的实例上会直接报错。而很多备份脚本是几年前写的,那时候还没上 GTID,一直没改。等到真要恢复,才发现导入这一步就过不去。

4.2 三类“看起来能恢复其实不能”的陷阱

第一类:日志断链。表现是重放到某个位点之后,时间突然跳了几个小时,中间的事件没了。原因通常是有人手工PURGE BINARY LOGS清过日志,或者磁盘满的时候日志写入失败。这种情况没有任何补救办法,只能承认数据回不到那个精确时刻。所以我在第 2 节反复强调保留期——它不是“最好配一下”的选项,是硬约束。

第二类:大事务把日志撑爆。一条DELETE FROM big_table不带条件,删了 2000 万行,这一个事务在 binlog 里可能有好几个 GB。解析它要吃掉大量内存,重放它要跑很久。更麻烦的是,如果你想把这段跳过,得先找到事务边界——而这个边界在几个 GB 的日志里翻起来非常痛苦。预防办法是从源头限制:max_binlog_size设置合理值(比如 512M 到 1G),同时在应用层禁止无条件的批量删除,改成小批量循环删。这个规矩听起来像常识,但每年都有人犯。

第三类:DDL 之后的表结构与历史数据不匹配。举个例子:表在 5 月份做了一次ALTER TABLE加字段,7 月份出了事故。你恢复 6 月的全量备份,表结构是旧的,然后重放 6 月到 7 月的 binlog,里面包含那次 ALTER,重放时表结构会跟着变——这个能对上。但如果你为了省事,直接在当前生产库的结构上重放历史 binlog,字段数对不上,行事件解析就会错位。这就是为什么恢复一定要在临时实例上做全流程,不能图快直接往生产上灌。

4.3 演练清单:把恢复时间压到可接受范围

恢复能力这东西,不演练等于没有。我的建议是每季度做一次“盲演”:让一个不参与日常备份的同事,随机指定一个过去 3 天内的时刻,要求把某个库恢复到那个时刻,全程计时。演练清单如下:

  • 备份文件是否可正常导入(不只是看文件存在,要真导一次);
  • 备份头部的位点信息是否完整、是否与日志能对上;
  • binlog 是否覆盖了目标时间段,有无断档;
  • 临时实例的磁盘空间是否够(按生产库大小的 1.5 倍预留);
  • 恢复脚本里的路径、端口、账号是否还有效;
  • 从开始操作到数据可用的耗时是多少,业务方能否接受。

这套流程走一遍,通常能暴露三到五个问题,而且都是平时不会碰到的角落。我印象最深的一次是演练时发现备份恢复脚本里写的还是旧机器的 IP,那台机器半年前就下线了。这种错误只有真跑一遍才会浮出来。

5. 备份策略怎么配才撑得住“任意时间点”

5.1 全量、增量与 binlog 的组合节奏

单靠每天一次全量 + binlog,恢复的 RTO(恢复耗时)会随着数据量增长而拉长。一个 500G 的库,导入一次全量可能就要两三个小时,这在事故场景里太慢了。实际可用的组合是这样:

层次频率保留作用
每周全量(物理热备)每周一次4 周恢复底座,导入快
每日全量(逻辑备份)每天一次7 到 14 天灵活挑表恢复,兜底
binlog 归档持续7 到 30 天时间点重放的唯一来源
快照(存储层)每天或更密3 到 7 天快速回滚到最近状态

物理热备和逻辑备份各有用途,别只留一种。物理备份恢复快、能整实例拉起,适合“整个库回到某个时刻”;逻辑备份灵活、能只挑一张表,适合“单表误删”。两类都备着,出事时才有选择余地。

binlog 的归档要特别注意:不要只留在本机。本机磁盘故障时,日志和数据一起没。定期把 binlog 同步到独立的备份存储上,同步频率建议每小时一次,这样最坏情况损失一小时的恢复粒度。

5.2 恢复演练的频次与验证方法

上一节讲了演练清单,这里讲频次和验证深度。

频次上,我是这么安排:每月一次轻量验证,每季度一次完整演练。轻量验证只做两件事——随机抽一份备份导入临时实例,确认能起来;随机挑一个历史位点,用 mysqlbinlog 解析一下,确认日志能读、位点对得上。这两件事加起来不到 30 分钟,但能拦住 80% 的“备份其实早就坏了”的情况。

完整演练则是全流程:模拟误删、定位位点、隔离恢复、回灌比对、计时。这个流程至少半天,要提前跟业务方打招呼占用一台临时机器。

验证深度上,不要只看“导入没报错”就完事。至少要做三件事:

  1. 行数比对:恢复后的表行数,与备份时刻的记录数对比;
  2. 校验和比对:对关键表算一个聚合校验值,比如SELECT COUNT(*), SUM(id), MD5(GROUP_CONCAT(...))这类组合,和源端比;
  3. 业务语义抽检:随机抽 20 条记录,人工看一眼金额、状态、时间字段是不是合理的。

第三件事最容易被跳过,也最能发现问题。行数对、校验和对,但字段值是错的——这种情况确实存在,尤其是涉及字符集转换、时区转换的场景。

最后说一个我自己的习惯:每次做完全量备份,立刻在备份文件上打一个标记,写上备份时刻、对应 binlog 位点、备份耗时、文件校验值(比如sha256sum)。这几行信息存在一个单独的运维记录里。真出事的时候,你不需要去翻备份文件头部、不需要去猜,直接查记录就知道该用哪份、从哪个位点开始。事故发生的那几分钟,人的判断力是会下降的,能少一步推导就少一步。

这套东西搭起来最开始要花点时间,但它是那种“平时完全感觉不到存在,出事时救命”的基础设施。我在实际使用中最深的体会是:恢复能力从来不是由最先进的那个工具决定的,而是由链条上最弱的那一环决定的——可能是没开的sync_binlog,可能是没记位点的备份脚本,也可能是那个把日志目录当临时文件清理掉的定时任务。把每一环都盘一遍,比研究任何高级恢复技巧都实在。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进