MySQL 备份、复制与恢复演练:从数据保护到故障切换

用真实账本验证一致快照、事务回放与写入边界

Aias00

2026-10-21

前言:恢复是一次需要证明的业务变更

数据库备份页面显示昨天的任务成功,复制监控显示两个线程都在运行,这些信息让人安心,却还没有回答最困难的问题:如果此刻误删订单,或者主库在最后一次提交之后失联,明天恢复出来的系统究竟承认哪些交易?客户已经收到成功的付款,会不会消失?一笔支出是否只剩下账户余额变化,却找不到对应订单?旧主重新启动时,又由谁保证它不继续接受写入?

本书把这些问题放进同一套实验。我们建立账户、订单和分录三种业务记录,用真实MySQL实例完成备份、恢复、日志回放和手工故障切换。每一次所谓成功,都必须拿出明确的业务状态。文件能下载、SQL能运行、复制线程没有报错,是过程证据;目标订单存在、应排除的事务不存在、账务关系成立,才是结果证据。

这本书与讨论索引、事务隔离和死锁的读物有不同的中心。我们会使用事务,却把视角放在数据库之外:业务确认、日志保存、恢复环境、写入入口与交付验收如何共同形成恢复能力。你不需要先构建复杂运维平台,但应能阅读SQL,理解提交与回滚,并愿意区分已经观察到的行为和希望系统具有的能力。

全书十八章沿着一条恢复路径展开。先定义承诺与实验边界,再制作可恢复的一致快照,然后用准确事务边界回放二进制日志,随后观察异步复制的应用延迟与丢失窗口,最后处理旧主隔离、手工提升、业务核对与演练制度。附录提供练习解析、官方来源和术语,完整实验代码会自动编入电子书,也能单独下载。

正式运行环境是Ubuntu的GitHub Actions和固定摘要的MySQL官方容器镜像。作者机器不具备Docker服务,因此没有把本地静态检查冒充数据库实验。随书程序在远端实际启动四个实例,生成机器可读证据,独立解压的实验包还会重新执行全部场景。具体版本、耗时与恢复结果以下载区的validation.json为准,不能把一次小数据集运行外推为生产容量指标。

本书不实现自动选主、代理切流、跨地域高可用或物理存储损坏恢复。故障切换是受控的人工流程,旧主通过容器停止而被隔离,重新启动时保持断网和只读。异步复制中尚未到达副本的已确认事务会真实丢失,我们保留这个结果。理解边界并没有削弱方案,反而让下一次改进有了清楚的起点。

所有破坏性操作只针对程序创建的随机命名教学资源。请先读第二章的隔离约定,再运行实验。生产系统中的恢复与切换必须结合你自己的拓扑、权限、存储和业务确认协议重新设计。我们要获得的能力,是知道如何提出可验证的恢复目标,并持续保存足以判断目标是否达成的证据。

第1章:先把恢复承诺写成可检查的业务事实

一家订单系统承诺成功提交后扣减账户余额。平时,应用把一次下单包装成事务:新增订单,写入负数分录,再更新余额。数据库故障后,人们往往先问备份有多大、导入要多久。更有价值的第一问是:恢复目标对应哪一次业务确认?如果这个问题没有答案,即使导入过程完全成功,也可能恢复到一个不被客户接受的世界。

用账本定义成功

随书业务从余额十万开始,所有金额使用整数最小单位。每个订单恰有一条支出分录,金额相反;账户余额等于初始余额加上全部分录之和。三条关系分别约束对象对应、金额含义和总账平衡。只有余额正确还不够,两笔订单一正一负地错位可能抵消;只有订单数量相同也不够,恢复出来的可能是不同客户或不同金额。

实验因此同时保存完整有序订单行、余额、分录总和与摘要。摘要方便比较和传递,但原始行仍保留在报告里,使差异能够定位到具体订单。这里的数据集很小,可以直接逐行检查。生产大表可能采用分区分块的规范化摘要,再对不一致块逐行核对。变化的是核对规模,核对的业务含义不能丢失。

给恢复目标一个名字

恢复通常有三类目标。第一类是回到某个备份快照,承认快照之后的交易暂时不存在;第二类是保留误删之前的有效事务,排除误删及其后不打算保留的变化;第三类是主库失效后以副本继续服务,接受或处理副本尚未拥有的确认记录。这三类目标的判定集不同,不应由同一个“恢复成功”标签掩盖。

我们给每个关键事务显式业务标签,例如误删前保留、误删后新增、已确认但未复制、新主专属写入。标签只用于实验解释,不参与MySQL的提交协议。恢复后直接查询这些ID,便能区分目标。生产可用订单号、支付流水号、幂等请求键等稳定标识,避免只根据大致发生时间猜测哪些请求丢失。

RPO表达允许或实际发生的数据损失范围,RTO表达从某个故障起点到某个服务恢复终点的时间。它们必须附带边界。本书实际报告丢失的一笔事务及其订单号,并记录客户端观测窗口;手工RTO从发起停止旧主到新主业务提交成功并核对结束。故障发现、代理路由和客户端重试没有进入程序,所以没有被算成完成。

把承诺拆成条件

“最多丢五分钟数据”至少依赖备份和日志能保存到哪里、日志能否读出、目标环境能否启动、恢复操作能否在预算内完成。复制副本落后五分钟,和每五分钟拷贝一次日志,并不是同一种保护。前者可能已经接收而尚未应用,后者可能根本没有保存源库最后一段日志。遇到源盘不可读时,两者的可恢复范围不同。

“十分钟恢复服务”也包含多个阶段:发现故障、判断故障、阻止旧主、选取数据源、恢复或提升、核对、切换入口、允许应用继续提交。若只给SQL导入计时,会把很多影响用户的时间藏起来。演练报告可以分别记录各阶段,再用整体边界给出总数。阶段拆解的作用是找到瓶颈,不是把困难阶段排除后宣传更好看的数字。

不变量不能替代业务确认

一个自洽的数据库也可能丢失合法订单。恢复到昨天的备份通常账务平衡,但今天客户收到的成功回执都不在里面。因此验收包含两层:数据库内部不变量,以及外部已确认事务集合。实验用源库客户端成功返回模拟确认;生产可能需要支付通道、消息回执或应用请求日志共同判定。不能要求损坏的数据库自己证明所有曾经确认的事实。

还要定义不可自动合并的情况。如果旧主和新主各接受不同订单,双方都有合法分录和余额,单边核对都能通过,但全局已经分叉。恢复流程必须先控制写入权,再决定数据如何修复。把两个库的行机械并集导入,可能重复支付或破坏依赖,属于新的业务变更,不能冒充普通复制追平。

本章练习

为一个库存服务写三条恢复后必须成立的关系,并列出两种关系都成立但客户仍可能受损的情况。再为RTO写清开始事件和结束事件。答案不能只写“恢复成功”或“服务正常”,应能由日志、请求和业务数据共同检验。完成这一步,后面每条备份命令才有明确的交付对象。

第2章:搭建只会破坏自己的四实例实验

恢复教学必须真的删除数据、停止复制和结束数据库进程。如果命令可能连到日常开发数据库,读者就无法放心验证负面结果。本书先建立一个封闭拓扑,再把所有破坏性命令绑定到本次创建的资源。实验不是让你在现有MySQL上执行几条危险SQL,而是从空白环境完整建立、观测和拆除。

四个实例各有一项职责

source是原始写入库,replica通过GTID异步复制接收事务。restore用于导入并发写入期间取得的逻辑快照,证明备份本身一致;pitr从第二份安静基线开始回放日志,证明误删前目标能够重建。四个角色不能混用,否则在源库上查询正确数据,可能被误当作恢复实例已经正确。

每次执行生成随机前缀,创建独占Docker bridge网络。容器只有这个私有网络,没有向宿主机发布数据库端口。实例使用不同server-id,数据库初始化生成各自server_uuid。程序执行SQL时带上完整容器名,不接受用户提供任意生产地址。容器标签也写入本次前缀,便于异常后识别资源。

销毁只遍历程序本次成功创建并记录的容器名,删除这些容器及其匿名卷,然后删除自己的网络。没有全局prune,没有按模糊名称批量删除。正常异常会进入finally清理;宿主机强杀或机器断电可能阻止清理,此时需要根据已知前缀逐项核查。不能因为一次实验遗留资源,就执行清空所有卷的快捷命令。

固定镜像仍要记录实际版本

镜像使用完整sha256摘要,避免浮动的8.4标签在重跑时悄悄切换内容。报告同时保存实际SELECT VERSION结果和RepoDigests。摘要确定分发对象,版本字符串帮助理解数据库语义,两者各有用途。仅在说明里写“使用MySQL八点四”,不能证明这次运行确实用了相同软件。

实例开启GTID与一致性约束,使用ROW格式的完整行镜像,并记录sync_binlog与innodb_flush_log_at_trx_commit。实验没有通过断电和损坏文件系统证明这些参数的物理持久性;记录参数只是让提交语义有明确条件。缓冲池缩小,关闭Performance Schema以适应教学CI,不能据此推导生产调优建议。

Python只使用标准库调用Docker命令,不需要额外数据库驱动。mysql与mysqldump来自固定服务器镜像;该精简镜像没有mysqlbinlog,因此程序另从官方固定8.4.11客户端RPM提取工具,校验下载SHA-256并记录实际工具版本。工具镜像沿用相同基础镜像,在无网络容器内只读挂载归档。等待就绪靠实际SELECT 1成功,而非固定睡十秒。启动失败、恢复失败或断言不成立都会返回非零,不会因为环境不满足就把硬实验标成跳过。

口令与日志的边界

实验口令是公开教学常量,账户仅存在于临时网络。它不适用于任何共享环境。程序通过docker exec的环境变量向客户端传入口令,避免把口令直接写成命令行参数;这并不构成完整秘密管理,能控制Docker的用户仍然拥有很高权限。报告不收集生产凭据,也不接触现有容器。

业务写入使用ledger_app,只授予ledger库的SELECT、INSERT、UPDATE和DELETE,没有SUPER、CONNECTION_ADMIN或全局管理权限;备份与角色编排才使用root。复制账户只授予复制需要的权限,源主机在私有网络内解析。GET_SOURCE_PUBLIC_KEY使教学连接能完成认证,并不等于配置了传输加密或证书验证。生产需要结合网络信任边界、TLS和账户来源限制处理,不能照搬通配主机名和公开密码。我们把这些明确列为未实现能力。

真实运行与静态构建分开

作者本机没有Docker服务,无法在那里声称执行了数据库实验。最早的提交只有实验CI,先让真实四实例跑通,再继续出版。完整发布流水线重新执行实验,把报告复制到书籍下载区,之后构建EPUB和网页。ZIP解压到新目录后,又建立全新随机资源执行一次,证明下载包不依赖源码目录中偶然存在的文件。

本地阅读器可以静态预览,移动布局和EPUB结构也能检查,但这类检查不能证明mysqldump参数有效。相反,数据库实验通过也不能证明在线章节完整。两种证据最后汇合到同次发布工件,读者才能确认自己下载到的代码、书稿与运行记录属于同一个交付版本。

开始前的容量准备

建议至少留出约两GB可用内存、三GB磁盘以及下载镜像的网络。四个实例同时存在,备份会包含用于延长导出的填充数据。填充表不参与业务验收,只帮助观测导出期间确实有事务提交。若资源不足,先调整运行环境;把并发实验去掉后依然声称验证一致快照,会改变本书要证明的命题。

第3章:提交、日志与确认的三条边界

客户端收到COMMIT成功,副本是否已经拥有这笔交易?备份包含某个订单,对应的binlog位置是否就在文件结尾?恢复问题经常来自把不同边界当成一个时刻。本章沿着一次下单,从源库内部提交走到客户端确认,再走到副本应用,解释后续实验为什么需要多种证据。

从业务事务开始

一笔订单包含三条修改:订单行、支出分录和账户余额。实验明确使用START TRANSACTION与COMMIT,使三项变化具有同一事务边界。任何一项失败都不能被当作完整业务成功。程序在每次实验后查询不变量,避免某个SQL错误被后续步骤掩盖。

InnoDB的redo主要服务于存储引擎崩溃恢复,binlog保存服务器层可用于复制与恢复的变化事件。它们不是可以随意互换的两份相同日志。操作人员通常用备份加binlog进行目标时间恢复,不会把某个redo文件直接送给mysql客户端。理解用途差异,也能避免看到“日志还在”就宣布可恢复。

源库内部需要协调事务与二进制日志的提交,具体持久性受参数、文件系统与设备保障影响。本书记录sync_binlog和innodb_flush_log_at_trx_commit,但不通过简单读变量宣称机器断电不会丢事务。容器正常停止与物理断电不同,源进程停止也不等于磁盘永久丢失。我们后面证明的是异步副本缺少已确认事务,而非磁盘持久性极限。

客户端成功也是一个观测

实验中的成功确认是mysql客户端返回零退出码。客户端返回前事务已经被服务器接受完成,但这个事实没有要求异步副本同步提交。若网络在提交成功后断开,客户端还可能看不到确认,形成结果未知。本书不把这种情况纳入RPO计数;示范丢失的那笔交易必须先明确得到源库成功回执。

生产系统需要保留稳定业务请求键。恢复后,应用可能重复提交一笔自己没有收到响应的请求;缺少幂等约束就可能重复扣款。数据库恢复解决的是某个数据状态,客户端恢复解决的是请求是否可以安全重试。两个流程必须通过订单号或流水号衔接,不能只靠数据库连接重新建立就认为业务已恢复。

接收与应用分开

异步复制至少涉及源端写出、网络传输、副本接收保存和副本执行。暂停SQL应用线程时,接收线程仍可能持续取得日志。此时“日志到了副本”和“查询能看到事务”有不同答案。把线程状态或接收位置当成业务可读状态,会让切换决策过早。

实验先停止副本SQL线程,提交一笔带标签的订单,确认副本业务状态保持不变,并用GTID集合判断目标事务尚未执行。随后恢复SQL线程,等待指定GTID集合执行完成,再逐行比较业务状态。等待函数成功是同步条件,业务比较是结果条件,二者共同约束结论。

最后的丢失实验则停止整个复制通道,保证新事务根本不会被接收。这样我们能确定地构造一笔源端已确认、副本没有的交易。这个故障注入不是随机网络延迟模拟,也不声称复现所有现实故障;它把异步协议允许的窗口固定下来,便于读者精确观察后果。

文件位置指向事件边界

二进制日志位置是文件内字节偏移,必须与文件名一起使用。不同文件的同一个数字没有共同业务含义。恢复开始位置来自逻辑备份记录的坐标,结束位置取自两个保留事务都提交之后、误删事务开始之前的服务器状态。我们不截断一个事务的中间部分来凑出想要的行数。

ROW格式保存行变化,mysqlbinlog的解释输出帮助人理解事件;真正回放仍使用可执行输出。把带解释注释的展示文本手工改成UPDATE语句,会产生一套未经验证的新恢复程序。我们保持日志原义,选取连续的事务前缀,再用业务状态确认边界正确。

GTID提供事务身份,文件位置提供某个日志流的具体切点。GTID集合包含事务,不意味着这些事务的业务关系仍然符合你的期望;误删也是一个有合法GTID的成功事务。要排除它,仍需知道要恢复到哪个业务目标。技术标识使定位更可靠,却不会替人选择该保留哪笔交易。

一个需要拒绝的推论

假设源成功返回,副本接收线程显示连接正常,就立即把旧主停止并提升副本。这个推论缺少事务实际接收与执行的证据。正常连接只表示通道某次握手成功,不是每个业务提交的同步回执。正确决策需要固定目标水位,检查副本拥有什么,并在无法补齐时明确丢失集合。即使之后发现源盘完好,也不能倒过来宣称当时的切换已经无损;补救需要额外日志、业务核对和独立审计。

本章练习

画出源端提交、客户端收到成功、副本接收、副本执行四个事件的先后关系。哪些事件在异步复制下不能由客户端成功推出?再描述一种客户端未收到成功但源库已经提交的情况。解释为什么恢复后仅查询某个GTID存在,仍不能证明客户订单已得到正确处理。

第4章:让备份类型服务于恢复路径

备份工具很多,选择经常从速度和体积开始。对恢复工程来说,更可靠的选择顺序是先确定失败场景,再确认恢复所需输入,最后衡量备份成本。一个每天都成功的备份任务,如果恢复需要已经停产的软件版本,或者必须访问故障中的原主机,就不能满足独立恢复的要求。

不同分类回答不同问题

逻辑备份保存建表语句和数据表达,恢复时重新执行;物理备份保存数据库文件及配套日志,恢复方式与版本和存储布局关系更紧。完整与增量描述覆盖范围,在线与离线描述取得备份时是否继续服务。这些维度可以组合,不能听到“在线”就推断恢复更快,也不能把“增量”理解成天然拥有时间点恢复能力。

本书选用mysqldump,不是因为它适合所有规模,而是因为SQL可读、恢复路径透明,便于在教学资源内核对业务状态。大库的逻辑恢复可能被建索引、事务日志和约束处理拖慢。这个局限不需要用空泛警告处理,应该通过容量演练比较实际导入时间、资源峰值和恢复预算。

物理备份需要对应工具对一致性、redo处理、版本兼容和加密的支持。仅复制正在变化的数据库目录通常不能得到可用备份。快照也需要知道存储层的一致性边界及数据库恢复条件。本书没有执行物理备份,因此不把逻辑实验的成功推广成物理方案可用。

一份备份是一组关联材料

数据文件之外,至少还要保留元数据、来源身份、取得时间范围、工具版本、选项、复制坐标或GTID信息、加密密钥获取路径和完整性指纹。对象权限、事件、触发器、存储过程是否在范围内,也应明确。随书dump显式包含routines、triggers和events,但教学业务没有创建这些对象,所以不能宣称已经验证复杂存储程序的兼容迁移。

备份摘要检查传输后的字节是否一致,不判断SQL是否完整表达了想要的数据。两个完全相同的损坏文件也会有相同摘要。我们把摘要与真实导入、对象检查和业务核对结合起来。报告中的dump_sha256能够标识哪份数据被测试过,state摘要则说明那份数据导入后的业务含义。

日志保留决定备份之间的空隙

假设凌晨完整备份成功,上午发生误删。若只有完整备份,就只能回到凌晨附近;想保留误删前的合法交易,需要从备份坐标开始的连续binlog。日志的保存位置必须独立于可能故障的源盘,否则源机不可读时,账面上的时间点恢复能力就会消失。

保留策略要覆盖最坏的发现延迟。误删可能当天没有被发现,审计周报才暴露差异。如果binlog已经按短周期过期,即使完整备份还在,也无法恢复到想要的时点。反过来,仅保留大量日志却没有合适基线,可能需要从很早的数据重放,导致恢复时间超出预算。

恢复环境也是备份的依赖

目标机器需要能获取镜像、启动数据库、读取备份密钥并容纳导入产生的临时空间。机房故障时,私有镜像仓库和密钥服务可能同样不可用。应把这些依赖纳入演练,而不是等导入当天才发现工具下载地址已经失效。本书固定镜像和客户端RPM,并记录hash,目的就是让软件来源也可以审查。

我们在干净容器恢复,不复用源实例的数据目录,也不把源端尚未导出的文件偷偷当作恢复输入。这样,备份的独立性才得到最基本的证明。生产中还应考虑账户授权、字符集、时区、插件和密钥版本,本书的小业务没有覆盖这些组合,只提供扩展检查的路径。

从失败清单倒推方案

如果主要风险是误删,应重视可定位的连续日志与隔离恢复;如果是主机损坏,应确保副本或备份不共享故障域;如果是应用逻辑批量写错,实时复制通常会忠实复制错误,需要延迟发现和历史恢复能力;如果是勒索或凭据失陷,备份删除权限和不可变保存同样关键。

这些场景可能需要多层保护,成本也不同。你可以从最重要的两类风险开始,为每类写出恢复输入、目标实例、验收条件和时间预算。随后跑一次实际恢复,得到的结果会比“采用业界通用方案”更能指导预算。工具名称不是恢复承诺,经过验证的输入到结果路径才是。

第5章:在真实写入期间取得一致快照

暂停全部业务后导出数据库容易理解,但许多系统无法每天长时间停写。在线逻辑备份必须回答一个微妙问题:订单表和账户表在不同时间被读出,如何仍然属于同一个业务时刻?本章用持续提交的三表事务检验single-transaction的实际结果,而不只检查命令是否带了这个选项。

构造会暴露不一致的业务

实验初始写入一笔金额一百的订单,账户余额变成九万九千九百。随后写入器连续提交一百二十笔金额为一的订单。每次提交同时改变订单、分录和余额,事务之间短暂停顿。备份如果把余额读在一个时点、分录读在另一个时点,就可能违反余额关系;如果订单和分录读取不一致,也会出现孤立对象。

填充表存放约十几MB文本,使导出有足够时间与写入重叠。它不参与账务摘要。选择填充而不是把并发写入改成巨量业务,是为了保持输出可读,也避免读者把测试规模误认为容量基准。关键不是导出花了几秒,而是在实际导出时间范围内确实有其他事务提交。

程序等待写入器至少新增一笔,再记录源订单数量,开始dump,完成后再查一次源数量。必须满足导出后的数量大于导出前的数量。写入器还需正常完成,最终源数量必须大于导出后数量。恢复快照的订单数应落在导出前后观测之间,同时所有账务关系成立。这个检查同时证明并发发生和恢复状态一致。

single-transaction的适用范围

mysqldump以一致快照读取事务表,使多个表的数据读取可以对应同一视图。我们显式使用InnoDB,不做并发DDL。这个约束很重要:MyISAM等非事务表不因选项出现就获得相同保证,备份期间的ALTER、DROP或RENAME也可能破坏原来的假设。官方文档R02专门说明了相关限制。

source-data记录与备份关联的复制坐标。single-transaction和source-data组合可能在起始阶段使用短暂锁来协调坐标,不意味着整个导出期间完全没有锁影响。因此“在线备份”不应被写成“绝对不影响写入”。实验观测到持续提交,只能说明此数据集与环境里写入确实继续,不能证明生产峰值延迟没有变化。

GTID选项要服务于目标

本书设置set-gtid-purged=OFF,避免把整个源服务器执行历史声明给独立教学恢复实例。恢复目标是验证业务数据与回放效果,不是从该dump直接构建原拓扑副本。若要用备份初始化生产GTID副本,必须重新设计GTID集合的传递、空实例条件和重复事务处理,不能只复制这一组选项。

逻辑备份可能只导出一个数据库,而源GTID历史覆盖服务器上更多数据库。把全局集合和局部数据混合使用,可能让恢复端以为一些实际上未导入的事务已经执行。这类错误很隐蔽:复制自动定位能正常启动,却永远不会再补齐被错误声明的事务。数据范围与事务历史范围必须一起审查。

真正导入后再判断一致性

dump文本里的INSERT数量不能代替恢复测试。程序把字节输入独立restore实例,等待mysql客户端成功,再通过state函数查询余额、分录和有序订单。快照比源最终状态少一些订单是预期结果,因为源在备份后继续提交。不能因为两者摘要不同就说备份坏了,也不能因内部一致就说所有最新事务都已保护。

恢复报告记录导出持续时间、前后源计数、恢复计数和dump摘要。读者可以看到快照位于观测窗口内,而不是把一个事后生成的静态快照说成并发快照。这个做法也适用于测试其他备份工具:先设计能暴露跨表错位的不变量,再在真实变化期间取备份,最后到另一环境验证。

备份窗口之外的维护

长事务会保留旧版本,备份本身也可能延长undo保留和增加I/O。表越大、写入越重,这些效应越值得观测。应在容量演练中记录业务延迟、历史版本与磁盘增长,并确定备份失败的终止策略。本书没有把小规模观察外推成无成本方案,而是把正确性验证与性能评估保留为两项独立任务。

第6章:把备份放进一台从未见过源数据的实例

同一个数据库里先导出再查询,几乎不能证明备份可用。最有说服力的起点,是一台拥有新数据目录、新实例身份和空业务库的目标实例。它能接触到的恢复输入只有备份字节和明确提供的工具。这样,源端现存数据无法悄悄补上备份遗漏,恢复依赖也会真实暴露。

两次恢复分别证明什么

restore导入并发期间的第一份快照,验证跨表一致性。pitr导入第二份基线,验证后续日志恢复的起点。第二份备份取得时写入器已经结束,源业务状态不再变化,因此可以直接与导入结果逐行比较。把两个目标拆开,能避免并发源持续变化导致比较参照不清楚。

基线之前执行FLUSH BINARY LOGS,让接下来要分析的变化集中在一份新的日志文件中。这个操作简化教学中的连续性问题,不消除生产跨文件恢复的要求。dump内部的坐标仍然是唯一恢复起点,不能猜测新文件总从某个固定位置开始,因为文件头和GTID事件也占据字节。

干净不是“删几张表”

一个长期使用的实例可能残留同名数据库、用户、全局变量、GTID历史和插件。仅删掉三张业务表,并不能让环境回到空白。有些残留不会马上报错,却会在回放或建立复制时产生难以解释的行为。本书直接创建新容器和匿名数据卷,使实例初始化过程可追踪。

即使如此,容器里仍有MySQL系统数据库和初始化创建的账户。我们所说干净,是没有之前运行的业务数据与事务历史依赖,而不是完全没有任何系统状态。恢复账本不包括迁移生产账户体系,不能因root能够查询就宣布应用权限正确。真实应用恢复还应使用实际服务账户验证最小权限。

先确认过程,再确认结果

导入客户端非零退出必须立即中止。忽略中途错误继续执行,可能让前面的表存在、后面的表缺失,最后由一条成功SELECT掩盖整体失败。我们的feed以check=True等待整个输入结束,任何错误都会抛出异常,实验不会继续把部分数据当成恢复基线。

过程成功之后,state函数读取所有订单,检查分录数量等于订单数量,检查不存在孤立分录或金额不匹配,再检查余额与分录和。完整订单按主键排序,统一用制表符与换行生成规范表示,最后加入余额和分录和计算SHA-256。小数据集保留原始行,便于读者独立核查摘要的语义。

这个规范只适用于本书字段类型。生产存在NULL、二进制值、字符集和浮点数时,必须设计无歧义编码。直接拼接字符串可能把不同记录合成同一个表示。大表还需处理排序代价和并发变化,通常应在冻结或同一一致视图下核对。摘要算法再强,也不能补偿含糊的数据编码。

恢复成功之后先别开放写入

刚恢复出的数据可能只是历史快照,还要继续应用binlog。如果应用提前连接并产生新事务,目标数据就混入了恢复流程之外的变化。后续重复主键、金额差异和GTID集合问题会更难解释。本书pitr实例只接受程序安排的恢复输入,没有任何业务客户端连接。

生产可以用网络隔离和权限收口保持恢复环境封闭,完成验收后再切换入口。read_only是一层限制,但拥有管理权限的用户仍可能改变它,不能把一个变量视为所有访问边界。流程应同时说明谁能连接、谁能改设置、何时允许应用写入,以及出现差异时如何撤回。

有些缺失不会被本书样本发现

本书只有简单表结构,没有外键、分区、触发器和存储程序。虽然命令带有相关导出选项,我们没有用不存在的对象来宣称功能已测试。扩展演练时,应给每类关键对象放入能验证的行为,例如恢复后触发器是否生成审计行、应用账户是否可执行必要过程、事件调度是否按预期保持关闭或启用。

同样,备份是否加密、密钥是否可取得、压缩是否可解开,都要通过实际恢复路径证明。把配置文件里某个开关截图放进报告,只说明意图。更可靠的报告能够从备份标识一路追到恢复实例和业务验收结果,形成可重复的证据链。

一次失败的价值

如果导入需要访问源库,而源库正是故障对象,应把它视为独立恢复能力的缺口。若导入报出版本或字符集不兼容,应保留原始错误,不要手工修正到能启动后省略这些步骤。真实恢复操作可以包含修正,但它们必须版本化、可重做,并重新计入恢复时间预算。下一章会把这条原则继续应用到日志归档。

第7章:把二进制日志变成源库失联后仍可使用的归档

在源容器里运行mysqlbinlog,看到熟悉的订单变更,很容易产生已经完成时间点恢复的感觉。但如果源容器停止、数据卷损坏或者服务器无法登录,这条命令还成立吗?真正的归档恢复必须让日志离开原写入位置,并且能够在源服务不可用时由独立工具读取。

从活动文件到封口文件

活动binlog持续追加。直接复制时需要理解文件尾部是否完整、读到哪里、校验和怎样验证。教学实验选择先执行FLUSH BINARY LOGS,让目标文件关闭,再复制已经封口的文件。这样归档内容不再被后续写入改变,文件长度与摘要具有稳定意义。

程序从备份坐标提取文件名,保存恢复所需的开始位置;完成两笔合法事务和误删后,轮转日志,再用docker cp复制这份真实文件到任务自己的临时归档目录。报告记录归档文件名、字节数、SHA-256、封口方式和工具挂载条件。原始文件不需要伪装成手写SQL,MySQL的真实事件结构得到保留。

复制完成后源容器执行docker stop停止(十秒宽限后可能强杀,实际退出码写入报告)。回放工具是另一个无网络容器,只读挂载归档目录。它既没有源库网络,也没有源库数据卷,只能读取归档中的文件。这个安排直接证明恢复不依赖源服务仍在线。它没有模拟源盘永久损坏,但已经切断了回放时向源服务借数据的路径。

工具也需要可重建

固定服务器镜像是精简版本,真实CI发现其中没有mysqlbinlog。我们没有把退出码一百二十七忽略掉,而是补充官方8.4.11客户端RPM,下载后核对固定SHA-256,从中提取实际工具。工具镜像沿用同一固定基础镜像,记录mysqlbinlog版本,架构明确为linux/amd64。

提取阶段使用cpio读取RPM负载。cpio本身不是解释MySQL日志的程序;参与恢复语义的是已校验的mysqlbinlog可执行文件。工具镜像仍可能依赖基础系统动态库,所以沿用固定基础镜像,并实际运行版本和回放,避免只相信二进制已复制成功。ARM原生与其他发行版并未验证,读者应在对应环境重新执行。

完整性与连续性是两种检查

摘要验证某一个文件是否改变,连续性验证恢复范围内是否缺少文件或事件区间。假设三份归档各自摘要正确,但中间那份根本没有保存,仍然无法完整恢复。本书通过轮转把目标区间放在一个文件中,因此单文件覆盖能够清楚检查。生产应保存源实例身份、文件序列、起止位置和前后基线关联,不能只靠文件名字典排序。

归档还需要保存不可变性与读取权限。能删除生产数据的凭据不应天然也能删除所有历史副本;保存期限要覆盖故障发现延迟。加密会引入密钥可用性,压缩会引入解压工具和空间。每一种转换都要进入实际演练。本书原始二进制归档没有压缩加密,所以不声称证明了这些环节。

用真正缺失的文件做负例

程序临时把所需归档文件移走,然后仍用原来的目标路径请求mysqlbinlog。工具必须失败,pitr实例必须保持刚导入的基线状态,并且这份基线摘要确实不同于目标状态。这样才能证明“缺日志导致目标不可达”,而不是随便读取一个不存在且无关的文件,再把报错当作演练结果。

之后将文件放回原名,执行完全相同的恢复范围,才进入成功路径。负例与正例共享基线、开始位置、结束位置和必要文件,差异只有归档是否存在。这个结构让读者看清日志在恢复中的因果作用,也防止程序悄悄用源端数据补齐缺失输入。

归档完成不是恢复承诺终点

文件已复制并且hash匹配,证明它们是稳定输入;能被工具解析,证明格式基本可读;能回放到干净实例并得到正确业务状态,才证明目标可重建。每层证据都需要,任何单层都不能代替整体。下一章把范围进一步收紧到事务边界,避免一份完整归档被错误选择的结束位置破坏。

生产日志归档还应监控最后成功归档时间、未归档尾部大小、磁盘容量和保留删除。告警应描述缺口可能影响哪段业务确认,而不只说上传任务失败。只有把日志进度与恢复目标关联,值班人员才知道需要多久内处理,是否应暂停高风险批量操作。

第8章:在完整事务边界停止回放

误删订单后,最直接的想法是恢复到删除之前一秒。但日志时间戳的精度、服务器时区、长事务和并发提交会让这个说法含糊。精确恢复更需要一个能明确排除错误事务的边界。本章在单一binlog文件中,用真实提交之后的字节位置保存两笔合法交易,排除误删及其后变化。

先定义目标集合

基线包含初始订单和并发备份阶段结束后的全部合法订单。之后源库提交订单二千零一和二千零二,金额分别十七和二十三。这两笔都需要保留。程序立即保存完整业务状态和SHOW BINARY LOG STATUS返回的位置,然后才执行删除事务。目标因此明确为基线加两笔合法交易,而不是“看起来接近事故前”。

错误事务删除全部订单和分录,将账户余额设回十万。它在数据库内部依然是一个完整、一致、成功提交的事务。随后再新增一笔误删后订单。恢复目标排除这两个阶段,既不能把错误删除带回来,也不能把后来的业务当成已自动修复。生产中是否保留事故后合法订单,需要单独业务评估,本书不自动合并。

开始位置和结束位置都有来源

开始位置来自mysqldump注释中的SOURCE_LOG_FILE和SOURCE_LOG_POS,表示基线之后应继续读取的日志位置。结束位置来自两笔合法事务提交成功后的服务器状态。文件名必须相同,否则程序中止,因为当前实验只实现单文件范围。我们没有用硬编码数字假装所有MySQL日志布局都一样。

mysqlbinlog读取start-position到stop-position指定的事件范围,输出可执行内容,再由mysql输入目标实例。结束点是最后一笔保留事务之后的边界,错误事务尚未开始,所以不会截到半个业务事务。日志里可能包含格式、GTID和会话设置事件,恢复代码交给官方工具解释,而不是自行解析字节拼成SQL。

为什么不手工过滤几张表

订单、分录和余额共同构成一项业务。如果只回放orders而忽略accounts,恢复结果可能显示订单完整,却无法结算。按数据库或表过滤还涉及跨库事务、默认数据库和ROW事件的解释条件。过滤规则需要专门设计,不能因为操作方便就默认安全。本书选择一段连续事务前缀,保留原始事务整体。

同理,挑几条DELETE的反向INSERT并不等于一般恢复。原值可能缺失,后续外键和唯一约束可能已变化,外部支付或消息副作用也不会随SQL倒放而撤销。业务修复可以有针对性的补偿流程,但那是另一种工具,应保留审计和独立验收,不能与日志前缀恢复混为一谈。

目标实例的GTID历史

基线导入没有写入源全局GTID集合,目标实例会有自身导入产生的事务身份;回放保留源日志的GTID事务。这个组合服务于独立历史恢复,而不是直接声明目标可以回到原生产拓扑。重新加入复制需要检查GTID集合、数据范围和额外本地事务,本书没有自动完成这个动作。

因此验收以业务完整状态为中心,源目标GTID被记录为定位信息。不能只查询GTID_SUBSET为真就宣布订单正确,因为误操作、跳过事务或错误初始化都可能让事务历史与业务数据含义分离。副本自动定位会在后面的章节单独讨论。

三组断言一起成立

首先,回放后的订单、余额与分录摘要必须等于之前保存的目标。其次,误删后订单三千零一必须不存在,证明没有把结束位置选得太晚。最后,源在回放期间确实停止,输入来自独立归档,证明恢复路径没有向在线源借状态。缺少其中任何一组,结论都会比实际证据更强。

日志摘要也写入报告,但它标识的是回放输入,不是成功条件。不同运行的实例UUID和日志时间会改变字节,合法的回放摘要可能不同;业务目标则可按相同订单构造稳定比较。读者应区分工件身份与业务等价,避免把每次实验文件hash不同误判成数据不一致。

扩展到生产跨文件恢复

真实恢复可能跨越很多日志文件。开始文件从基线坐标起读,中间文件完整回放,最后文件在目标事务边界停止。需要核验顺序、来源身份、校验和、归档缺口与事务完整性,并保持目标隔离。先在副本环境验证范围,再开放流量。本书把这条路径缩成单文件,是为了清楚暴露原则,不是声称跨文件恢复无需额外工作。

第9章:误删演练必须保留错误已经扩散的事实

误删是恢复演练中最容易被低估的场景。数据库没有崩溃,监控可能全绿,事务甚至非常符合约束。若副本忠实跟随源库,错误也会迅速到达所有在线副本。此时“有一主一从”不能代替历史备份。本章沿着实际删除的传播过程,解释为什么恢复环境和当前服务环境必须分开处理。

错误事务同样会成功

实验删除所有订单和分录,再把余额设回初始值。这三项操作放在同一个事务里,最终账务关系仍然成立。它模拟的是操作意图错误,而非数据库部分提交。MySQL不能从SQL的合法性推断人是否选错了客户或漏写WHERE条件;复制协议也不会替业务识别这是一场事故。

删除之后再写一笔新订单,使当前源状态既不是空库,也不是事故前状态。若只检查表存在且有数据,就会误判恢复成功。我们的PITR目标拥有事故前的一百多笔订单,而当前源和复制追平后的副本只有事故后一笔。这两个状态都内部平衡,区别来自应该保留的业务集合。

历史恢复先进入隔离环境

事故发生时直接把生产库回退到旧状态,可能覆盖事故后合法订单,并触发应用重复执行支付、发货或通知。更稳妥的思路是先在隔离目标恢复历史状态,确认事故影响范围,再决定整库切换、按对象提取还是业务补偿。本书pitr就是这个隔离目标,不自动替代当前source。

恢复前保存事故现场也很重要。源上的误删后数据、应用日志和外部回执可能帮助区分需要保留的变化。立即清空源卷或重做复制,会删除修复线索。实验直到全部核对完成才销毁任务资源;生产应有更严格的只读取证和保留策略,避免操作人员为了尽快看到绿色状态而毁掉证据。

选边界需要业务上下文

本书事先知道错误事务发生的位置,便于精确展示。真实事故中,需要结合审计记录、业务ID、binlog事件和提交时间定位。时间戳适合缩小搜索范围,最后仍要落到事务边界。长事务可能在开始很久之后才提交,看到操作时间并不等于找到可回放终点。

如果误删与合法写入交织,简单回到误删前会排除一些后续合法交易。要保留它们,就进入选择性修复问题。应明确每笔修复的前置条件、幂等键和外部副作用,不能通过随意跳过DELETE事件得到一个看似完整的数据集。跨表依赖、唯一键占用和业务状态推进都可能使手工补入不再等价于原事务顺序。

复制追平是错误传播的证明

PITR完成后,程序重新启动源,等待副本执行源GTID集合,再逐行比较源与副本。副本只有事故后一笔订单,历史恢复实例则保留误删前目标。这个对照直接证明复制与备份服务于不同风险:复制提供较新的在线副本,历史恢复提供选择过去状态的能力。

这里没有说副本毫无价值。它可以降低单机故障后的恢复时间,也可分担读取。但当风险是合法提交的错误数据时,它通常会同步错误。延迟副本可以增加发现窗口,却引入主动落后与恢复操作复杂度,需要独立演练。本书的暂停SQL线程是机制实验,不等于实现了生产延迟备份策略。

从整库结果走向对象修复

对某个客户误删,可能只需要从隔离恢复库提取该客户的完整业务对象。提取范围不应局限于一张主表,还需包含分录、状态历史、关联索引和外部凭证。导回当前库前先分析同一ID是否已有后续记录,明确冲突如何处理,执行后再次核对业务关系。

对整库回退,则需要更严格的写入冻结和入口切换,因为数据库回到过去,应用缓存、搜索索引、消息消费位置和外部系统未必同步回退。恢复数据库只是系统恢复的一部分。应让各下游知道新的数据版本或修复范围,避免旧缓存把已恢复的数据再次覆盖。

记录被排除的内容

报告不能只有“保留了二千零一订单”。还应明确删除事务没有执行,误删后订单三千零一没有进入目标。排除集是恢复目标的一半。若只检查保留集,结束位置太晚时可能保留订单又把它删除,或者额外带入不该出现的变更。完整状态摘要与显式排除查询一起缩小这种漏洞。

演练结束时写清事故发现时间、最早受影响事务、选择边界依据、恢复输入、验证差异和剩余修复工作。客户补偿与财务确认可能晚于技术恢复,这不是可以省略的尾巴。恢复报告应让业务知道当前系统承认哪些事实,以及哪些事实仍需人工处理。

第10章:把GTID当作事务身份,而不是数据正确性的证书

文件位置需要同时记住源文件名和偏移,切换源时也容易混淆。GTID为事务提供跨复制流可识别的身份,让副本能声明自己已经执行哪些事务,并由源发送缺少的部分。但一个更好用的定位机制,仍然依赖正确的初始数据和诚实的执行历史。本章明确它能帮助什么,以及不能替你证明什么。

实例身份与事务序列

