资讯详情

资讯详情

MySQL时间点恢复实战:全量备份+binlog重放全链路

1. 先搞清楚恢复到任意时间点到底在恢复什么1.1 一次误操作之后常见的是三种结局聊 MySQL 数据恢复到任意时间点这个话题我不想一上来就贴参数。先说说我见过最多的场面某个业务库凌晨跑批脚本写错了一个条件一条不带 where 的 UPDATE 把订单状态全刷成了同一个值等到早上业务方发现已经过了四个小时。这时候团队的反应基本分成三档。第一档只有一份昨天凌晨的全量备份没有任何增量日志。这种情况下能做的只有把库回到昨天凌晨昨天一整天的订单、支付、库存全部蒸发业务方只能手工补数据补到怀疑人生。第二档有全量备份也有 binlog但是没人知道误操作发生在 binlog 的哪个位置也没人做过重放演练。于是现场翻文档、临时搭环境、边试边错本来两小时能搞定的事拖成了八小时。第三档全量备份 完整 binlog 一份写清楚坐标的备份元数据 一台随时能起起来的隔离实例。定位、截断、重放、校验整套动作下来一两个小时数据只丢到误操作前的那一秒。这篇文章想讲的就是第三档怎么搭出来。它不是某个高级特性而是一套由全量备份、binlog 归档、位点记录、重放流程四块拼起来的工程能力。适合谁看手上管着 MySQL、需要为数据安全兜底的后端开发、运维、DBA 都合适对 binlog 只停留在听说过阶段的同学也能从里面拿到一条能直接照着做的路径。1.2 本质是一张照片加一卷录像带把时间点恢复拆开看模型特别朴素。全量备份是某个瞬间拍下的静态照片它告诉你在 T0 这个时刻库里的每一行长什么样。binlog 是 T0 之后持续录制的录像带它记录了每一个改变数据的动作。所谓恢复到任意时间点 T就是先把照片摆出来再把录像带快进到 T 那个位置逐帧播一遍。这个模型解释了很多现象。为什么没有 binlog 就只能回到备份时刻因为录像带断了中间空档补不回来。为什么备份必须记录准确的起始坐标因为录像带你得知道从第几分钟开始播播早了重复执行播晚了丢一段。为什么 ROW 格式这么重要因为录像带里如果只写把某行改成新值这种模糊指令回放时机器状态稍有不同结果就不一样而 ROW 格式写的是这一行原来是什么、改成了什么回放是确定性的。我习惯把这个过程叫做基线 增量重放。理解了这一层后面所有的参数、命令、坑都是围绕怎么让这张照片更清晰、这卷录像带更完整、重放时的对齐更精准展开的。1.3 三条硬前提缺一条整条链路就废了很多人以为开了 binlog 就等于有了时间点恢复能力实际上至少要同时满足三个条件。第一条binlog 必须是开着的而且覆盖窗口要够长。binlog 不是永久保存的到达过期时间或者磁盘吃紧时会被自动清理。如果误操作发生在周一而 binlog 只保留三天周三才发现那周一那段已经没了神仙也救不回来。覆盖窗口要按最晚发现误操作的时间来定而不是按平均恢复时间来定。第二条备份里必须带准确的起始坐标和一致的数据快照。只有数据文件没有坐标你不知道该从 binlog 的哪个位置开始播有坐标但数据不一致比如备份过程中有写入重放就会撞主键冲突或者产生重复数据。这两个问题在 mysqldump 里靠--single-transaction和--source-data解决在物理备份里靠 redo 日志的一致性点解决。第三条得有一个能随便折腾的隔离环境。恢复这件事最大的风险不是技术难而是在生产库上直接操作。拿一台独立实例把全量恢复上去、把 binlog 重放上去、把数据校验完确认没问题再考虑切换。没有隔离环境很多团队会在要不要直接在生产上试这个问题上犹豫半小时而这半小时里 binlog 还在继续写。注意第三条经常被忽略但它决定了你出事时敢不敢动手。提前准备一台配置相近的空实例平时就用来做恢复演练出事时直接拿来用这一步能省掉大量现场决策时间。2. 底座配置让 binlog 真正具备可回放的能力2.1 binlog_format 为什么必须是 ROW而不是 STATEMENT这是整条链路里最不能妥协的一项。STATEMENT 格式记录的是执行了什么 SQL 语句看起来更省空间、更直观但它有个致命问题回放结果不保证和执行时一致。举个最常见的例子一条UPDATE t SET update_time NOW() WHERE id 100STATEMENT 格式里存的就是这句原文等你在恢复实例上重放时NOW() 取的是重放时刻的时间不是原始执行时刻。类似的还有UUID()、RAND()、session变量、带LIMIT但没ORDER BY的更新行的命中顺序不确定。平时主从复制看起来没事是因为延迟小、数据分布稳定但做恢复重放时这些差异会实打实地变成数据错误。ROW 格式记录的是行级前镜像和后镜像也就是这一行的哪几个列从什么值变成了什么值。回放时不需要重新求值任何函数直接按镜像写入结果是确定的。做时间点恢复binlog_formatROW是底线。配套的还有一个参数binlog_row_image默认是FULL意思是前镜像和后镜像都记录完整的所有列。有人为了省空间改成MINIMAL只记录被修改的列和主键这在复制场景下能跑但在反向解析回滚 SQL的场景下会缺字段恢复出来的行是不完整的。所以我的建议是保持FULL空间换确定性这个交易划算。-- 检查当前设置 SHOW VARIABLES LIKE binlog_format; SHOW VARIABLES LIKE binlog_row_image;2.2 保留窗口怎么算别拍脑袋按日增量推binlog 保留期的配置在 8.0 是binlog_expire_logs_seconds单位秒5.7 是expire_logs_days单位天。设多少合适取决于两件事你的 binlog 日增量以及你能接受的最晚发现误操作时间。先量日增量方法很直接看现有 binlog 文件的大小和生成时间SHOW BINARY LOGS;假设列出来一天大概生成 20GB 的 binlog你要求覆盖 7 天那就是 140GB 的额外磁盘占用这部分要提前算进磁盘规划里。很多事故的真实起因不是没开 binlog而是磁盘满了导致 binlog 写入失败或者运维为了救磁盘手动PURGE BINARY LOGS把关键日志清了。-- MySQL 8.0保留 7 天 SET GLOBAL binlog_expire_logs_seconds 604800; -- MySQL 5.7保留 7 天 SET GLOBAL expire_logs_days 7;顺手提一句8.0 里binlog_expire_logs_seconds和expire_logs_days同时设置时以秒为准两个都配上容易互相干扰挑一个用就行。2.3 sync_binlog恢复精度和写入性能之间的那根绳sync_binlog决定 binlog 什么时候真正落到磁盘。设成 1每次事务提交都 fsync 一次机器掉电最多丢最后一点点设成 0交给操作系统决定刷盘时机性能好一些但崩溃时可能丢掉最后几百毫秒到几秒的 binlog 事件。这里要区分清楚两个概念事务提交成功和binlog 落盘。如果sync_binlog0事务返回成功但 binlog 还在操作系统缓存里这时候机器断电数据页可能已经落了innodb_flush_log_at_trx_commit1的情况下binlog 却丢了这就造成了主库数据和 binlog 不一致后面重放时会撞上数据已存在的错误。我的做法是主库上sync_binlog1innodb_flush_log_at_trx_commit1两个都开牺牲一点写入性能换数据安全。对于写入压力特别大的场景可以退一步用sync_binlog100这类折中值但要清楚这意味着崩溃时可能丢掉最多 100 个事务的 binlog时间点恢复的精度会受影响。参数组合崩溃时可能丢失适用场景sync_binlog1 trx_commit1几乎不丢核心业务库恢复精度要求高sync_binlog100 trx_commit1最多 100 个事务的 binlog写入压力大、可容忍分钟级误差sync_binlog0 trx_commit2数秒数据日志类、可重算的场景2.4 GTID 和 position两种定位方式各有各的顺手处定位重放起点和终点MySQL 给了两套坐标。基于 position 的方式坐标形如mysql-bin.000123:884512含义是第 123 号日志文件的第 884512 字节。它精确、直接缺点是一旦日志文件被切分或者清理坐标就失效了而且跨主从切换后 position 完全对不上。基于 GTID 的方式坐标是一串uuid:transaction_id含义是第几个事务。它的好处是全局唯一、跨实例可对齐主从切换后照样能用重放时可以指定跳过某几个 GTID 不执行做排除特别方便。如果集群已经开了 GTIDgtid_modeON、enforce_gtid_consistencyON恢复流程我优先用 GTID尤其是需要跳过个别事务的时候比算 position 省心太多。如果没开 GTIDposition 也能用只是要额外注意日志文件的完整性和顺序。3. 备份基线怎么打才敢在出事时真的拿来用3.1 mysqldump 的关键参数每个都有它的理由数据量在几十 GB 以内逻辑备份是最省事的选择。但随手一条mysqldump db backup.sql是不够用的下面这组参数我认为是标配mysqldump -h127.0.0.1 -P3306 -uroot -p \ --single-transaction \ --source-data2 \ --flush-logs \ --routines --triggers --events \ --set-gtid-purgedOFF \ --default-character-setutf8mb4 \ --databases order_db /backup/order_db_$(date %Y%m%d%H%M).sql逐个说清楚为什么。--single-transaction让备份过程中的所有读操作处于同一个可重复读快照里保证 InnoDB 表的数据是一致的。注意它只对事务型引擎有效如果库里混着非事务引擎的表一致性保证不了这种情况必须用物理备份或者加锁。--source-data25.7 里叫--master-data2会把备份开始时的 binlog 坐标以注释形式写进 SQL 文件开头这是重放起点的来源。设成 2 表示写成注释而不是直接执行CHANGE MASTER避免恢复时误触发复制配置。--flush-logs在备份开始前切一个新的 binlog 文件。好处是起点坐标正好落在新文件的开头附近重放时截取范围清晰不用在半个老文件里找位置。--set-gtid-purgedOFF在开了 GTID 的环境里必须加。不加的话导出的文件里会带上SET GLOBAL.GTID_PURGED恢复到新实例时会因为 GTID 集合冲突直接报错。恢复目标是独立实例时一般不需要带上原实例的 GTID 执行历史关掉更省事。3.2 数据量大时逻辑备份会被物理备份替代单库超过一两百 GBmysqldump 的导出和导入时间会长到无法接受恢复窗口根本压不住。这时候要换成基于数据页拷贝的物理热备工具思路是直接复制 InnoDB 的数据文件同时记录 redo 日志最后通过一个 prepare 阶段把数据文件推进到一致状态。物理备份的优势是快几百 GB 的库恢复可能只要十几分钟劣势是版本和平台相关跨大版本恢复基本不可行也不能只恢复单表除非用导出单表的附加功能。所以我的常见组合是物理备份做日常基线逻辑备份做单表级的补充和跨版本迁移。不管用哪种最终都要产出一份元数据文件内容至少包括备份开始和结束时间、binlog 文件名与起始 position、起始 GTID 集合、MySQL 版本、字符集、备份文件校验和。这份文件是出事时唯一能救命的说明书比备份本身还容易被忽略。备份方式恢复速度粒度跨版本适用规模逻辑备份慢库/表级灵活支持几十 GB 以内物理热备快实例级基本不支持几百 GB 以上文件系统快照最快实例级受限有存储层支持时3.3 备份完不做恢复演练等于没有备份我在实际工作中踩过最疼的一次坑不是备份没做而是备份做了三个月从没验证过。真出事的时候打开文件才发现某个库的字符集参数写错了中文全是乱码更早一次是备份任务被 cron 里的超时杀掉文件只有正常大小的三分之一但退出码是 0监控从来没报过警。现在的做法很固定每周从最近一次的备份里随机抽一份在隔离实例上完整恢复一遍检查三个指标——表数量对不对、几个关键表的行数量级对不对、随便挑几条业务数据内容对不对。同时记录整个恢复耗时这个数字直接决定了真实故障时的 RTO。4. 完整实操把数据精准拉回误操作前的那一秒4.1 先把时间线摊开假设某公司的订单库配置是 ROW 格式 GTID 开启 binlog 保留 7 天。事件顺序是这样的00:00 每日全量备份完成元数据记录 binlog 起点为mysql-bin.000123的 position 154GTID 集合为3f2a...:1-88000009:30:12 某开发在排查问题时执行了一条漏了 where 条件的 UPDATE影响了订单表里的 12 万行09:42 业务侧发现订单状态异常报障09:45 值班同学介入此时最要紧的动作不是去查 SQL而是先止住写入。原因很简单每多写一秒binlog 就多一段误操作之后的有效数据也还在持续变化越晚处理需要丢弃或修正的数据越多。标准的做法是把应用侧切到只读或者直接把业务库的连接摘掉让写入停下来。紧接着第二个动作立刻把当前正在写的 binlog 从磁盘上拷出来并执行一次FLUSH BINARY LOGS。之所以要拷贝是因为后续可能会有人为了腾空间清理日志或者实例重启导致日志轮转拷到独立目录之后这些日志就与生产实例解耦了怎么操作都不怕。-- 切一个新日志文件把当前文件封口 FLUSH BINARY LOGS; SHOW BINARY LOGS;# 把相关 binlog 全部拷到备份盘 mkdir -p /backup/rescue_20240101 cp /var/lib/mysql/mysql-bin.00012* /backup/rescue_20240101/ ls -lh /backup/rescue_20240101/4.2 在 binlog 里把那个事务揪出来接下来是定位。用mysqlbinlog把嫌疑时间段的日志解析成可读文本关键在于两个参数--base64-outputDECODE-ROWS把 ROW 格式的二进制事件解码成伪 SQL-v输出详细的行变更信息。mysqlbinlog --base64-outputDECODE-ROWS -v \ --start-datetime2024-01-01 09:25:00 \ --stop-datetime2024-01-01 09:35:00 \ /backup/rescue_20240101/mysql-bin.000123 /tmp/inspect_0930.sql打开文件之后关注几样东西每个事务开头会打印BEGIN和 GTID形如SET SESSION.GTID_NEXT3f2a...:880123接着是### UPDATE order_db.orders加前后镜像的行数据最后是COMMIT加# at 884512这样的位置标记。判断哪个事务是元凶看两点受影响的行数和执行时刻。12 万行的更新在 ROW 格式里会有大量### UPDATE行文件里肉眼可见地特别长时间戳也对得上 09:30:12。找到之后把它前面那个位置记下来这就是重放的终点。如果开了 GTID还有一个更快的办法直接找那个事务的 GTID后面重放时用--exclude-gtids把它排除掉其余全部重放。这在误操作之后还有正常业务写入、需要保留的场景下特别有用。注意mysqlbinlog的--start-datetime和--stop-datetime是按运行 mysqlbinlog 那台机器的本地时区去解析的而 binlog 里存的是 UTC 时间戳。如果解析机和数据库不在同一时区筛选出来的范围会整体偏移几个小时。我遇到过解析出来的日志看起来一条都没有的情况最后发现就是时区差。4.3 截断出可回放的片段定位到误操作事务的起始位置之后就要生成从备份起点到误操作之前这一段的重放脚本。这里分两种情况。情况一只恢复到误操作前丢弃之后的所有变更。这是最常见的做法命令里用--stop-position卡住终点mysqlbinlog --start-position154 \ --stop-position884500 \ /backup/rescue_20240101/mysql-bin.000123 \ | mysql -h127.0.0.1 -P3307 -uroot -p target_instance如果时间跨度跨了多个 binlog 文件把文件名按顺序全部列出来用空格或通配符连起来即可。mysqlbinlog会按给定顺序依次解析中间不要漏文件漏一个就会出现数据断档。情况二跳过误操作保留之后的正常写入。这种就得用 GTID 排除mysqlbinlog --exclude-gtids3f2a4c1e-...:880123 \ /backup/rescue_20240101/mysql-bin.000123 \ /backup/rescue_20240101/mysql-bin.000124 \ | mysql -h127.0.0.1 -P3307 -uroot -p target_instance这里有个细节值得强调--stop-position的边界是不包含该位置的事件执行也就是说停在那条事件之前。所以终点要取误操作事务BEGIN事件对应的位置而不是它后面某个位置。取错了会导致误操作被部分重放那比不恢复还麻烦。4.4 在隔离实例上落地并校验真正的恢复动作分三步走全部在隔离实例上完成。第一步把全量备份导入。逻辑备份直接喂给 mysql 客户端mysql -h127.0.0.1 -P3307 -uroot -p /backup/order_db_20240101000000.sql导入完成后立刻核对元数据里的坐标确认这个备份的终点就是mysql-bin.000123:154。如果导入过程因为主键冲突、表已存在报错说明目标实例不干净必须清空重来不要试图--force硬扛过去。第二步重放 binlog。用上一步准备好的片段注意重放时最好临时关掉 binlog 记录避免恢复过程自己又写一堆日志SET SESSION sql_log_bin 0;第三步校验。这一步最少要查三个层次表数量与元数据是否一致、关键表的行数是否落在合理区间、抽样若干条业务记录逐字段比对。有条件的话把核心表的CHECKSUM TABLE结果和备份时刻的记录做个对比能更快发现问题。4.5 切换与收尾隔离实例验证通过之后切换方式取决于业务架构。如果是主从结构最稳的是把恢复好的实例提成新的主库把应用连接切过去如果只是单表数据出错可以只把出问题的那张表导出来在生产上做定点替换代价小得多。切换之后还有两件事别漏。一是把误操作的那条 SQL 和这次恢复的完整过程写成记录包括定位到的 position、GTID、耗时、遇到的问题这份记录下次能省一半时间。二是回到源头堵住漏洞生产账号禁止直连、禁止不带 where 的批量更新、给变更操作加审批和 dry-run 环节。5. 常见问题与排查技巧实录5.1 时间对不上八成是这几个原因按时间筛选 binlog 却没筛到想要的事件这是最高频的问题。我整理过几种成因。时区问题前面提过解析机与数据库时区不一致。解决办法是用--start-position代替时间筛选或者先把解析机时区调成和库一致。长事务的时间戳错位。一个事务可能 09:00 开始09:30 才提交binlog 里 BEGIN 事件的时间戳是 09:00COMMIT 是 09:30。如果按--start-datetime09:25去筛这个事务的部分事件会被漏掉导致解析出来的片段不完整。判断方法是看事务前后事件的时间戳跨度跨度大的要特别小心。从库上的 binlog 时间戳不是原始执行时间。从库应用 relay log 时binlog 事件的时间戳取决于当时的TIMESTAMP处理方式和主库执行时间可能有偏差。如果要在从库上做恢复最好以主库的 binlog 为准。5.2 binlog 缺失、损坏、被清空这几种情况在事故现场并不罕见处理思路各不相同。过期清理是最常见的PURGE BINARY LOGS或自动过期把需要的文件删了。这时候只能退回到最近一次全量备份或者从从库、其他副本上找是否还有残留的日志文件。防范手段就是把保留窗口设够长并且监控 binlog 文件的生成和清理情况。主从切换导致文件编号重置。新主库的 binlog 从mysql-bin.000001重新开始和旧主库的文件编号重叠按文件名顺序重放会乱序。这时候必须靠 GTID 来定位或者把文件重命名成带实例标识的格式。文件损坏通常伴随磁盘异常或写入中断表现为mysqlbinlog解析时报错退出。可以试试跳过损坏部分继续解析但恢复出来的数据完整性就要打问号了这种情况建议直接以最近的全量备份为准。5.3 大事务、DDL、跨库操作这几个硬骨头大事务没法在中间截断。MySQL 保证一个事务的所有事件写在同一个 binlog 文件里所以即使max_binlog_size设成 1GB一个 5GB 的事务也会让某个文件远超这个大小。这意味着你没法把重放停在事务内部只能整个事务一起执行或者一起跳过。遇到这种大事务重放时间长、锁持有久最好在业务低峰做。DDL 是隐式提交的回滚不了。误执行DROP TABLE或TRUNCATE之后binlog 里记录的是这条 DDL 本身没有行级镜像可以反向解析。处理方式只有一条恢复到 DDL 执行之前的时间点然后单独把那张表导出来补回生产。所以线上环境的 DDL 权限要收得很紧这个不是流程洁癖是真金白银的教训。跨库或多表事务在 ROW 格式下会按表分别记录事件重放到只包含部分库的目标实例时会因为找不到表而报错。解决办法是重放时加上--rewrite-db做库名映射或者在目标实例上预先建好空表结构。5.4 常见问题速查表现象可能原因排查动作处理方式按时间筛不到事件时区不一致对比解析机与库的时区改用 position 筛选重放报主键冲突起点选早了重复重放检查起点 position 是否等于备份终点调整起点或跳过冲突事务重放报表不存在目标库缺表或库名不同核对表结构先建表或用库名映射解析日志报错退出文件损坏或不完整校验文件大小与校验和从其他副本取日志恢复后数据缺一段中间漏了某个 binlog 文件核对文件编号连续性补齐文件重新重放大事务重放卡住事务涉及行数过多看 ROW 事件数量错峰执行分批处理时间点恢复后仍有重复数据恢复前生产还在写检查恢复期间是否有新写入先冻结写入再恢复5.5 一个很少人提但很好用的技巧对于DELETE误删这类场景如果只是少量行其实不一定非得走全量恢复。ROW 格式的 binlog 里存了被删行的完整前镜像理论上可以把它反向拼成INSERT语句只把删掉的行补回去。有些开源脚本就是干这个的也能自己写个解析程序处理。但这个路子有几个前提binlog_row_image必须是FULL否则拼出来的行缺字段目标表上不能有会冲突的唯一键自增主键要显式指定值不能让库自己生成。所以我的经验是行数少、表结构简单时用反向解析补数据最快行数多或者涉及多表关联时老老实实走全量加 binlog 重放别贪这个快。6. 从能恢复到敢恢复把流程变成可重复的动作6.1 备份策略分层别用一套方案打天下单一备份策略很难同时满足成本和恢复速度。我倾向的做法是分层每周一次全量物理备份做基线用于大范围恢复每天一次逻辑备份用于单表级别的定点修复和跨环境迁移binlog 持续归档到独立存储覆盖窗口按业务重要性定核心库至少 7 天重要库 3 到 7 天。归档这块要特别注意binlog 不能只留在数据库所在的那块盘上。数据库盘出问题的时候数据和日志一起没恢复链路直接断掉。定期把 binlog 同步到独立的备份存储并且校验传输完整性这是把能恢复变成一定能在灾难后恢复的关键一步。6.2 把恢复演练做成例行任务恢复能力不是文档写出来的是练出来的。我现在习惯把演练做成固定周期的事每月挑一个非核心库模拟一次误删从发起故障到数据校验完成全流程走一遍记录每一步的耗时和卡点。这个过程中最有价值的信息往往不是花了多久而是在哪个环节卡住了。可能是找不到备份元数据文件可能是隔离实例版本和备份版本不匹配可能是某个库的字符集参数不对。这些细节在真出事时会被无限放大提前踩一遍就都不是事了。6.3 几条我认为必须写进规范的事备份必须带元数据文件位置、GTID、时间、版本、校验和一样不能少并且和数据文件放在一起。恢复必须在隔离环境完成验证后再切换不允许直接在生产库上试。生产账号禁止直连执行 DML尤其是批量更新和删除走变更平台加 dry-run。binlog 归档独立存储且保留窗口要覆盖最晚可能发现误操作的时间不是平均值。每次恢复都留一份复盘记录记录误操作 SQL、定位坐标、恢复耗时、踩到的坑形成团队自己的知识库。这几条听起来都很朴素但真出事的时候能同时做到这五条的团队恢复过程往往波澜不惊做不到的基本都是在现场重新发明流程。我个人在实际操作中的体会是时间点恢复这件事技术门槛其实不高命令一共就那么几条难的是在出事的那一刻所有前置条件都已经就位。binlog 开着、保留窗口够长、备份带坐标、隔离实例随时能起、恢复演练做过几遍——这五件事里有任何一件没做现场就会多花几个小时去补救。所以与其在事故中研究怎么恢复不如平时把这几件事按周按月地做扎实出事时打开文档照着敲命令就行。真正让人安心的从来不是我知道怎么恢复而是我上个月刚恢复过一次用了 47 分钟。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →