Skip to content

MySQL 故障切换:服务恢复以后,数据真的连续吗 ​

高可用至少包含三件事:发现故障、选出可以提升的 replica、让应用连到新 source。三件事都做成了,仍可能有已经确认的写入不见了、超时重试写了两遍、刚写完去 replica 读不到。这些要分开验证。

先看三次实测切换的结果。客户端每 20ms 写一笔带唯一 request_id 的订单,记下每一笔收到的成功确认;source 被 SIGKILL 后,按同一套流程提升 replica1,再拿确认记录和新 source 上的数据逐笔核对:

场景已确认写入新 source 上缺失重复故障到第一笔新写入
异步复制,replica 先与 source 网络分区,3 秒后 source 崩溃34513708.6 s
半同步复制,replica1 继续接收但暂停应用,4 秒后 source 崩溃391009.8 s
半同步复制,两个 replica 都不可达,等待超时后 source 崩溃45725509.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 收到写入 relay log③ replica 应用gtid_executed④ 读 replica 可见异步:客户端在 ① 之后收到成功崩溃时这一段已确认的写入会丢半同步(AFTER_SYNC):等 ② 的 ACK 才返回ACK 不等于已应用:必须先把 relay log 应用完再提升半同步等待超过 rpl_semi_sync_source_timeout 后退化为异步,保障随之消失
图 1 · 异步复制在第 1 步就确认,source 崩溃时第 1、2 步之间的事务会丢;半同步把确认推迟到 replica 写入 relay log,但不等 replica 应用,提升前必须先应用完 relay log

异步复制: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 断开」同级的告警。半同步不是共识协议,它缩小了丢数据的窗口,但没有消除它。

四、切换流程:每一步都留下证据 ​

判定故障连续 3 次探测失败比较候选已接收 / 已执行应用 relay logWAIT_FOR_EXECUTED提升关闭只读其他 replica 改指向AUTO_POSITION切换路由写入新 source业务校验缺失 · 重复 · 校验和旧 source:先隔离,恢复后不能直接接回多出的事务要比对后丢弃,节点按新 source 重建实测(异步场景):判定 6.6 s · 应用与提升 0.6 s · 路由 0.2 s · 故障到第一笔新写入 8.6 s
图 2 · 切换不是一个动作:判定、比较候选、应用 relay log、提升、改指向、切路由,每一步都要留下证据;实测从故障到第一笔新写入约 8.6—9.8 秒,其中约 6.5 秒花在判定上
  1. 判定故障。 实验中从 replica1 每秒探测一次 source,连续 3 次失败才判定,耗时约 6.5 秒,占了整个切换时间的大部分。判定太快会把短暂抖动当故障,太慢会拉长不可用时间,这是需要按业务权衡的参数。

  2. 隔离旧 source。 确认它不会再接受写入(进程已停、网络已隔离或已设为只读)。旧 source 如果还能写,切换后就会有两个 source。

  3. 比较候选。 对每个 replica 记录已接收集合、已执行集合,以及两者之差:

    sql
    SELECT 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 复制与延迟。

  4. 应用完 relay log。 停止接收,启动 SQL 线程,等已接收的集合全部执行:

    sql
    STOP REPLICA IO_THREAD;
    START REPLICA SQL_THREAD;
    SELECT WAIT_FOR_EXECUTED_GTID_SET('<RECEIVED_TRANSACTION_SET>', 120);
  5. 提升。 STOP REPLICA; RESET REPLICA ALL;,关闭 super_read_only 与 read_only。

  6. 其他 replica 改指向新 source。 使用 SOURCE_AUTO_POSITION=1,按 GTID 补齐。

  7. 切换路由。 连接池、代理或服务发现把写流量指向新 source。

  8. 业务校验。 用客户端确认记录或业务对账数据统计缺失、重复,写入切换报告。

实测三个场景的阶段耗时:

场景故障 → 判定判定 → 提升完成提升 → 路由切换故障 → 第一笔新写入
异步分区6.6 s0.6 s0.2 s8.6 s
半同步,暂停应用6.4 s2.3 s0.2 s9.8 s
半同步超时6.6 s2.1 s0.2 s9.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写完立即读 replica1300 / 3000.25 ms
直接读延迟 1 秒的 replica写完立即读 replica230 / 300.13 ms
会话粘滞写后一段时间内读 source00.19 ms
等待写入水位在 replica 上等这笔写入的 GTID,再读00.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 集合:

text
old_is_subset_of_new   0
old_only_transactions  …:125-261

它比新 source 多出 137 个事务,正好是丢失的那 137 笔写入。如果把它直接配置成新 source 的 replica,实测复制在应用新 source 的第一个事务时就报错停止(Duplicate entry:两边为不同的订单分配了相同的自增主键),两边的 orders 分别是 251 行和 208 行,已经分叉。在更不走运的情况下,冲突不会报错,分叉就会被静默带进后续数据。

正确的处理是:

  1. 用 GTID_SUBSET 检查旧 source 的集合是否包含于新 source;
  2. 不包含时,导出多出来的事务交给业务判断(补录还是放弃),不要直接接回;
  3. 按新 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 重建后再加入。这些步骤都应该在演练中留下证据,而不是只看服务有没有恢复。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权