常见GTID由源UUID与事务编号组成,集合可以表示连续范围与多个来源。server-id用于复制拓扑中的服务器标识,server_uuid用于实例身份相关的事务标记,它们不能随便复制成所有实例同一个值。直接克隆数据目录时尤其需要理解实例身份如何生成与保留。

实验让四个容器从新数据卷初始化,分别使用不同server-id,开启gtid-mode与enforce-gtid-consistency。复制使用SOURCE_AUTO_POSITION=1,由协议按执行集合决定缺失事务。正式报告保存目标时点的源GTID集合,便于把业务状态与事务历史关联起来。

集合包含只说明执行声明

GTID_SUBSET判断某集合是否包含于另一个集合。若结果为真,说明所查询的执行历史包含目标事务身份,不意味着每一张表都和源相同。错误导入、过滤复制、跳过事务、人工本地修改,都会让数据内容与历史之间出现偏差。恢复验证因此不能从“集合相等”直接跳到“账务正确”。

本书wait_replica先等待源当前GTID集合执行完成,然后调用state逐行核对订单、余额与分录。等待保证比较前副本有机会赶上这个静止目标;数据核对证明实际结果。若源还在持续写入,比较必须固定一致视图或选择明确水位,否则两次查询之间的新事务也会制造表面差异。

自动定位需要正确基线

初始数据可以通过一致备份取得,但要让数据范围与GTID声明匹配。比如只导出一个库,却把全服务器GTID集合设为已执行,可能声称自己拥有根本没导入的数据。相反,导入了源数据却没有相应历史,复制可能重复发送同样事务,导致主键冲突或更危险的语义差异。

本书副本从空业务状态开始,在源创建业务表之前就建立复制,所以没有备份初始化的歧义。独立pitr恢复使用set-gtid-purged=OFF,服务于历史恢复验证,不把它直接接回生产拓扑。这两个路径有意分开:我们分别证明复制机制与数据恢复,而不在教学中偷偷合并成一套未经测试的重建流程。

等待函数有时间预算

WAIT_FOR_EXECUTED_GTID_SET可以等待指定集合执行,超时不应被默默忽略。程序限定等待时间,失败时输出复制状态帮助定位。成功返回后仍做业务比较。等待太短可能只是重连退避尚未结束,等待无限长则会让CI卡死且无法区分故障。本书曾实际遇到源重启后的默认重连退避,因此显式缩短并重启接收线程。

这次兼容问题说明,正确的GTID目标并不能消除操作时序。源已经可以SELECT 1,不代表副本接收线程此刻立刻重连。恢复手册应区分服务器就绪、通道连接、日志接收、事务应用和业务验收,不把一个就绪探针当成整条链路完成。

分叉之后不能靠集合并集解决

旧主与新主同时写入,会生成不同来源事务。如果两边修改了同一订单,各自都拥有合法GTID,但合并顺序会影响业务结果。自动定位只知道缺哪些身份,不知道哪笔退款应取消、哪个订单状态更新具有业务优先级。此时需要保存分叉历史并人工判定,不能看到集合互不包含就尝试强行互相复制。

还要警惕“清空GTID历史后重来”。这样可能让原有数据看似没有执行过对应事务,导致重复应用;也可能破坏后续追溯。重置历史是有明确前提的管理操作,不是通用修复按钮。本书不对返回旧主执行这种操作,而是将它隔离,保留差异用于分析。

学会同时读两本账

第一本账是数据库事务历史,回答这个实例声称执行了哪些事务;第二本账是业务记录,回答哪些订单、分录和余额实际存在。恢复工程需要把两者联系起来。只读第一本可能漏掉内容错误,只读第二本可能不知道缺失范围与可继续复制的条件。把源身份、水位和业务摘要一同写入报告,才能为下一次切换留下可信起点。

第11章:建立一条能够被业务查询证明的异步复制

复制教程常以SHOW REPLICA STATUS中两个Yes结束。那说明线程处于某种运行状态,却没有证明业务数据已经到达,也没有证明初始化方式正确。本书从空实例建立GTID复制,在表结构和交易写入之后再核对完整账本,让通道状态与业务结果形成同一个可检查的过程。

账户与网络先就绪

源创建专用复制账户,授予REPLICATION SLAVE权限。账户只用于读取复制流,不承担应用写入。源和副本位于任务私有bridge网络,通过source别名连接,没有对外发布端口。公开教学口令与GET_SOURCE_PUBLIC_KEY只适合这个隔离环境,生产应使用受控凭据和经过验证的TLS配置。

副本配置源主机、复制账户、自动定位和重连间隔,启动通道,再开启super_read_only。只读限制使应用式写入不能偷偷修改副本;复制线程具有特殊执行路径,仍能应用源变化。实际机制应以当前版本文档为准,不要把普通客户端能否写入与复制是否能应用混成同一个权限问题。

为什么在建业务表之前复制

源和副本此时都没有ledger业务库,所以复制可以从源的事务历史开始建立,不需要先处理大规模数据装载。随后建库建表、写入初始订单与填充表,副本都通过同一复制通道取得。这个选择降低了教学复杂度,却不等于现有生产大库可以忽略初始备份。

若要给已有源增加副本,需要一致初始数据及与之匹配的坐标或GTID集合。先随便导出,再猜一个当前水位,会在导出期间形成缺失或重复范围。第五章的一致快照为这种需求提供概念基础,但本书没有将那条恢复路径直接用于生产副本初始化,因此不能把实验结果推广成完整迁移方案。

建立成功包含三步

第一步是通道启动且没有立即出错。第二步是等待源指定GTID集合在副本执行,确保验证查询的时间点足够晚。第三步是源与副本state完全相同,包括全部订单行、余额与分录校验。只有第三步直接回答了本书关心的业务结果。

如果等待超时,应查看连接、认证、源日志保留和应用错误,而不是反复执行START REPLICA掩盖原因。如果业务摘要不一致,即使线程仍在运行也应中止后续切换实验。将失败留在首次出现的位置,能保存更准确的诊断信息,避免错误被之后的新订单冲淡。

复制状态需要解释条件

接收线程可能已连接,却没有新事件;应用线程可能在运行,却被一个大事务拖住。Seconds_Behind_Source的数值也依赖连接、事件时间和应用进度等条件,不能独自解释为所有业务请求的新鲜度。网络中断时某些观测不代表一个可靠的实时延迟上界。

本书不依靠单个延迟秒数做断言,而是安排一个具体订单,观察它何时在副本出现,以及对应GTID是否执行。生产可以用业务心跳、水位与应用查询共同监控,但心跳也只是某条路径的代表。跨库或并行复制时,还应确认所选择的水位确实覆盖需要一致读取的事务范围。

只读是正常状态,不是故障

副本开启super_read_only后,普通业务写入应被拒绝。这个状态防止意外漂移,也为之后的提升提供明确前置条件。读服务仍可能正常工作,不能把连接成功理解成可承接写服务。客户端路由应知道实例角色,避免写请求随机落到副本后由应用盲目重试到其他地址。

拥有管理权限的账户可以改变角色,因此权限管理和控制面同样重要。本书用root执行管理编排,订单写入与只读负例使用仅有账本增删改查权限的ledger_app,并记录SHOW GRANTS,避免管理员绕过read_only掩盖角色错误。生产需要把复制管理、备份读取、应用写入和审计权限分离,并演练凭据轮换后复制是否仍可恢复连接。

复制保护与备份保护叠加

有了可用副本,我们仍保留独立快照和日志归档。因为副本会同步合法错误,归档可能帮助历史恢复;副本提供较新的在线状态,又可能缩短主机故障后的服务恢复时间。两条路径相互补充,但各自都有验收条件。下一章将主动暂停应用,观察“源已提交”和“副本已可读”之间的空隙如何出现并消失。

第12章:暂停应用,再证明副本真正追平

延迟不是一个抽象秒数,而是一组已经发生却尚未在目标可见的业务变化。要理解异步复制,最好先主动制造可控延迟,再观察恢复过程。本章只暂停SQL应用线程,让接收可以继续,借此区分网络传输与事务执行,也说明为什么恢复服务前应等待明确业务水位。

冻结一个已知前缀

程序先让副本追平源,并保存其完整业务状态。之后执行STOP REPLICA SQL_THREAD,源端新增订单四千零一。该事务成功返回后,副本state必须仍等于之前的前缀,查询不到新订单。源端GTID集合也必须不是副本已执行集合的子集。这个差异来自显式停止应用,而不是随机等待某次网络碰巧变慢。

接收线程没有被停止,所以新日志可能已经写入relay log。我们不依赖它一定在某个毫秒内接收完成,也不把这个实验称作网络延迟测量。它证明的命题更窄而清楚:源已成功提交,并不保证副本业务查询立即可见;接收与执行是不同阶段。

如果这里仅查看Seconds_Behind_Source,可能得到难以稳定比较的数值。我们直接用业务ID与GTID包含关系表示差异,保证结果不依赖机器时钟刚好相差几秒。生产也可使用类似水位,但需要关注水位生成频率与覆盖范围,避免一个低频心跳掩盖关键表积压。

恢复应用不等于立即追平

START REPLICA SQL_THREAD只发起执行恢复,不是等待所有事务完成的同步屏障。程序再次读取源当前GTID集合,并等待该集合在副本执行,之后才比较状态。若省略等待,在启动后立刻查询可能读到旧状态,从而把正常追赶误判成复制失败;若只等待固定几秒,则会对机器速度形成隐含依赖。

等待有明确超时,失败输出复制状态。超时可能意味着应用错误、源日志不可用、重连退避或资源不足。不同原因需要不同处理,不能通过跳过事务让绿色状态回来。GTID目标明确后,诊断可以围绕哪个阶段没有跨过这个水位展开,比一句“从库慢”更具体。

追平后再核对全部业务

等待成功之后,订单、余额和分录必须与静止源完全相同,特别包括订单四千零一。集合水位与内容比较一起证明追平,而不是只看新增订单存在。单点存在检查可能漏掉旧订单被意外改动,完整摘要则约束整个教学数据集。

生产数据很大时,可以选择关键业务抽样、分块摘要与持续一致性检查组合。但抽样只支持概率或局部结论,报告要说明范围。将几个样本相同写成“全库完全一致”,会把验证能力夸大。这里的数据足够小,因此我们能够直接给出全部有序订单行。

暂停期间的读取语义

副本读服务可能继续返回旧数据。用户刚完成订单就跳转到副本查询,可能误以为下单失败并再次提交。应用可采用读己之写策略、指定水位等待或临时回源等方式处理,但每种方式都涉及超时与失败选择。本书不实现路由层,只把底层可见性差异真实展示出来。

如果读请求要求跨多个表一致,单看某张表更新到某个ID也未必足够。需要事务级视图与合适水位,并理解并行复制的执行顺序。恢复服务不是“连上就可以读”,而是选择能满足业务语义的数据版本。这也解释了为什么切换验收需要业务条件,而不只是TCP探针。

延迟积压影响恢复时间

当主库故障时,副本可能还有大量已经接收但尚未应用的日志。如果这些日志完整且可用,等待应用可以减少丢失,却增加恢复时间。直接提升并开放写入可能让情况更复杂,因为新的本地事务和旧relay事务会相遇。正确做法依赖具体拓扑与切换计划,不能机械选择“最快打开只读开关”。

本书后面的丢失实验会停止整个通道,使缺失事务明确没有到达副本,因此不涉及尾部relay抢救。这个选择让RPO结果可确定,也避免把“尚未执行”和“根本没收到”混为一谈。读者应保留这两个故障模型,分别设计恢复与验收。

本章练习

把暂停对象改成IO接收线程,保留SQL线程运行,思考已经接收的relay log会怎样变化。重新运行时应给出源GTID、副本执行集合和业务订单的三项证据。若实验结果没有按预期出现,先检查暂停发生时已有多少日志,再决定是否需要新的事务标记,不能用任意睡眠替代因果判断。

第13章:亲眼看到一笔已确认事务在切换后消失

讨论异步复制风险时,人们常说“理论上可能丢一点”。这个表述太轻,容易被当成可以忽略的边角。本章让源明确确认一笔订单,再保证副本没有接收它,最后切换到副本并查询。这笔订单在旧主存在、在新主不存在,风险从模糊可能变成可定位的业务差异。

把窗口固定下来

程序让副本追平后,执行STOP REPLICA停止接收与应用,保存副本完整前缀。源随后提交订单五千零一,金额十三,标签为已确认但未复制。mysql客户端必须返回成功,源完整业务状态比副本多一笔订单,并包含对应分录与余额扣减。此时副本查询仍然没有这笔订单。

停止通道是一种故障注入,不是对公网延迟分布的模拟。它不测量典型丢失概率,也不说明任何负载下都会丢同样数量。它只证明协议允许的关键事实:异步源可以在副本没有事务时向客户端确认。只要后续承接写入的节点缺少该事务,已确认业务就会从服务视角消失。

源容器最终执行docker stop,十秒宽限后可能强杀,不删除数据卷。即使旧主磁盘完好,新主也不会自动知道这笔订单。现实中如果还能抢救旧主日志,可能减少损失,但那需要额外流程。为了展示明确边界,本书不在提升前从旧主补日志,报告如实保留这笔缺失。

源端持久与副本拥有是两回事

将sync_binlog和InnoDB刷盘参数设得严格,有助于源端崩溃持久性,但不会把异步复制变成同步确认。订单五千零一即使已经稳稳写在旧主磁盘,只要新主没有收到,切换后的查询仍然缺失。保护源盘和保护跨节点确认范围,需要分别设计。

半同步、组复制或其他协议可能缩小某些窗口,但它们各有确认条件、退化行为和故障模型。不能从名称推导无条件零丢失。本书没有执行这些协议,因此不借它们装饰结果。要采用更强承诺,就应设计与承诺匹配的真实故障实验,包括退化、超时和网络分区。

