MySQL 故障切换:服务恢复以后,数据真的连续吗
高可用至少包含三件事:发现故障、选出可以提升的 replica、让应用连到新 source。三件事都做成了,仍可能有已经确认的写入不见了、超时重试写了两遍、刚写完去 replica 读不到。这些要分开验证。
先看三次实测切换的结果。客户端每 20ms 写一笔带唯一 request_id 的订单,记下每一笔收到的成功确认;source 被 SIGKILL 后,按同一套流程提升 replica1,再拿确认记录和新 source 上的数据逐笔核对:
| 场景 | 已确认写入 | 新 source 上缺失 | 重复 | 故障到第一笔新写入 |
|---|---|---|---|---|
| 异步复制,replica 先与 source 网络分区,3 秒后 source 崩溃 | 345 | 137 | 0 | 8.6 s |
| 半同步复制,replica1 继续接收但暂停应用,4 秒后 source 崩溃 | 391 | 0 | 0 | 9.8 s |
| 半同步复制,两个 replica 都不可达,等待超时后 source 崩溃 | 457 | 255 | 0 | 9.8 s |
三次切换都「成功」了,服务在 10 秒内恢复写入;但其中两次丢了已经确认给客户端的写入,而这不是切换流程的错。这篇文章从这张表出发,把可用性、数据完整性、幂等性和读一致性分开讨论。
本文讨论 MySQL 8.4 的经典异步与半同步复制。InnoDB Cluster(Group Replication 加 MySQL Router)的协议和失败边界不同,结论不能直接套用,文末单独说明。
一、先说结论
- 切换成功不等于数据完整。 异步复制下,source 已确认、还没传到任何 replica 的事务,会随 source 一起消失;实测分区 3 秒后崩溃,丢了 137 笔已确认写入。
- 半同步的 ACK 只表示 replica 收到了,不表示应用了。 实测 replica1 暂停应用 4 秒,ACK 照常返回;直接提升它会缺 277 笔,先把 relay log 应用完再提升则一笔不缺。
- 半同步等待超时后会退化为异步。 两个 replica 都不可达时,source 等满
rpl_semi_sync_source_timeout后照常提交,之后崩溃同样会丢数据。 - 数据库切换不会替应用去重。 客户端超时后不知道事务是否已经提交;没有唯一请求号时重试会写出两行,有唯一请求号时重试能识别「已经成功」。
- 「写完立刻读 replica」几乎总是读不到。 即使复制正常,实测 300 次写后立即读,300 次都读不到;需要读 source,或者等 replica 追上这笔写入的 GTID。
- 旧 source 恢复后不能直接接回去。 它多出来的事务正好是丢失的那些,数据已经分叉。
二、先定义四个目标
| 目标 | 问题 | 本文的度量 |
|---|---|---|
| 可用性 | 多久恢复写服务 | 故障到第一笔新写入成功的时间 |
| 数据完整性 | 已确认的写入是否都在 | 客户端确认序列与新 source 数据的差集 |
| 幂等性 | 结果未知的请求重试后会不会重复 | 同一 request_id 的行数 |
| 读一致性 | 写完之后读到的数据有多新 | 写后立即读取的「读不到」次数 |
「高可用」这个词通常只指第一个。另外三个要单独设计、单独验证,下面逐个展开。
三、异步与半同步到底确认到哪一步
异步复制:source 提交并写完 binlog 就返回成功,replica 什么时候拉走由它自己决定。source 崩溃时,已确认、还没被任何 replica 收到的事务就没有了。
实测场景一:两个 replica 与 source 的复制网络断开,客户端仍然连着 source 写入;3 秒后 source 崩溃。新 source 上缺失 137 笔,全部是分区开始之后、崩溃之前确认的写入。分区开始前一刻确认、还在复制途中的写入同样会丢,在另一次运行中出现过,所以缺失的范围要以确认时间和复制进度一起判断。
半同步复制:source 提交时,等待至少一个 replica 回复 ACK 才返回成功。MySQL 8.4 默认的等待点是 AFTER_SYNC:写完 binlog 之后、存储引擎提交之前等待,replica 把事务写进 relay log 后回复 ACK。
这缩小了「已确认但只存在于 source」的窗口,但 ACK 发生在图中的第 ② 步:
- replica 收到了,不代表应用了,读 replica 仍可能读不到;
- 提升 replica 之前,必须先把它已经收到的 relay log 应用完。
实测场景二:replica2 停止接收,replica1 继续接收并回复 ACK,但暂停应用;4 秒后 source 崩溃。半同步一直有效(Rpl_semi_sync_source_yes_tx 为 289,no_tx 为 0)。此时 replica1 已执行的数据里缺 277 笔已确认的写入,它们全部躺在 relay log 里;按下面第四节的流程先应用完再提升,缺失为 0。
半同步会退化为异步。 source 等待 ACK 超过 rpl_semi_sync_source_timeout(默认 10 秒)后,放弃等待、照常提交,并把半同步状态切到 OFF;之后的提交都不再等 ACK。实测场景三把超时设为 2 秒,两个 replica 都不可达:第一批提交等了 2 秒后返回,状态变为 OFF(Rpl_semi_sync_source_no_tx 为 164),此后 source 崩溃,丢了 255 笔已确认写入。
至少一个 replica 重新追上后,source 会自动恢复半同步;退化期间确认的写入却没有任何保护。所以要监控 Rpl_semi_sync_source_status,并把「半同步退化」当作和「replica 断开」同级的告警。半同步不是共识协议,它缩小了丢数据的窗口,但没有消除它。
四、切换流程:每一步都留下证据
判定故障。 实验中从 replica1 每秒探测一次 source,连续 3 次失败才判定,耗时约 6.5 秒,占了整个切换时间的大部分。判定太快会把短暂抖动当故障,太慢会拉长不可用时间,这是需要按业务权衡的参数。
隔离旧 source。 确认它不会再接受写入(进程已停、网络已隔离或已设为只读)。旧 source 如果还能写,切换后就会有两个 source。
比较候选。 对每个 replica 记录已接收集合、已执行集合,以及两者之差:
sqlSELECT c.RECEIVED_TRANSACTION_SET, @@global.gtid_executed, GTID_SUBTRACT(c.RECEIVED_TRANSACTION_SET, @@global.gtid_executed) AS received_not_applied FROM performance_schema.replication_connection_status c;选择收到事务最多的 replica,而不是已执行最多的。还要检查候选上有没有 source 上不存在的事务(errant transaction),见 MySQL 复制与延迟。
应用完 relay log。 停止接收,启动 SQL 线程,等已接收的集合全部执行:
sqlSTOP REPLICA IO_THREAD; START REPLICA SQL_THREAD; SELECT WAIT_FOR_EXECUTED_GTID_SET('<RECEIVED_TRANSACTION_SET>', 120);提升。
STOP REPLICA; RESET REPLICA ALL;,关闭super_read_only与read_only。其他 replica 改指向新 source。 使用
SOURCE_AUTO_POSITION=1,按 GTID 补齐。切换路由。 连接池、代理或服务发现把写流量指向新 source。
业务校验。 用客户端确认记录或业务对账数据统计缺失、重复,写入切换报告。
实测三个场景的阶段耗时:
| 场景 | 故障 → 判定 | 判定 → 提升完成 | 提升 → 路由切换 | 故障 → 第一笔新写入 |
|---|---|---|---|---|
| 异步分区 | 6.6 s | 0.6 s | 0.2 s | 8.6 s |
| 半同步,暂停应用 | 6.4 s | 2.3 s | 0.2 s | 9.8 s |
| 半同步超时 | 6.6 s | 2.1 s | 0.2 s | 9.8 s |
后两个场景的「提升」都多用了 1.5 秒左右,就是在应用 relay log 里积压的事务。这一步不能为了缩短恢复时间而省略。
自动化工具(Orchestrator、MySQL Router 配合 InnoDB Cluster、云厂商的高可用服务)把这些步骤自动化了,但每一步的判断依据相同,也应该输出相同的证据。
五、应用为什么仍然需要幂等
source 在提交后、客户端收到响应前崩溃,或者网络在这一刻断开,客户端只会看到一个异常。它无法区分「事务没执行」和「执行了,但响应丢了」。
实测:两个 replica 都不可达时,半同步让 source 的提交等待 2 秒;客户端设置了 1 秒的读超时,于是两笔写入都在客户端报了超时,而事务在服务端随后提交成功。4 秒后按同一个请求号各重试一次:
| 表 | 重试 | 结果 |
|---|---|---|
| 没有唯一键 | 普通 INSERT | 同一请求出现 2 行 |
request_id 唯一 | INSERT … ON DUPLICATE KEY UPDATE request_id = request_id | 影响行数 0(已存在),仍是 1 行 |
所以写接口要带唯一的请求号,并由数据库的唯一约束兜底;重试时根据「已存在」判断为成功,而不是再做一遍。三个切换场景里,崩溃瞬间正在执行的那一笔都按同一个请求号重试,新 source 上没有出现重复。
使用 Connector/J 时有一个细节:ON DUPLICATE KEY UPDATE 命中已有行且没有实际修改时,MySQL 返回的影响行数是 0;但驱动默认报告的是匹配行数,会返回 1。要用影响行数区分「新插入」和「已存在」,需要连接参数 useAffectedRows=true。
六、读写分离的四种读策略
复制正常时,写完立刻去 replica 读,能读到吗?实测每种策略 300 次(延迟 1 秒的 replica 为 30 次):
| 策略 | 做法 | 读不到的次数 | 读取耗时(中位数) |
|---|---|---|---|
| 直接读 replica | 写完立即读 replica1 | 300 / 300 | 0.25 ms |
| 直接读延迟 1 秒的 replica | 写完立即读 replica2 | 30 / 30 | 0.13 ms |
| 会话粘滞 | 写后一段时间内读 source | 0 | 0.19 ms |
| 等待写入水位 | 在 replica 上等这笔写入的 GTID,再读 | 0 | 0.79 ms(replica1)、2.0 s(replica2) |
复制正常时的延迟通常是毫秒级,但「写完立刻读」发生在微秒到亚毫秒之后,几乎一定比复制快。
四种策略按业务契约选择:
- 接受最终一致:列表页、统计数字,直接读 replica;
- 会话粘滞:用户刚修改过的数据,在一段时间内从 source 读;
- 等待写入水位:客户端从会话状态拿到自己写入的 GTID(
session_track_gtids=OWN_GTID),在 replica 上执行WAIT_FOR_EXECUTED_GTID_SET(gtid, 超时)后再读,读不到就降级读 source。代价是等待时间取决于 replica 的实际延迟; - 关键读走 source:余额、库存、支付状态这类用于决策的读取。
实验里配置了 SOURCE_DELAY = 1 的 replica2,GTID 等待的中位数约 2 秒,而不是 1 秒:延迟复制按 source 上的提交时间(秒级精度)计算,实际等待会比配置值更长。不要把等待时长写死成固定毫秒数。
七、旧 source 不能直接接回
场景一切换完成后,重启旧 source 并检查它的 GTID 集合:
old_is_subset_of_new 0
old_only_transactions …:125-261它比新 source 多出 137 个事务,正好是丢失的那 137 笔写入。如果把它直接配置成新 source 的 replica,实测复制在应用新 source 的第一个事务时就报错停止(Duplicate entry:两边为不同的订单分配了相同的自增主键),两边的 orders 分别是 251 行和 208 行,已经分叉。在更不走运的情况下,冲突不会报错,分叉就会被静默带进后续数据。
正确的处理是:
- 用
GTID_SUBSET检查旧 source 的集合是否包含于新 source; - 不包含时,导出多出来的事务交给业务判断(补录还是放弃),不要直接接回;
- 按新 source 重建这个节点(全量克隆或从备份恢复),再作为 replica 加入。
八、经典复制与 InnoDB Cluster
| 经典异步 / 半同步复制 | InnoDB Cluster | |
|---|---|---|
| 复制协议 | source 推送 binlog,replica 独立应用 | Group Replication,基于组通信的多数派确认 |
| 故障判定与提升 | 外部工具或运维流程 | 组内自动选主,需要至少 3 个成员 |
| 路由 | 应用或代理自行切换 | MySQL Router 感知主节点变化 |
| 已确认写入 | 取决于复制方式和超时,见第三节 | 多数派确认后才提交,少数派分区不能写 |
本文的实验只覆盖经典复制。InnoDB Cluster 需要至少 3 个成员加 Router 单独验证,不能用单节点或两节点模拟多数派。
九、常见误区
- 「切换成功,就没有丢数据」:实测两次成功的切换分别丢了 137 和 255 笔已确认写入。
- 「半同步等于强一致」:它只等 replica 收到事务;超时后还会退化为异步。
- 「半同步 ACK 过的 replica 可以直接提升」:必须先应用完 relay log,实测不这样做会缺 277 笔。
- 「超时了就重试一次」:没有唯一请求号时,实测重试写出了第 2 行。
- 「复制很快,写完读 replica 没问题」:实测复制正常时,写后立即读 300 次全部读不到。
- 「旧 source 修好了直接接回去」:它带着丢失的那批事务,两边已经分叉。
小结
故障切换要同时回答四个问题:多久恢复写入、已确认的写入还在不在、重试会不会重复、写完能不能读到。异步复制接受丢失窗口,半同步缩小窗口但会退化;提升前先应用完 relay log,切换后用确认记录核对缺失与重复;应用侧用唯一请求号保证幂等,按业务选择读策略;旧 source 按新 source 重建后再加入。这些步骤都应该在演练中留下证据,而不是只看服务有没有恢复。
配套实验
- codesphere-labs/storage/mysql-failover-read-consistency:异步分区后崩溃、半同步与暂停的 applier、半同步超时退化、重试歧义、写后读四种策略与旧 source 接回,保留客户端确认序列与切换时间线(验证记录)
参考资料
- MySQL 8.4:Semisynchronous Replication
- MySQL 8.4:Replication Source Options and Variables(rpl_semi_sync_source_timeout 等)
- MySQL 8.4:Replication with Global Transaction Identifiers
- MySQL 8.4:GTID Functions(GTID_SUBSET、WAIT_FOR_EXECUTED_GTID_SET)
- MySQL 8.4:Server Tracking of Client Session State(session_track_gtids)
- MySQL Connector/J:Connection Properties(useAffectedRows)
- MySQL Shell 8.4:InnoDB Cluster Requirements