一、为什么「每天备份」不等于「能恢复到现在」
个人站长最常做的一件事是每天 mysqldump 一份全库,压缩后扔进 backup 目录,磁盘够大就一直躺着。这套方案对付「误删一张表」确实有效,但遇到另一类事故就完全失效:比如下午三点上线了一次改动,代码里有个循环把价格字段全部写成了 0,四小时后才被发现。这时候你需要的不是「回到昨天凌晨两点」,而是「回到今天下午两点五十九分」——昨天的全量备份里根本没有今天上午产生的新订单,直接还原等于把一整天的业务数据一起抹掉。
这就是时间点恢复(Point-In-Time Recovery,简称 PITR)要解决的问题。它的原理并不复杂:先还原一份最近的全量备份作为「底座」,然后把备份之后产生的 binlog 一条条重放上去,一直重放到事故发生前的最后一秒。全量备份提供「压缩后的过去」,binlog 提供「连续的近况」,两者拼起来才是完整的时间线。缺任何一半都不成立:没有全量备份,binlog 要重放几个月;没有 binlog,两份全量之间的几个小时就是空白。
我踩过的坑是:备份脚本一直在跑,binlog 却从来没检查过。有一次磁盘紧张,我在 my.cnf 里把 expire_logs_days 从 7 改成了 1,心想「反正每天有全量,binlog 留着占地方」。三个月后真的需要恢复时才发现,事故当天的 binlog 早已被自动清理,只能退到前一天的备份,损失了大约十几个小时的数据。所以这篇要讲的不是「怎么开 binlog」,而是「怎么让 PITR 真的能在需要的那一刻用上」。
二、先确认三件事:binlog 开着、是 ROW、能读到
MySQL 8.0 默认已经开启 binlog,但很多从 5.7 迁移过来、或者用面板一键安装的环境里仍然关着。开工第一件事是查,不是改:
mysql -uroot -p -e "SELECT @@log_bin, @@binlog_format, @@binlog_expire_logs_seconds, @@server_id;"三件必须有:log_bin=1;binlog_format=ROW;server_id 非零(哪怕单机也要给一个值,否则开不起来)。ROW 格式是 PITR 的前提,因为只有 ROW 才记录了每一行改动前后的完整镜像,才能在误操作之后算出「反向 SQL」做精确回滚。STATEMENT 格式重放时依赖执行时的上下文(当前时间、随机数、自增),重放结果可能与原库不一致;MIXED 虽然大多数情况可用,但既然要做恢复,就别留不确定性。
接着要确认 binlog 文件真的落在磁盘上、并且磁盘还有余量:
ls -lh /var/lib/mysql/mysql-bin.* | tail -5
df -h /var/lib/mysql个人站一天写入几百万行的话,binlog 一天可能增长几百兆到几 G。保留策略要同时看两个维度:时间上至少覆盖「两次全量备份之间的间隔 × 2」,容量上保证分区不低于 30% 余量。如果每天全量、保留 7 天,那么 binlog 至少要覆盖 7 天,这样任意两份全量之间的空隙都能补齐。把 binlog_expire_logs_seconds 设成 604800 比用天为单位的旧参数更精确,8.0 之后旧参数已经废弃。
三、全量备份必须带 binlog 坐标
做 PITR 的备份和普通备份只差一个参数,但少了它整条链路就断了。完整命令是这样的:
mysqldump -uroot -p \
--single-transaction \
--source-data=2 \
--set-gtid-purged=ON \
--routines --triggers --events \
--all-databases \
| gzip > /data/backup/full-$(date +%F).sql.gz--single-transaction 保证 InnoDB 表的一致性快照且不锁表;--source-data=2(5.7 里叫 --master-data,8.0.26 后改名)会在备份文件里写入一行注释,记录备份开始时刻对应的 binlog 文件名和位置,这一行就是 PITR 的起点坐标。用 2 而不是 1,是因为 1 会写一条 CHANGE MASTER 语句,直接执行会把从库配置一起改掉,作为一个参考坐标用注释更安全。
备份完必须做一件事:把坐标单独记下来。恢复现场最怕的是「有备份、有 binlog,但不知道从哪个位置开始重放」。我喜欢在备份的同时往一个文本索引里追加一行:
echo "$(date -Is) $(grep -m1 'CHANGE MASTER\|CHANGE REPLICATION' /data/backup/full-$(date +%F).sql.gz 2>/dev/null | head -c 200)" >> /data/backup/index.log更稳的做法是解压备份头的第一兆字节去找那行注释,而不是猜。索引文件本身也要跟备份一起异地存一份,否则机器挂了的时候你连「备份对应哪个 binlog 位置」都不知道。
四、把 binlog 变成「人能看懂」的文件
需要恢复时,第一反应不应该是「直接重放」,而是先把范围找出来。mysqlbinlog 有几个参数能把一堆二进制变成可读的 SQL:
mysqlbinlog --base64-output=DECODE-ROWS -vv \
--start-datetime="2026-11-05 00:00:00" \
--stop-datetime="2026-11-05 15:00:00" \
/var/lib/mysql/mysql-bin.000123 > /tmp/bn_readable.sql--base64-output=DECODE-ROWS -vv 会把 ROW 格式的事件翻译成带注释的伪 SQL,能看清每一行改了什么、时间戳是多少。这一步的目的不是执行,而是定位:找到那条「把价格写成 0」的 UPDATE 对应的 GTID 或 position。用 grep 在可读文件里搜表名,前后翻几十行,通常几分钟就能锁定时点。
如果开了 GTID,重放最好按 GTID 集合来控制,比 position 更不容易错位——因为 position 依赖文件边界,而 GTID 是全局唯一的。定位到之后重放用:
mysqlbinlog --skip-gtids=false \
--include-gtids='3e11b9de-8c2a-11ee-9b3f-0242ac110002:1-10457' \
/var/lib/mysql/mysql-bin.000123 /var/lib/mysql/mysql-bin.000124 \
| mysql -uroot -p注意重放到临时实例而不是生产库。PITR 的正确姿势永远是「在旁路环境恢复出一份完整数据,确认无误后再决定怎么把数据搬回去」,直接在线上重放等于把唯一的现场也毁了。
五、一个能跑的最小演练流程
把这套东西写成脚本,每月跑一次,才能在真出事时不慌。流程分七步,全部在旁路实例上完成:
第一步,启动一个独立实例,用不同的 datadir、端口和 socket,避免碰到线上:
mysqld --no-defaults --datadir=/data/pitr/datadir --port=3307 \
--socket=/tmp/pitr.sock --skip-networking=0 \
--log-error=/data/pitr/err.log &第二步,导入最近一份全量备份,过程中任何一行错误都要让脚本退出非零,不能吞掉。第三步,找到备份里的坐标,算出需要重放的 binlog 文件列表。第四步,按 GTID 或 position 重放到目标时间点。第五步,做一致性检查——不是「能连上」就算完,而是抽三张核心表做行数比对和最新一条记录的时间戳比对,与事故前的业务侧记录对齐。第六步,记录这次演练的恢复耗时(RTO)和最后一条成功记录的时间(RPO),这两个数字写进日志。第七步,关掉实例,清理 datadir。
整个演练最容易被跳过的是第五步。我见过太多「恢复成功」的复盘,其实只是 mysqld 起来了、能 select count(*) 了,业务方一用就发现数据缺了一大块。所以检查项要具体到表:订单表取最近一小时的行数与业务后台报表对上;用户表检查自增 ID 的最大值是否回退;文章表检查最后发布时间是否落在预期区间。三项都过,才算这次恢复可用。
六、几个会让人白忙一场的细节
第一个是字符集与时区。mysqldump 默认带 SET NAMES 和时区设置,重放时如果旁路实例的 time_zone 与线上不同,带时间字段的 WHERE 条件可能命中错误的行。恢复前统一用 SET time_zone='+08:00',并在脚本开头断言 @@time_zone 与线上一致。
第二个是 sql_log_bin。在旁路实例重放时如果不关掉它,旁路实例自己也会写 binlog,白白占空间;但如果是「恢复后要接回主从」的场景,就必须保留。我的做法是演练实例一律 SET sql_log_bin=0,接回生产时另走一次正式同步。
第三个是 DDL 的不可逆性。ROW 格式能精确回滚 DML,但一条 DROP TABLE 或 ALTER TABLE ... DROP COLUMN 重放到一半就再也回不去。所以真正的防护是「DDL 之前先做一次全量」,而不是指望 binlog。这也是我把全量备份安排在每日凌晨之外、另加「发布前备份」的原因。
第四个是磁盘空间。恢复一份 20G 的库到旁路实例,加上重放期间产生的临时文件和 binlog,实际占用可能是线上的两倍以上。演练脚本开头要先算剩余空间,不够就直接报错退出,别跑到一半把 /data 塞满,连线上都一起挂掉。
七、让 PITR 变成日常习惯
技术方案写完了,真正决定它有没有用的还是节奏。我现在的做法是三件事固化到日历里:每天凌晨全量备份并记录 binlog 坐标;每周自动跑一次「恢复到最后一条 binlog」的空演练,只验证链路通不通,不比对数据;每月一次完整演练,包含数据比对和 RTO 记录。三档成本递增,覆盖的失败模式也不同——每天那档防止「备份脚本悄悄坏了」,每周那档防止「binlog 被清理或不连续」,每月那档才是真正的实战预演。
还有一个容易被忽略的指标:binlog 的连续性。每隔几天跑一次 SHOW BINARY LOGS,把文件名序号和列表保存下来,下次比对时如果发现中间少了几个号,说明有异常清理,要立刻查是谁删的。这个检查只要一行 SQL,却能提前几个月发现「PITR 已经不可能成功」的事实。
八、小结
时间点恢复不是高级功能,而是每个有真实数据写入的站点都该具备的基本能力。落地只需要四件事:binlog 用 ROW 且留够天数;全量备份带 --source-data=2 并单独记录坐标;恢复永远在旁路实例做并在数据层面验证;把演练写进日历而不是写进文档。做完这四件之后,再遇到「下午三点误操作」这类事故,你能给出的回答就从「只能回到昨天」变成「可以回到三点差一分」,这两句话之间的差距,就是能不能保住当天业务的区别。