丢失计数来自业务差集

我们保存故障前源状态和副本前缀,提升后显式查询订单五千零一不存在,同时核对新主其他数据仍然等于前缀再加新主专属订单。RPO报告因此写丢失一笔已确认事务,并列出具体ID。它没有通过源副本时间差估算订单数量,也没有把所有未执行GTID简单等价成客户交易数。

一项业务操作可能跨多个数据库事务,某个GTID也可能包含多笔订单,真实RPO需要选择业务单位。支付系统可能更关心确认金额和客户数量,配置系统可能关心最近发布版本。报告应同时保存技术水位与业务差集,避免一个看似很小的事务数量掩盖高价值损失。

时间窗口如何解读

程序记录停止复制后取得前缀到源确认缺失事务之间的单调时钟间隔。这个数值是本次客户端观测窗口,不是生产承诺,也不是最后一笔幸存事务的精确年龄。我们明确保存边界,而不把几百毫秒写成“系统RPO小于一秒”。

若要测真实业务RPO时间,需要独立确认日志及各事务确认时间,明确哪些记录在新主存在。时钟同步、网络回执和日志持久性都会影响精度。把这些外部证据放在同一故障域也可能同时丢失,设计时应考虑独立保存。这里的小型演练只给出可重复的事务级结果。

不把数据损失当成测试失败

本章场景PASS表示预期风险被正确观测,不表示系统满足零丢失目标。如果测试要求切换后订单五千零一存在,这套异步方案就应该失败。理解测试名称与业务承诺的区别很重要:我们验证了机制的边界,而不是通过修改断言掩盖缺陷。

恢复决策还需要业务接受。若丢失范围超过允许预算,应停止自动开放写入,尝试补齐日志或选择更完整节点。若选择继续服务,则必须保留缺失集合,安排对账、重试和客户处理。数据库切换完成不能把已确认交易的责任一起切走。

本章练习

假设五千零一订单对应外部支付已扣款,新主查不到订单。说明为什么直接让客户重新支付不安全,以及至少需要哪两个稳定标识支持修复。再为业务方写一句准确状态说明,要求包含已知缺失范围、当前可服务状态和后续核对动作,不能使用“无损恢复”四个字。

第14章:提升之前,先证明旧主不能继续写

故障切换最危险的时刻,是有人认为旧主已经死了,旧主却仍在另一条网络路径上服务。新主一旦开始接受写入,两份账本就可能同时前进。复制落后只造成一个历史前缀缺失,双写则会形成两个不同未来。因此写入边界必须先于提升建立,并且需要实际证据。

连接失败不能证明隔离

管理节点连不上源,可能只是管理网络故障;部分应用仍可通过旧连接写入。DNS切换也不会立即关闭已有连接。代理不再转发新请求,仍可能有绕过代理的客户端。任何一项观测都应与整体访问路径对应,不能把某次ping失败当作全局写入权已经收回。

本书选择更容易验证的教学边界:旧主容器必须处于停止状态。promote函数先通过docker inspect读取实际Running,只有明确false才允许继续。检测到仍在运行就抛出FENCE_REQUIRED,副本保持super_read_only。这个机制依赖同一Docker控制面能够真实停止旧主,不是跨机房自动仲裁方案。

负例必须实际发生

在旧主仍运行且拥有未复制订单时,程序故意调用提升函数。它必须拒绝,然后以不具有SUPER或CONNECTION_ADMIN的ledger_app向副本发起一次真实业务事务。客户端必须非零退出,错误包含MySQL的ERROR 1290及read-only说明,副本完整业务状态不得改变。这样负例同时证明控制流程拒绝,以及数据库仍不接受新的业务写入。

如果只检查super_read_only变量为一,可能漏掉错误客户端没有真正发出写入,或权限路径与预想不同。如果只看到stdout为空,也可能只是命令运行失败于别的原因。退出码、错误语义与状态核对合在一起,才支持“只读拒绝”的结论。这是恢复实验中很常见的证据差别。

正确路径的顺序

开始计时后,先停止旧主,等待Docker命令完成,再由提升函数重新检查停止状态。之后停止并清除副本复制连接设置,依次关闭super_read_only与read_only并核对两者均为零,最后由只有账本增删改查权限的ledger_app写入新主专属订单并验证完整账本。任何前置步骤失败都不会继续开放写入。把这些步骤写成一个受控程序,比手册里一句“确认旧主关闭”更易复现。

RESET REPLICA ALL清理复制通道配置,不会把副本业务数据变成源的最新数据,也不会补齐那笔未复制事务。操作名称里有RESET,不代表恢复完成。我们在清理前已保存幸存前缀,清理后仍以这个前缀为验收基准,明确承认已知缺失。

数据库只读与外部隔离各有作用

super_read_only可以阻止普通数据写入,但管理权限仍能改变开关。因此生产隔离通常需要结合云实例断电、存储隔离、网络策略、代理控制和凭据策略,具体方式取决于故障域和控制面可靠性。多种措施应形成可验证的顺序,而不是堆叠术语。

本书实际使用容器停止实现提升前硬边界;返回旧主时再使用断网与只读。它没有实现远程电源管理、分布式租约或多数派仲裁,因此不会把单机Docker实验称为生产自动HA。读者可以沿同样的证据结构替换隔离机制:先构造旧主仍可写的负例,再证明所有实际写路径被阻断。

隔离失败时系统应怎样做

无法确认旧主隔离时,自动提升可能扩大事故。流程应保留当前只读状态,给出缺少的隔离证据,并进入人工处理。可用性与一致性选择需要事先由业务承诺约束,不应由值班人员在压力下临时决定。拒绝提升是一个明确结果,比在不确定中同时开放两边更可审计。

这不意味着所有环境都必须选择相同策略。某些业务可以承受分叉后合并,另一些账务业务不能。但策略必须反映到具体数据模型与修复流程。声称“我们可以最终一致”而没有冲突解决规则,并不能替代写入权控制。

保留隔离动作的证据

记录旧主身份、停止请求、停止结果、提升开始、首笔新主提交和验收完成,可以帮助事后证明没有重叠写入。生产还应记录控制面调用ID和应用流量切换时间。本书用单调时钟衡量阶段耗时,用实际容器状态判断边界,避免把墙钟文本顺序误当作严格因果关系。

下一章会让旧主重新启动,展示即使数据还在,也不能直接回到原角色。恢复能力不仅是第一次成功提升,还包括如何处理一个带着分叉历史返回的旧节点。

第15章:新主开始写入之后,旧主应该去哪里

提升的终点不是关闭只读开关,而是新主能够按照预定数据边界接受业务,同时旧主无法重新夺回写入权。尤其当旧主磁盘还在,它可能带着新主没有的订单重新启动。这个节点很有价值,也很危险。本章保留其差异,证明它被隔离,而不把恢复网络连接误当作重新加入成功。

新主第一笔写入是验收对象

旧主停止后,副本停止复制并清理通道配置,依次关闭super_read_only与read_only,检查两项均为零。程序由ledger_app提交订单六千零一,金额十九,标签为新主专属。随后查询该订单存在、旧主独有订单五千零一不存在,全部订单必须等于幸存前缀加六千零一,余额必须在前缀上再减十九。

这些断言同时证明新角色可写、历史边界明确以及新写入确实落在目标节点。只关闭super_read_only会保留read_only,root等管理身份可能绕过后者,因此验收必须由没有管理特权的业务账号完成。仅执行SET GLOBAL成功不能证明业务表可写,空表INSERT成功也不能证明真实账本关系成立。我们用与正常业务相同的三表事务完成验证,使提升验收与业务语义相连。

手工RTO在所有这些检查完成后结束,而不是在开关改变那一刻结束。读者可以从报告看出等待旧主停止占据多少时间,但不能把十秒左右的教学结果宣传为整套线上切换耗时。应用连接池、路由与外部依赖不在程序内,相关时间没有被测量。

两边此时已经有不同未来

旧主拥有五千零一,新主拥有六千零一,两边都保留原来幸存的订单。旧主余额包含十三的支出,新主包含十九的支出。它们各自内部平衡,但业务事实不同。再次启动旧主后,直接把它作为可写主库暴露出去,会让用户读到不同结果,还可能重复执行已经确认的操作。

更换源配置也不能自动解决。若想让旧主跟随新主,需要确定如何处理其额外事务、是否重建数据、如何保存丢失订单证据。把两边GTID集合合并可能导致冲突,直接丢弃旧卷又会失去补偿依据。这里选择最清楚的教学结果:隔离保存,不自动重新加入。

返回路径先断网

程序在旧主仍停止时,将它从任务网络断开,然后启动。这样即使数据库进程刚启动到只读设置生效之间存在时间,其他容器也无法通过该网络连接它。程序随后通过docker exec的本地管理路径设置super_read_only,再发起真实旧主写入,必须得到ERROR 1290。

Docker inspect还要确认网络集合为空。数据库状态查询确认六千零一没有神奇出现在旧主,失败写入七千零一也没有产生。旧主保留原有分叉历史,既不是已经修复,也不是可以重新承接流量。报告明确automatically_rejoined为false,让状态含义可机器识别。

网络隔离与管理路径的区别

docker exec能在断网容器里执行本地SQL,是受控管理路径。它不意味着应用仍可从任务网络写入。生产也常存在独立管理面,必须明确哪些主体仍有修改角色的能力。隔离操作应限制业务访问,同时保留必要取证能力;无法分清两者,可能把所有管理权限都误当成业务安全。

只读变量不是不可撤销保险。拥有足够权限的管理员仍能关闭它,所以审计和变更流程必须覆盖角色变更。正确运行的隔离方案需要防止自动脚本在检测旧主恢复后自行启用写入。自动化的默认行为应与当前集群角色来源一致,不能只根据本机启动成功决定当主。

安全重建通常更容易解释

当差异已经分析并导出,重建旧节点为新主的副本,往往比试图原地合并更可控。重建需要新的正确基线、匹配的GTID历史与完整复制验收。旧节点曾经是主,不代表它天然适合再次跟随。每一次角色转换都应记录源身份和初始数据范围。

本书没有执行这条重建路径,原因是它会引入第二套备份初始化和额外事务处理,不适合在同一实验中悄悄增加未经充分验证的步骤。我们把它留作明确扩展任务,要求读者设计额外验收,而不是用一句“再配置复制即可”糊弄过去。

切换之后还有业务工作

订单五千零一的客户已经收到成功确认,需要对账与补偿。若外部支付存在,不能只在新主插回一行订单就认为所有状态一致;要核对支付金额、幂等键和下游事件。新主六千零一也应进入后续备份和日志保护,避免切换成功后保护链路尚未恢复。

还应检查监控指向、备份任务源、复制账户、只读查询路由和告警规则。很多恢复事故发生在“服务已经好了”之后,因为旧任务仍从错误节点备份,新的日志没有归档。技术切换需要一个明确收尾清单,确保下一次故障仍然有可用恢复输入。

第16章:用业务核对阻止漂亮但错误的恢复结果

恢复系统最容易产生一种危险的自信:进程启动了、表都在、线程没报错,于是任务结束。真正的问题往往藏在对象对应、金额、边界和分叉里。本章把随书state函数拆开,说明每项检查覆盖什么,并建立从技术过程到业务验收的分层证据。

第一层是执行过程

工具退出码检查很基础,却常被shell管道和重定向吞掉。mysqldump失败可能留下部分文件,mysqlbinlog失败可能只产生头部文本,mysql导入失败可能已经提交前面一些语句。程序对每个关键子进程检查返回值,异常立即停止,避免后续成功查询掩盖前序失败。

负例则需要相反判断:必须非零,并且错误是预期原因。只读拒绝明确要求ERROR 1290与read-only语义;缺归档要求实际必需文件缺失时工具失败。随后再查业务状态没有改变。这样可以区分“保护机制工作”和“命令根本没有执行到目标”。

第二层是数据库结构与对象

表存在、列类型和主键约束正确,是业务数据能够解释的前提。教学库由同一个dump恢复,完整构建验证会比较网页、EPUB与源码,但数据库结构仍主要由真实导入证明。扩展到生产时,应独立核对字符集、索引、分区、外键、触发器和存储程序,不只比较CREATE TABLE文本。

对象行为也可能受sql_mode、时区和权限影响。相同SQL在不同环境可能有不同结果,恢复报告应记录关键变量和实际服务账户测试。本书记录了与日志和持久性有关的变量,没有声称覆盖所有配置。验证范围写清楚,比罗列很多未执行检查更可信。

第三层是关系与总账

state读取余额和分录总和,要求余额等于十万加delta。随后核对分录数量与订单数量,查找没有订单或金额不匹配的分录。entries.order_id有唯一约束,结合数量相等和无孤立错误,使每个订单必须恰有一条对应分录。完整订单行包含主键、金额与业务标签。

这里的逻辑依赖实验表结构:唯一约束是检查推理的一部分。若去掉它,同样的数量和孤立查询可能不能证明一一对应,因为一个订单可以重复对应而另一个缺失。任何验证器都依赖数据模型,应把这些前提写出来,不能把函数名称叫validate就视为普遍证明。

第四层是目标集合

一致快照的目标不是源最终数据,因此只要求恢复状态位于导出观测窗口内并满足关系。安静基线要求与源最终状态完全相等。PITR要求与误删前保存的目标相等,并排除误删后订单。故障切换要求幸存前缀加新主专属订单,同时明确缺失源端独有订单。

四种目标各有参照,不能复用一条“源副本相同”断言。若PITR最后与当前误删源相同,反而是失败;若异步故障切换与故障前源完全相同,可能说明没有真正制造丢失窗口。测试应从预期业务命题推导,而不是为了方便通过随时更换比较对象。

第五层是外部确认

数据库内部检查不能知道客户收到了什么。实验把mysql客户端成功返回作为确认,并在报告保留源故障前状态。生产应与独立请求日志、支付系统和下游回执核对。没有外部确认集合时,只能说恢复库内部一致,不能承诺所有已确认交易都被保留。

这层核对还帮助处理结果未知。某次客户端超时可能已提交,也可能未提交。恢复后通过稳定订单号查询,再按幂等协议决定是否重试。若应用每次重试生成新订单ID,数据库恢复本身再正确,也无法阻止客户被重复扣款。

摘要是索引,不是解释

报告同时保存摘要和有序行,便于快速判断相等及定位差异。大数据场景可以保存分块摘要与差异清单,但应保持规范化编码版本。摘要计算方式改变后,旧报告可能无法直接比较,需要标识算法和字段范围。不要把一个十六进制字符串当作无需解释的质量标签。

本书的业务数据有限,报告可读性比极致压缩更重要。一次PITR恢复一百二十三个订单时,读者可以独立核算总支出二百六十、余额九万九千七百四十。显式数值与机器摘要相互支持,也让编辑审稿能发现文字与代码是否一致。

为失败保留足够信息

实验异常会保存FAIL报告和已完成场景,CI始终上传证据。这样可以看到失败发生在启动、备份、归档、复制还是提升。生产还需保护敏感数据,对诊断信息做权限和保留控制。教学标签不是客户数据,我们才允许直接存储完整有序行;不能把这种做法无条件搬到真实个人信息表上。

第17章:给RPO与RTO加上能经受追问的边界

恢复指标最容易被测量方式美化。只测SQL执行、不测人工判断,结果会显得很快;只统计副本延迟、不统计确认记录缺失,结果会显得很安全。本章用实际报告解释指标,并给出将教学测量扩展成生产演练的方法。数值的意义来自边界,不能脱离条件单独传播。

先说这次真的测到了什么

一次成功的实验CI中,MySQL实际版本为8.4.11,PITR从位置一百九十八回放到一千二百五十三,恢复一百二十三笔订单,余额九万九千七百四十。复制切换阶段源端独有订单五千零一没有进入新主,实际丢失一笔已确认事务。手工RTO约十点六秒,具体运行值以validation.json为准。

这些数字不是固定答案。新运行的快照时点、UUID、日志时间和停止耗时都会变化;订单构造和目标金额则保持相同。正式出版CI会重新执行,并将那次报告作为对应工件的证据。书中的一次观测用于解释读法,不会冒充所有运行都达到同样速度。

时间起点必须明确

程序在发起docker stop旧主前开始计时。因此旧主停止的等待包含在RTO内,十秒宽限后可能强杀,实际退出码保存在报告,之后还有检查隔离、提升、写入和完整核对。故障发现发生在程序外,没有测量。控制台人工审批、代理切流、DNS缓存、连接池重建和客户端重试也没有实现,所以报告明确排除这些部分。

如果生产要求“用户重新能够完成付款”为终点,就需要从独立客户端真实提交并验证业务回执,不能沿用本书的数据库客户端边界。若要求“所有订单查询与搜索恢复一致”,还需加入下游索引与缓存水位。指标名称可以相同,业务终点不同就不能直接比较。

RPO不要只写时间差

本书报告实际丢失事务数量和订单ID,再记录从副本前缀观测到源确认之间的单调时钟窗口。这个窗口不等于最后幸存业务的年龄,也不是源和副本时钟差。它只是本次故障注入阶段的观测间隔,不能升级成生产时间上界。

当业务吞吐变化很大时,相同一分钟可能对应一笔交易,也可能对应十万笔。恢复承诺最好同时描述时间、业务数量和价值范围。金融系统尤其需要明确确认金额与外部清算关系。用一个平均延迟秒数代表所有客户损失,会让指标失去业务含义。

把恢复时间拆成阶段

生产演练可记录发现、决策、隔离、数据准备、重放、校验、入口切换、业务验收。每一阶段保留起止事件与证据,最后用连续总时间表示用户受影响范围。若阶段重叠,要说明并行关系,不能把所有阶段最短值相加构造一个没有实际运行过的总数。

容量还会改变阶段占比。小库可能启动和控制面等待占主要时间,大库可能导入与建索引占主要时间。仅把本书订单数增加十倍,不一定能模拟生产IO、长事务与并发连接。容量演练应尽量保持数据分布、索引、硬件和实际恢复工具一致,结果才有可用参考。

失败也要进入指标记录

一次恢复因日志缺失失败,不能从统计中删除后只报告成功平均值。应该记录目标不可达、可恢复到哪个较早边界,以及追加修复需要什么输入。失败概率和最长恢复时间往往比平均成功耗时更影响承诺。连续多次演练的变化也能暴露工具升级或数据增长带来的退化。

本书先经历缺少mysqlbinlog和复制重连退避两个真实CI失败,再修复并重跑。它们说明文档中的命令必须在目标环境执行。成功证据与失败原因共同帮助建立手册,但最终Release只使用完整成功流水线工件,避免把失败时生成的部分文件当成正式交付。

指标改进需要对应措施

如果RTO主要消耗在获取备份,改善存储位置、预置环境或并行读取可能有效;如果主要耗在核对,优化规范化摘要和分块定位可能有效;如果RPO来自异步未接收窗口,需要改变确认与复制策略,而不是仅加快导入。措施必须对应已测瓶颈,否则只是换一个更复杂的工具名称。

任何提高可用性的改动都应重跑隔离与丢失负例。更快提升可能缩短等待,却增加旧主仍可写的风险;更积极保留日志可能增加磁盘压力;更强同步可能影响延迟与可用性。把收益与故障边界放在同一演练报告中,团队才能做清楚的工程选择。

写出一份可用的承诺

用一句话描述失败场景、保护范围、恢复终点和必要条件,再附最近演练记录。比如在单主故障且隔离控制面可用时,以某副本明确水位恢复数据库写服务,已知丢失集合需业务对账。它比“高可用、秒级恢复、零丢失”更长,却能指导下一次真实操作。

第18章:把一次恢复练习变成持续可交付的能力

读完命令并成功跑过一次,只完成了起点。数据库版本会升级,数据会增长,权限会收紧,备份位置和密钥服务也会变化。恢复能力需要随着这些变化重新验证。本章把书中的实验、证据与出版流程连接起来,形成一套可以持续复用的演练工作方式。

先保存一份最小恢复清单

清单应列出目标故障、业务保留集与排除集、备份标识、日志范围、工具版本、目标环境、写入隔离方法和验收条件。每个字段都能对应实际文件或操作。不要把“由资深同事确认”当作唯一恢复依据;经验可以帮助判断,但关键结果应留下其他人能复核的证据。

随书程序体现了这个清单的可执行部分:随机独占资源、固定软件来源、实际备份导入、封口归档、停源回放、GTID等待、真实只读拒绝、隔离后提升和完整业务比较。它没有把手册所有内容自动化,尤其没有实现生产业务确认与外部路由,但实现范围是清楚而可重复的。

版本变化触发重新演练

升级MySQL客户端可能改变默认参数或输出字段,服务器升级可能影响复制认证与日志格式。固定digest避免无意变化,不代表永远无需升级。升级时应主动选择新版本,核对官方变化说明,再完整执行场景和负例。只把镜像标签改成latest然后观察能启动,不足以证明恢复能力延续。

业务结构变化也应触发更新。新增外键、事件、加密列或跨库事务后,原来的简单账本检查可能不覆盖新风险。应为每个重要业务对象添加可验证样本,确认备份包含它、恢复能重建它、回放保留正确顺序。验证器不是一成不变的证书,而是随着业务共同维护的代码。

独立实验包防止隐藏依赖

源码目录里可能留有本地文件,使实验看似完整。正式构建把所有实验脚本、工具Dockerfile和说明打入ZIP,解压到新临时目录,再运行完整真实四实例实验。十个场景必须全部PASS,没有跳过。这个检查直接证明读者下载的包具备运行所需文件,而不是仅检查ZIP里有几个熟悉的文件名。

包不包含镜像和客户端RPM,这些从固定官方地址获取并校验来源。网络或地址不可用时实验失败,不能假装离线可用。若生产恢复要求完全离线,应把验证过的软件放到独立可达仓库,并把获取失败纳入演练。本书的下载依赖已明确写入实验说明。

正式证据来自同次发布

完整CI先执行真实实验,保存validation.json,再生成网页、EPUB和实验包,检查内容与导航,执行EPUBCheck和浏览器验收。发布到Pages的目录与创建Release所用的book-downloads来自同一个成功运行。公开下载后重新计算摘要,确认读者拿到的EPUB与CI工件字节相同。

后续交付说明使用不触发构建的文档提交,避免仅更新报告就重新生成一个未经过同样发布核验的EPUB。若正文或代码需要修改,则走新的完整流水线和补丁版本,不覆盖旧Release资产。这个纪律保存了“哪份书对应哪次实验”的可追溯关系,读者也能回到自己下载的版本。

演练频率由变化与风险决定

可以定期运行,也应在大版本升级、备份策略改变、权限调整和重大业务迁移后运行。周期不必机械一致:高价值账务可能需要更密集的恢复抽查,低频配置库也需要覆盖密钥和人员变化。关键是不要等事故发生才第一次验证新的恢复路径。

演练应轮换执行人员。让没有写过原脚本的人根据说明重做,能发现隐含知识与模糊步骤。将失败记录转成具体改进,例如增加归档缺口检测、明确只读错误断言或补充重连预算。避免只记录一次成功截图,下一次仍依靠同一个人临场判断。

守住破坏性操作范围

恢复练习可以逼真,但破坏必须可控。任务资源随机命名、端口不公开、清理只针对本次创建对象,是本书的基本约束。生产规模预演应使用脱敏数据与独立环境,若需要生产演练,则建立与业务授权匹配的更严格流程。不能把教学中的公开口令、root管理和容器停止直接搬过去。

最值得保留的习惯,是每次都先问这一步将证明哪项业务事实。备份文件存在证明输入可定位,日志连续证明范围可能重建,隔离证明写入权明确,恢复核对证明目标状态达成。把这些事实连起来,恢复能力才会从一句承诺变成可以反复交付的工程结果。

附录A:练习解析与扩展实验

恢复承诺的参考答案

库存服务可以要求库存流水与订单明细对应、可售量等于期初加全部增减、已发货订单不超过已确认出库。三者成立仍可能遗漏一整笔客户已确认订单,也可能把客户身份关联错。内部自洽与外部确认必须同时检查。RTO可以从故障注入开始到独立客户端成功下单并查询库存扣减结束,明确是否包含检测、路由和下游同步。

提交与复制的参考答案

源端提交先于客户端成功回执,但异步副本接收和应用不需要先于这个回执。网络在提交后断开时,客户端可能只收到错误,源事务却已经存在。恢复后根据稳定业务键查询并按幂等协议重试,不能换一个新订单号盲目再扣款。GTID包含只表示执行历史声明,不能替代金额、对象关系与外部回执检查。

暂停线程的扩展

停止IO线程时,SQL线程仍可能应用已有relay log,所以副本数据可能继续前进,直到现存日志耗尽。要观察后续缺口,应先固定已执行水位,再在源提交新的唯一标签事务。记录源集合、副本集合与业务查询结果,恢复接收后等待明确集合执行,再核对。不要假设停止接收等于立即停止所有应用。

外部支付已成功的扩展

五千零一缺失时,至少需要稳定订单号与支付流水或幂等请求键。先查询支付通道确认扣款,再决定在新主补建业务对象还是退款。重复支付不安全,因为原支付事实不会随着数据库切换消失。对业务的准确说明可以是:新主已恢复写入,订单五千零一尚未在新主找到且源已确认成功,正在核对支付流水并执行补偿。

不能直接照搬的三项改造

第一,将pitr加入原拓扑:需要处理本地导入产生的GTID、源集合、数据覆盖范围与额外事务,不能只执行CHANGE SOURCE。第二,跨文件回放:需要验证文件连续性、来源身份与各自起止边界。第三,自动提升:需要真实故障检测、旧主隔离、候选数据比较、路由与恢复后的防回切机制。任何改造都要增加负例,不能仅改变脚本开关。

建议的下一步实验

可以为订单添加外键与触发器,备份后验证其恢复行为;可以制造较大的单笔事务,区分接收日志大小与应用可见性;可以在归档上传中途故障,验证不完整文件不会进入可恢复清单。以上是待执行方向,不属于本版十个已运行场景。扩展时继续遵守随机独占资源与仅删除自己的容器约定。

附录B:一手来源与版本边界

本书以Oracle发布的MySQL 8.4 Reference Manual为主要语义依据。以下条目在2026年10月21日真实下载,文件字节数与SHA-256保存在下载区sources.json以及仓库research/sources.md。全文缓存只用于核查,不随书或实验包公开分发。下面是作者自己的归纳,不是复制官方正文。

  1. Using mysqldump for Backups:逻辑备份与SQL恢复的基本路径。
  2. mysqldump:single-transaction、source-data、set-gtid-purged等选项及限制。
  3. Point-in-Time Recovery:完整备份与连续日志组成恢复链路。
  4. Recovery Using Event Positions:用事件位置确定恢复区间。
  5. Replication with GTIDs:事务身份、执行集合与自动定位。
  6. Setting Up Replication Using GTIDs:实例与初始化前提。
  7. Replication Threads:接收和应用的职责差异。
  8. Switching Sources During Failover:切换过程与源选择注意事项。
  9. mysqlbinlog:事件解析、位置参数与可执行回放。
  10. SHOW REPLICA STATUS:线程、错误与延迟字段的含义。
  11. Backup and Recovery Types:备份分类所表达的不同维度。

实际数据库是固定官方镜像中的8.4.11,工具mysqlbinlog来自Oracle固定8.4.11客户端RPM,摘要见实验代码与validation.json。在线手册可能更新,本书以所获取版本和真实实验交叉核查。一次实验没有覆盖的配置或产品能力,会明确作为扩展讨论,不用官方文档存在来代替实际运行证据。

附录C:术语与快速核对表

术语 本书中的准确含义
一致快照 事务表在同一逻辑视图下读取,不等于导出结束时最新数据
逻辑备份 用SQL等逻辑表达保存结构与数据,恢复时重新执行
基线 后续日志回放的已验证起始数据状态
binlog MySQL服务器层变化日志,用于复制与目标恢复
redo InnoDB内部崩溃恢复所用日志,不是mysql客户端回放输入
PITR 在基线之后按日志恢复到明确目标边界
GTID 事务身份及其集合表达,不是业务正确性证书
relay log 副本接收后供应用线程执行的中继日志
接收水位 副本已经取得的日志进度,不等于业务可见进度
执行水位 副本已应用的事务进度,仍需检查数据内容
fence 在提升新主前排除旧主继续写入的实际边界
super_read_only 服务器只读限制,管理员可改变,不能替代全部外部隔离
RPO 允许或实际发生的数据损失范围,需说明业务单位与边界
RTO 从明确故障起点到明确服务终点的时间,需列出包含阶段
分叉 两个节点分别接受写入,形成不同事务与业务未来
归档摘要 标识文件字节的SHA-256,不单独证明日志连续与业务正确

恢复前先问:输入是否独立可得,范围是否连续,目标是否隔离?回放时先问:坐标是否来自真实基线,结束是否为完整事务边界?提升时先问:旧主是否已被实际阻断,候选节点缺什么?验收时先问:哪些确认交易保留,哪些事务排除,不变量是否成立?收尾时先问:分叉证据是否保存,新主的备份与归档是否已经接续?

附录:随书实验完整代码

以下文件在构建时从随书实验与测试目录读取,与下载区实验包同源。它们是本书教学实现;实际验证范围以实验章节和验证记录为准。

lab/README.md

# MySQL 恢复实验

需要 linux/amd64、Python 3.12+、Docker Engine、可下载固定官方 MySQL 镜像,约 2 GB 可用内存与 3 GB 磁盘。工具RPM固定为x86_64,ARM原生环境未验证;本书正式证据来自 Ubuntu 24.04 GitHub Actions,不把不同平台结果混为一谈。

```sh
python3 lab/run.py --evidence evidence/runtime.json
```

程序创建带随机前缀的私有 bridge network 和四个实例,端口不发布到宿主机,使用教学专用口令,完成后只删除本次记录的容器(含匿名卷)及网络。不会执行 Docker prune。遇异常不跳过测试,返回非零,证据 status 为 FAIL;进程被强杀则需按本次随机前缀手工清理其资源,不能清理其他项目。

10个场景必须全部 PASS:GTID复制建立、写入期间的一致快照、干净基线恢复、事务边界PITR、缺失日志不可达到目标、误删同样复制、应用暂停再追平、未隔离拒绝提升、隔离后手工提升并观测已确认事务丢失、返回旧主断网且只读。

业务写入由ledger_app执行,仅具有ledger.*的SELECT/INSERT/UPDATE/DELETE,没有SUPER或CONNECTION_ADMIN。提升显式关闭super_read_only和read_only并检查0/0,再用该普通业务账号提交与查询。角色管理、备份和核对使用root。

事务业务不变量:账户余额=100000+分录delta之和;订单与分录逐行一一对应;分录金额等于订单金额相反数。恢复目标还比较全部有序订单、余额和分录总和的SHA-256,不能仅用行数或线程状态判定成功。

RTO仅计本次docker stop发起到新主成功提交并查询确认的手工流程,排除故障发现、路由、DNS与客户端重试。RPO报告实际缺失事务ID和数量;观察窗口是停止复制后相邻客户端观测间隔,不是线上RPO保证。

使用的逻辑备份参数为 `--single-transaction --source-data=2 --set-gtid-purged=OFF --databases ledger`,实验只针对InnoDB事务表且没有并发DDL。恢复目的不是直接加入原复制拓扑,不能照搬此GTID选项到生产副本初始化。回放前FLUSH封口并复制真实日志到任务私有归档,记录字节与hash,停止源实例后用无网络工具容器只读挂载归档。缺失日志负例临时移走实际必需文件并验证基线未变化。回放使用同一实际binlog文件中的开始位置与误删事务之前的提交结束位置,生产跨文件日志需逐文件验证连续性。

每次运行均重新拉起干净实例,固定镜像digest,报告实际VERSION和变量。不包含自动选主、代理改路由、物理备份、跨地域容灾、故障磁盘持久性或生产密钥管理。

lab/run.py

#!/usr/bin/env python3
"""Real, isolated MySQL recovery exercises. Requires Docker; never skips tests."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import tempfile
import time
import urllib.request

IMAGE = 'mysql@sha256:0f51e63e537a1f176423fa03ef559aabe6fa4372a53160a44bc7e75da7d15364'
PASSWORD = 'recovery-lab-only'
CLIENT_URL = 'https://repo.mysql.com/yum/mysql-8.4-community/el/9/x86_64/mysql-community-client-8.4.11-1.el9.x86_64.rpm'
CLIENT_SHA256 = '2f1e8317c557363dd1554b29a9f6d69403802dc623fe72d9995f20f542771bb1'


def call(args, data=None, check=True, timeout=180):
    p = subprocess.run(args, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
    if check and p.returncode:
        raise RuntimeError(f'{args[0:3]} failed ({p.returncode}): {p.stderr.decode(errors="replace")[-3000:]}')
    return p


class Lab:
    def __init__(self, output):
        self.prefix = 'mysql-recovery-' + secrets.token_hex(6)
        self.network = self.prefix + '-net'
        self.names = []
        self.tmp = tempfile.TemporaryDirectory(prefix=self.prefix)
        self.report = {'status': 'RUNNING', 'image': IMAGE, 'scenarios': [], 'scope':
            'Isolated Linux Docker bridge; teaching data; manual failover; no production routing, automatic HA or cross-region claim.'}
        self.report['source_sha'] = os.environ.get('GITHUB_SHA', 'local-unattributed-run')
        self.output = Path(output)
        self.output.parent.mkdir(parents=True, exist_ok=True)

    def sql(self, name, query, check=True):
        return self.sql_result(name, query, check=check).stdout.decode().strip()

    def sql_result(self, name, query, check=True, user='root'):
        return call(['docker', 'exec', '-e', 'MYSQL_PWD=' + PASSWORD, self.prefix + '-' + name,
                     'mysql', '-u' + user, '--batch', '--raw', '--skip-column-names', '-e', query],
                    check=check)

    def business(self, name, query):
        return self.sql_result(name, query, user='ledger_app').stdout.decode().strip()

    def readonly_rejected(self, name, query):
        result = self.sql_result(name, query, check=False, user='ledger_app')
        error = result.stderr.decode(errors='replace')
        assert result.returncode != 0 and 'ERROR 1290' in error and 'read-only' in error, error
        return {'user': 'ledger_app', 'exit': result.returncode, 'mysql_error': error.strip()}

    def prepare_tools(self):
        context = Path(self.tmp.name) / 'tools'
        context.mkdir()
        rpm = context / 'client.rpm'
        with urllib.request.urlopen(CLIENT_URL, timeout=90) as response:
            rpm.write_bytes(response.read())
        assert hashlib.sha256(rpm.read_bytes()).hexdigest() == CLIENT_SHA256
        dockerfile = Path(__file__).with_name('tools.Dockerfile')
        (context / 'Dockerfile').write_bytes(dockerfile.read_bytes())
        call(['docker', 'build', '-t', self.prefix + '-tools', str(context)], timeout=240)
        version = call(['docker', 'run', '--rm', '--network', 'none', self.prefix + '-tools',
                        'mysqlbinlog', '--version']).stdout.decode().strip()
        assert '8.4.11' in version
        self.report['binlog_tool'] = {'url': CLIENT_URL, 'rpm_sha256': CLIENT_SHA256, 'version': version,
                                     'architecture': 'linux/amd64'}

    def archived_binlog(self, directory, filename, startpos, stoppos, check=True):
        return call(['docker', 'run', '--rm', '--network', 'none', '--mount',
                     f'type=bind,source={directory},target=/archive,readonly', self.prefix + '-tools',
                     'mysqlbinlog', '--start-position=' + str(startpos), '--stop-position=' + str(stoppos),
                     '/archive/' + filename], check=check)

    def stop_source(self, stage):
        call(['docker', 'stop', '--time', '10', self.prefix + '-source'])
        state = json.loads(call(['docker', 'inspect', '--format', '{{json .State}}',
                                 self.prefix + '-source']).stdout)
        assert state['Running'] is False and state['OOMKilled'] is False
        self.report.setdefault('stop_observations', []).append({'stage': stage,
            'running': state['Running'], 'exit_code': state['ExitCode'],
            'finished_at': state['FinishedAt'], 'grace_seconds': 10})

    def feed(self, name, data):
        return call(['docker', 'exec', '-i', '-e', 'MYSQL_PWD=' + PASSWORD, self.prefix + '-' + name,
                     'mysql', '-uroot', '--binary-mode'], data=data)

    def check(self, name, **values):
        self.report['scenarios'].append({'name': name, 'status': 'PASS', **values})
        print('PASS', name, flush=True)

    def start(self, name, server_id):
        full = self.prefix + '-' + name
        call(['docker', 'run', '-d', '--name', full, '--network', self.network,
              '--network-alias', name, '--label', 'aias.mysql-recovery=' + self.prefix,
              '-e', 'MYSQL_ROOT_PASSWORD=' + PASSWORD, '-e', 'MYSQL_ROOT_HOST=%', IMAGE,
              '--server-id=' + str(server_id), '--log-bin=mysql-bin', '--gtid-mode=ON',
              '--enforce-gtid-consistency=ON', '--binlog-row-image=FULL',
              '--innodb-buffer-pool-size=64M', '--performance-schema=OFF'])
        self.names.append(name)
        for _ in range(100):
            try:
                if self.sql(name, 'SELECT 1') == '1':
                    return
            except RuntimeError:
                pass
            time.sleep(1)
        raise RuntimeError('MySQL readiness timed out: ' + name)

    def state(self, name):
        rows = self.sql(name, 'SELECT id,amount,tag FROM ledger.orders ORDER BY id;').splitlines()
        account = int(self.sql(name, 'SELECT balance FROM ledger.accounts WHERE id=1'))
        delta = int(self.sql(name, 'SELECT COALESCE(SUM(delta),0) FROM ledger.entries'))
        entry_count = int(self.sql(name, 'SELECT COUNT(*) FROM ledger.entries'))
        orphan = int(self.sql(name, 'SELECT COUNT(*) FROM ledger.entries e LEFT JOIN ledger.orders o ON e.order_id=o.id WHERE o.id IS NULL OR e.delta<>-o.amount'))
        assert account == 100000 + delta, (name, account, delta)
        assert entry_count == len(rows) and orphan == 0, (name, entry_count, rows, orphan)
        canonical = '\n'.join(rows) + f'\nbalance={account}\ndelta={delta}'
        return {'orders': len(rows), 'balance': account, 'entry_sum': delta,
                'sha256': hashlib.sha256(canonical.encode()).hexdigest(), 'rows': rows}

    @staticmethod
    def order(ident, tag, amount=1):
        assert re.fullmatch('[a-z0-9-]+', tag)
        return (f"START TRANSACTION; INSERT INTO ledger.orders VALUES({ident},{amount},'{tag}'); "
                f"INSERT INTO ledger.entries VALUES({ident},{ident},-{amount}); "
                f"UPDATE ledger.accounts SET balance=balance-{amount} WHERE id=1; COMMIT;")

    def dump(self):
        p = call(['docker', 'exec', '-e', 'MYSQL_PWD=' + PASSWORD, self.prefix + '-source',
                  'mysqldump', '-uroot', '--single-transaction', '--source-data=2',
                  '--set-gtid-purged=OFF', '--routines', '--triggers', '--events', '--hex-blob',
                  '--databases', 'ledger'])
        return p.stdout

    def wait_replica(self):
        gtid = self.sql('source', 'SELECT @@GLOBAL.gtid_executed')
        result = self.sql('replica', f"SELECT WAIT_FOR_EXECUTED_GTID_SET('{gtid}',30)")
        assert result == '0', self.sql('replica', 'SHOW REPLICA STATUS\\G')
        assert self.state('source') == self.state('replica')
        return gtid

    def promote(self):
        running = call(['docker', 'inspect', '--format', '{{.State.Running}}', self.prefix + '-source']).stdout.strip()
        if running != b'false':
            raise RuntimeError('FENCE_REQUIRED: old primary remains running; promotion refused')
        self.sql('replica', 'STOP REPLICA; RESET REPLICA ALL; SET GLOBAL super_read_only=OFF; SET GLOBAL read_only=OFF;')
        assert self.sql('replica', 'SELECT @@GLOBAL.read_only,@@GLOBAL.super_read_only') == '0\t0'

    def run(self):
        self.prepare_tools()
        call(['docker', 'network', 'create', '--label', 'aias.mysql-recovery=' + self.prefix, self.network])
        for i, name in enumerate(['source', 'replica', 'restore', 'pitr'], 11):
            self.start(name, i)
        self.report['mysql_version'] = self.sql('source', 'SELECT VERSION()')
        self.report['server_variables'] = self.sql('source', 'SELECT @@gtid_mode,@@enforce_gtid_consistency,@@binlog_format,@@binlog_row_image,@@sync_binlog,@@innodb_flush_log_at_trx_commit')
        self.report['image_inspect'] = json.loads(call(['docker', 'image', 'inspect', IMAGE, '--format', '{{json .RepoDigests}}']).stdout)
        self.sql('source', "CREATE USER 'repl'@'%' IDENTIFIED BY 'replica-lab-only'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';")
        self.sql('source', f"CREATE USER 'ledger_app'@'%' IDENTIFIED BY '{PASSWORD}'; GRANT SELECT,INSERT,UPDATE,DELETE ON ledger.* TO 'ledger_app'@'%';")
        grants = self.sql('source', "SHOW GRANTS FOR 'ledger_app'@'%'")
        assert 'SUPER' not in grants and 'CONNECTION_ADMIN' not in grants and 'ALL PRIVILEGES' not in grants
        self.report['business_identity'] = {'user': 'ledger_app', 'grants': grants,
                                           'scope': 'ledger.* DML; no administrative privileges'}
        self.sql('replica', "CHANGE REPLICATION SOURCE TO SOURCE_HOST='source',SOURCE_USER='repl',SOURCE_PASSWORD='replica-lab-only',SOURCE_AUTO_POSITION=1,SOURCE_CONNECT_RETRY=1,GET_SOURCE_PUBLIC_KEY=1; START REPLICA; SET GLOBAL super_read_only=ON;")
        self.sql('source', '''CREATE DATABASE ledger; USE ledger;
            CREATE TABLE accounts(id INT PRIMARY KEY,balance BIGINT NOT NULL) ENGINE=InnoDB;
            CREATE TABLE orders(id INT PRIMARY KEY,amount INT NOT NULL,tag VARCHAR(40) NOT NULL) ENGINE=InnoDB;
            CREATE TABLE entries(id INT PRIMARY KEY,order_id INT UNIQUE NOT NULL,delta INT NOT NULL) ENGINE=InnoDB;
            CREATE TABLE padding(id INT PRIMARY KEY,payload LONGTEXT NOT NULL) ENGINE=InnoDB;
            INSERT INTO accounts VALUES(1,100000);''')
        self.business('source', self.order(1, 'opening-order', 100))
        # Slow enough for real dump/commit overlap, but padding is never a business invariant.
        self.sql('source', "SET SESSION cte_max_recursion_depth=3000; INSERT INTO ledger.padding WITH RECURSIVE seq AS (SELECT 1 n UNION ALL SELECT n+1 FROM seq WHERE n<2000) SELECT n,REPEAT('x',8192) FROM seq;")
        self.wait_replica()
        self.check('replication-bootstrap', source_equals_replica=True)
        writer_sql = ''.join(self.order(i, 'backup-concurrent') + ' DO SLEEP(0.08);' for i in range(2, 122))
        writer = subprocess.Popen(['docker', 'exec', '-i', '-e', 'MYSQL_PWD=' + PASSWORD,
                                  self.prefix + '-source', 'mysql', '-uledger_app'], stdin=subprocess.PIPE,
                                  stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        writer.stdin.write(writer_sql.encode()); writer.stdin.close(); writer.stdin = None
        deadline = time.monotonic() + 15
        while int(self.sql('source', 'SELECT COUNT(*) FROM ledger.orders')) < 2:
            assert time.monotonic() < deadline
            time.sleep(.03)
        before = int(self.sql('source', 'SELECT COUNT(*) FROM ledger.orders'))
        dump_started = time.monotonic()
        snapshot = self.dump()
        dump_seconds = time.monotonic() - dump_started
        after = int(self.sql('source', 'SELECT COUNT(*) FROM ledger.orders'))
        writer_out, writer_err = writer.communicate(timeout=40)
        assert writer.returncode == 0, writer_err.decode()
        source_final = self.state('source')
        assert after > before, ('No observed commits during actual dump', before, after)
        self.feed('restore', snapshot)
        restored = self.state('restore')
        assert before <= restored['orders'] <= after < source_final['orders'], (before, restored, after)
        self.check('consistent-snapshot-under-writes', source_count_before=before,
                   source_count_after=after, restored=restored, final_source_orders=source_final['orders'],
                   dump_seconds=round(dump_seconds, 4), dump_sha256=hashlib.sha256(snapshot).hexdigest())
        # A second quiet baseline makes the exact recovery target independently unambiguous.
        self.sql('source', 'FLUSH BINARY LOGS')
        baseline = self.dump()
        pos = re.search(rb"SOURCE_LOG_FILE='([^']+)', SOURCE_LOG_POS=(\d+)", baseline)
        assert pos, 'No source coordinates in dump'
        filename, startpos = pos[1].decode(), int(pos[2])
        self.feed('pitr', baseline)
        assert self.state('pitr') == source_final
        self.check('clean-baseline-restore', baseline=source_final, binlog=filename, start_position=startpos)
        self.business('source', self.order(2001, 'keep-before-delete', 17))
        self.business('source', self.order(2002, 'keep-second', 23))
        target = self.state('source')
        status = self.sql('source', 'SHOW BINARY LOG STATUS').split('\t')
        assert status[0] == filename
        stoppos = int(status[1])
        target_gtid = self.sql('source', 'SELECT @@GLOBAL.gtid_executed')
        # This unwanted transaction is valid SQL and replicates: replication is not a backup.
        self.business('source', 'START TRANSACTION; DELETE FROM ledger.entries; DELETE FROM ledger.orders; UPDATE ledger.accounts SET balance=100000 WHERE id=1; COMMIT;')
        deleted = self.state('source')
        assert deleted['orders'] == 0
        self.business('source', self.order(3001, 'after-delete', 7))
        self.sql('source', 'FLUSH BINARY LOGS')
        archive = Path(self.tmp.name) / 'archive'
        archive.mkdir()
        archived_file = archive / filename
        call(['docker', 'cp', self.prefix + '-source:/var/lib/mysql/' + filename, str(archived_file)])
        self.report['binlog_archive'] = {'file': filename, 'bytes': archived_file.stat().st_size,
            'sha256': hashlib.sha256(archived_file.read_bytes()).hexdigest(),
            'sealed_by_flush': True, 'replay_mount_readonly': True, 'replay_network': 'none'}
        self.stop_source('offline-archive-replay')
        # Remove the actual required archive: the failure must not mutate the clean baseline.
        held = archived_file.with_suffix('.held')
        archived_file.rename(held)
        missing = self.archived_binlog(archive, filename, startpos, stoppos, check=False)
        assert missing.returncode != 0
        assert self.state('pitr') == source_final and source_final['sha256'] != target['sha256']
        self.check('missing-log-cannot-reach-target', mysqlbinlog_exit=missing.returncode,
                   required_file=filename, baseline_unchanged=True, snapshot_differs_from_target=True)
        held.rename(archived_file)
        replay = self.archived_binlog(archive, filename, startpos, stoppos).stdout
        self.feed('pitr', replay)
        assert self.state('pitr') == target
        assert not self.sql('pitr', 'SELECT id FROM ledger.orders WHERE id=3001')
        self.check('transaction-boundary-pitr', start_position=startpos, stop_position=stoppos,
                   binlog=filename, expected=target, actual=self.state('pitr'), excluded=['delete-transaction','after-delete'],
                   target_source_gtid=target_gtid, replay_sha256=hashlib.sha256(replay).hexdigest(),
                   source_running_during_replay=False, archive=self.report['binlog_archive'])
        call(['docker', 'start', self.prefix + '-source'])
        for _ in range(60):
            try:
                if self.sql('source', 'SELECT 1') == '1':
                    break
            except RuntimeError:
                time.sleep(1)
        else:
            raise RuntimeError('Source did not restart after offline archive replay')
        # Restart receiver explicitly; do not let its default reconnect backoff
        # become an undocumented 60-second wait in a 30-second assertion.
        self.sql('replica', 'STOP REPLICA IO_THREAD; START REPLICA IO_THREAD;')
        self.wait_replica()
        assert self.state('replica')['orders'] == 1  # it received the accidental deletion too
        self.check('accidental-delete-replicates', replica_orders=1, recovered_pitr_orders=target['orders'])
        self.sql('replica', 'STOP REPLICA SQL_THREAD')
        applied = self.state('replica')
        self.business('source', self.order(4001, 'delayed-apply', 11))
        wanted = self.sql('source', 'SELECT @@GLOBAL.gtid_executed')
        assert self.state('replica') == applied
        assert self.sql('replica', f"SELECT GTID_SUBSET('{wanted}',@@GLOBAL.gtid_executed)") == '0'
        self.sql('replica', 'START REPLICA SQL_THREAD')
        self.wait_replica()
        self.check('pause-and-catch-up', during_pause=applied, after_resume=self.state('replica'))
        self.sql('replica', 'STOP REPLICA')
        failover_prefix = self.state('replica')
        prefix_time = time.monotonic()
        self.business('source', self.order(5001, 'acknowledged-but-not-replicated', 13))
        source_ack_time = time.monotonic()
        source_before_failure = self.state('source')
        assert source_before_failure['orders'] == failover_prefix['orders'] + 1
        assert not self.sql('replica', 'SELECT id FROM ledger.orders WHERE id=5001')
        try:
            self.promote()
        except RuntimeError as error:
            assert 'FENCE_REQUIRED' in str(error)
        else:
            raise AssertionError('Unsafe promotion was accepted')
        denied = self.readonly_rejected('replica', self.order(6000, 'must-reject'))
        assert self.state('replica') == failover_prefix
        assert self.sql('replica', 'SELECT @@GLOBAL.super_read_only') == '1'
        self.check('unfenced-promotion-refused', old_primary_running=True, replica_remains_read_only=True,
                   actual_write_rejection=denied)
        failure_start = time.monotonic()
        self.stop_source('failover-fence')
        self.promote()
        self.business('replica', self.order(6001, 'new-primary-only', 19))
        promoted = self.state('replica')
        assert not self.sql('replica', 'SELECT id FROM ledger.orders WHERE id=5001')
        assert self.business('replica', 'SELECT tag FROM ledger.orders WHERE id=6001') == 'new-primary-only'
        assert promoted['orders'] == failover_prefix['orders'] + 1
        expected_rows = failover_prefix['rows'] + ['6001\t19\tnew-primary-only']
        assert promoted['rows'] == expected_rows
        assert promoted['balance'] == failover_prefix['balance'] - 19
        new_primary_ready = time.monotonic()
        self.check('fenced-manual-failover-with-observed-loss', source_before_failure=source_before_failure,
                   surviving_prefix=failover_prefix, promoted=promoted, lost_acknowledged_order_ids=[5001],
                   rpo_lost_transactions=1, observed_window_seconds=round(source_ack_time-prefix_time, 4),
                   manual_rto_seconds=round(new_primary_ready-failure_start, 4),
                   business_user='ledger_app', read_only=self.sql('replica', 'SELECT @@GLOBAL.read_only'),
                   super_read_only=self.sql('replica', 'SELECT @@GLOBAL.super_read_only'),
                   rto_boundary='docker-stop invocation to verified new-primary business commit; excludes detection/routing/client retry')
        # A returning old primary stays disconnected; fence its writes before any possible rejoin.
        call(['docker', 'network', 'disconnect', self.network, self.prefix + '-source'])
        call(['docker', 'start', self.prefix + '-source'])
        for _ in range(60):
            try:
                self.sql('source', 'SET GLOBAL super_read_only=ON')
                break
            except RuntimeError:
                time.sleep(1)
        else:
            raise RuntimeError('Returning source did not start')
        denied = self.readonly_rejected('source', self.order(7001, 'old-primary-reject'))
        assert not self.sql('source', 'SELECT id FROM ledger.orders WHERE id=7001')
        assert not self.sql('source', 'SELECT id FROM ledger.orders WHERE id=6001')
        networks = json.loads(call(['docker', 'inspect', '--format', '{{json .NetworkSettings.Networks}}', self.prefix + '-source']).stdout)
        assert not networks
        self.check('returning-old-primary-quarantined', networks=networks,
                   super_read_only=self.sql('source', 'SELECT @@GLOBAL.super_read_only'),
                   divergent_old_state=True, automatically_rejoined=False, actual_write_rejection=denied)
        self.report['status'] = 'PASS'
        self.report['scenario_count'] = len(self.report['scenarios'])
        assert self.report['scenario_count'] == 10

    def close(self):
        self.output.write_text(json.dumps(self.report, ensure_ascii=False, indent=2) + '\n')
        for name in self.names:
            call(['docker', 'rm', '-f', '-v', self.prefix + '-' + name], check=False)
        call(['docker', 'network', 'rm', self.network], check=False)
        call(['docker', 'image', 'rm', self.prefix + '-tools'], check=False)
        self.tmp.cleanup()


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--evidence', default='evidence/runtime.json')
    args = parser.parse_args()
    lab = Lab(args.evidence)
    try:
        lab.run()
    except BaseException as error:
        lab.report['status'] = 'FAIL'
        lab.report['error'] = str(error)
        for name in lab.names:
            logs = call(['docker', 'logs', '--tail', '40', lab.prefix + '-' + name], check=False)
            print(name, logs.stderr.decode(errors='replace')[-5000:])
        raise
    finally:
        lab.close()


if __name__ == '__main__':
    main()

lab/tools.Dockerfile

FROM mysql@sha256:0f51e63e537a1f176423fa03ef559aabe6fa4372a53160a44bc7e75da7d15364
# Only extraction uses cpio. The actual mysqlbinlog comes from an exact Oracle
# client RPM whose SHA-256 is checked by run.py before this build.
COPY client.rpm /tmp/client.rpm
RUN microdnf install -y cpio && \
    cd /tmp && rpm2cpio client.rpm | cpio -id ./usr/bin/mysqlbinlog && \
    cp /tmp/usr/bin/mysqlbinlog /usr/local/bin/mysqlbinlog && \
    rm -rf /tmp/client.rpm /tmp/usr && microdnf clean all