Skip to content

MySQL 复制与延迟:一笔事务怎样抵达副本 ​

客户端在 source 上收到 COMMIT 成功,只说明事务在 source 上提交了。它还要被发送、写进 relay log、被调度、在 replica 上提交,任何一段积压,都会让「副本在线」和「副本数据新鲜」变成两回事。

先看一个实测现场。1 个 source、2 个 replica,source 每 50ms 写一条带序号的心跳。把 replica2 从复制网络上断开,客户端和监控仍能正常连接它:

断开后IO 线程状态Seconds_Behind_Sourcereplica2 落后的心跳
1 秒ON018 条
7 秒ON0135 条
14 秒ON0266 条
约 15 秒后CONNECTINGNULL继续增加

前 15 秒里,只看线程状态和 Seconds_Behind_Source,这个副本是「健康、零延迟」的;按业务序号看,它最多落后 281 条心跳,也就是约 14 秒。这篇文章把复制链路拆成四个位置,说明延迟产生在哪里、为什么常用指标会误判,以及并行回放能解决什么、不能解决什么。

本文以 MySQL 8.4 LTS 的异步复制为准,使用 source/replica 术语。

一、先说结论 ​

  • 复制链路有四个位置:source 已提交、replica 已接收(relay log)、正在应用、已应用。客户端在第一个位置就收到了成功,读 replica 的人要等到第四个位置。
  • Seconds_Behind_Source 只是一个观测值。 复制链路静默中断时它会持续显示 0;大事务应用完之后它还会短暂显示几秒。判断数据新不新,要看业务水位或 GTID。
  • 大事务会堵住后面所有事务。 实测 source 上执行 2.3 秒的百万行更新,提交后 replica 要再花约 3.6 秒应用;这期间后续心跳都已经收到,但一条也没有应用。
  • 并行回放只对互不相关的事务有效。 实测积压 2 万个单行事务,更新独立行时 4 个 worker 把追平时间从 11.9 秒降到 6.2 秒;全部更新同一行时,4 个 worker 的耗时与 1 个相同。
  • replica 自身的负载会拖慢回放。 同样的积压,replica 上同时跑两个全表查询时,追平时间从 6.4 秒变成 16.7 秒。

二、先把复制拆成四个位置 ​

sourcereplica提交 · 写 binlog客户端收到成功binlog dump 线程按 GTID 发送receiver(IO)线程写入 relay logrelay log已接收、尚未应用coordinator按依赖分发worker 提交写 replica 的 binlog1234① gtid_executed(source)② RECEIVED_TRANSACTION_SET③ APPLYING_TRANSACTION(worker)④ gtid_executed(replica)Performance Schema:replication_connection_status、replication_applier_status_by_worker
图 1 · 一笔事务要依次经过四个位置;客户端在第 1 个位置就收到了成功,读 replica 的人要等它走到第 4 个位置
位置数据在哪里怎么观察进度中断后从哪里继续
① source 已提交source 的 binlogsource 的 gtid_executed—
② replica 已接收replica 的 relay logreplication_connection_status.RECEIVED_TRANSACTION_SET按 GTID 自动定位,只拉缺少的事务
③ 正在应用coordinator 分发给 workerreplication_applier_status_by_worker.APPLYING_TRANSACTION 及其提交时间戳relay log 中尚未应用的部分
④ replica 已应用replica 的数据文件与自己的 binlogreplica 的 gtid_executed—

四个位置分别由不同的线程推进:source 上的 binlog dump 线程负责发送,replica 上的 receiver(也叫 IO 线程)负责接收并写 relay log,coordinator 按事务之间的依赖把事务分给 worker,worker 执行并提交。

这张表同时回答了三类问题:

  • 数据丢了吗? 看 ② 是否包含 source 已确认的事务,这是故障切换关心的,见 MySQL 故障切换;
  • 数据新不新? 看 ④ 与 ① 的差距,这是读 replica 的业务关心的;
  • 卡在哪一段? ① 与 ② 差得多是网络或发送端问题,② 与 ④ 差得多是回放问题。

用 GTID 集合可以直接算出「已接收但尚未应用」的事务:

sql
-- 在 replica 上执行
SELECT GTID_SUBTRACT(c.RECEIVED_TRANSACTION_SET, @@global.gtid_executed) AS received_not_applied
FROM performance_schema.replication_connection_status c;

三、binlog 与 GTID ​

3.1 binlog 记录的是什么 ​

MySQL 8.4 默认 binlog_format=ROW、binlog_row_image=FULL:binlog 记录的是每一行修改前后的完整内容,而不是 SQL 文本。所以 replica 回放时不需要重新执行查询条件,结果也不受执行计划影响;代价是修改的行越多、行越宽,binlog 就越大。一次更新 100 万行,就是 100 万行的前后镜像。

binlog 与 redo log 的职责不同:redo 负责本机崩溃恢复,binlog 负责复制和按时间点恢复。两阶段提交保证的是同一台机器上 redo 与 binlog 对一笔事务给出同一个结论,见 Redo Log 与 Undo Log;它不保证这笔事务已经到达任何 replica。

3.2 GTID 表达的是「执行过哪些事务」 ​

GTID 由 server_uuid:序号 组成,gtid_executed 是一个集合,例如 2ab5…:1-121604。复制使用 SOURCE_AUTO_POSITION=1 时,replica 把自己已有的集合告诉 source,source 只发送缺少的事务。相比文件名加偏移量,集合比较在重连、换 source、故障切换时都更可靠。

两个容易踩的地方,实验中都遇到了:

  • 8.4 默认没有开启 GTID。 gtid_mode 的默认值仍是 OFF,需要显式设置 gtid_mode=ON 与 enforce_gtid_consistency=ON。
  • replica 上的本地写入会成为 errant transaction。 实验容器初始化时(创建账户等)在每个 replica 上留下了自己 server_uuid 的 1-5 五个事务,source 上并不存在。平时这不影响复制;一旦把这个 replica 提升为新 source,其他节点会向它要这些事务,或者它们在某次重建中丢失。建立复制之前,要在全新的 replica 上执行 RESET BINARY LOGS AND GTIDS,并保持 super_read_only=ON。

四、延迟产生在哪里 ​

按链路分段,每一类都有对应的观测:

原因表现观测
复制网络中断或变慢① 与 ② 之间积压RECEIVED_TRANSACTION_SET 不再增长;IO 状态可能仍是 ON
单个大事务② 已收到后续事务,④ 停在大事务之前received_not_applied 持续增加,worker 的 APPLYING_TRANSACTION 长时间不变
热点行等强依赖并行回放退化为串行事务集中在一个 worker 上
replica 自身资源紧张回放整体变慢replica 的 CPU、I/O、Buffer Pool 命中率,查询负载
DDL、锁等待回放卡在某个事务上worker 状态、data_lock_waits、metadata_locks

4.1 大事务:后面的事务全被堵住 ​

在 source 上一次更新 100 万行(执行 2.3 秒),期间心跳照常每 50ms 写一条。replica1 的采样:

source 提交后心跳落后已接收未应用正在应用的事务距 source 提交Seconds_Behind_Source
0.3 秒6 条7 个0.3 秒0
1.3 秒26 条27 个1.3 秒1
3.6 秒70 条71 个3.6 秒4
3.8 秒00—4

三个现象:

  • 大事务提交之后,replica 才开始应用它。 source 上执行的 2.3 秒,和 replica 上应用的约 3.6 秒是先后发生的,不会重叠。
  • 后面的小事务都已经收到,就是不能先应用。 replica_preserve_commit_order=ON(默认)要求 replica 按 source 的提交顺序提交,心跳必须排在大事务后面。
  • Seconds_Behind_Source 与业务延迟对不上。 大事务应用了 3 秒多,它才慢慢涨到 4;追平之后它仍显示 4。它由当前应用事务的时间戳推算,并不是业务可见的延迟。

大事务的治理办法是拆小,见 千万级大表怎么清理数据 的分批删除;百万行导入也应分批提交,见 百万行数据导入。

4.2 复制链路静默中断 ​

开头的现场就是这一类。断开网络不会立刻让 TCP 连接报错,replica 要等 replica_net_timeout 秒收不到任何数据(包括 source 的心跳)才判定断线。实验把它设成 15 秒,默认是 60 秒。

复制网络连通断开 30 秒恢复IO 状态 · SBSON · 0ON · 0CONNECTING · NULLON · 追平业务序号落后00 → 281 条继续增加0断开约 15 秒后判定断线只看线程状态和 SBS 会误判为健康replica_net_timeout = 15 秒(默认 60);心跳每 50ms 一条
图 2 · 复制网络断开后的约 15 秒里,IO 线程仍显示 ON、Seconds_Behind_Source 仍是 0,业务序号却已经落后 281 条心跳;直到超过 replica_net_timeout 才被发现

判定断线后,receiver 按 SOURCE_CONNECT_RETRY 重连,默认间隔 60 秒。第一次实验沿用了默认值,网络恢复后近一分钟都没有重连,监控上的延迟一直是 NULL。生产上这两个参数要和告警阈值一起设计,不能只依赖默认值。

五、并行回放能解决什么 ​

replica 的 coordinator 读取 relay log,按事务之间的依赖关系把可以并行的事务交给不同 worker。MySQL 8.4 中:

  • replica_parallel_workers 默认为 4;
  • replica_preserve_commit_order 默认为 ON,并行执行、按序提交;
  • 依赖信息由 source 在写 binlog 时生成。8.4 已经移除了 binlog_transaction_dependency_tracking 变量,source 总是按 writeset(事务修改了哪些行)判断依赖,效果相当于旧版本设置为 WRITESET。

所以「能不能并行」取决于事务改的是不是同一批行,而不是 worker 数量。实测先停掉 replica1 的 SQL 线程,在 source 上用 16 个客户端提交 2 万个单行 UPDATE,再启动 SQL 线程计时直到追平:

独立行 · 1 个 worker11.9 s独立行 · 4 个 worker各 worker 约 5,000 个6.2 s同一行 · 1 个 worker10.7 s同一行 · 4 个 worker分配 19,998 / 4 / 1 / 110.9 s停止 SQL 线程积压事务后再启动,用 WAIT_FOR_EXECUTED_GTID_SET 计时
图 3 · 同样积压 2 万个单行事务:更新互不相关的行时,4 个 worker 把追平时间缩短近一半;全部更新同一行时事务之间有依赖,几乎都落在同一个 worker 上,增加 worker 没有收益
负载worker 数追平耗时每个 worker 执行的事务数
更新 1 万个独立账户111.9 s20,000
更新 1 万个独立账户46.2 s5,084 / 5,043 / 4,972 / 4,905
全部更新同一个账户110.7 s20,000
全部更新同一个账户410.9 s19,998 / 4 / 1 / 1

热点行的 2 万个事务前后都有依赖,coordinator 只能一个接一个地交给同一个 worker。source 上它们本来也是被行锁串行化的,热点行的问题在 replica 上被原样复制了一遍,解法见 表设计里的三个细节 的热点行一节。

replica 上的查询负载也会拖慢回放。 同样积压 2 万个独立事务,replica1 上同时跑两个全表扫描查询时,追平时间从 6.4 秒变成 16.7 秒。报表、导出这类重查询如果放在承担读流量的 replica 上,会直接变成复制延迟。

六、怎样量化延迟 ​

SHOW REPLICA STATUS 中的 Seconds_Behind_Source 简单直观,但前面已经看到它的两个盲区:链路静默中断时显示 0,大事务期间与业务可见性不一致。官方文档也指出,它不适合比传统一主多从更复杂的拓扑。

更可靠的做法是组合使用:

观测回答的问题来源
业务心跳表的最大序号业务读 replica 能看到多新的数据source 定期写入 (seq, written_at),replica 上读 MAX(seq)
已接收与已执行的 GTID 集合卡在接收还是应用replication_connection_status 与 gtid_executed
事务的原始提交时间戳正在应用的事务在 source 上是多久前提交的replication_applier_status_by_worker 的 APPLYING_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP、LAST_APPLIED_TRANSACTION_*
receiver 与 worker 状态线程是否在工作、有没有报错SERVICE_STATE、LAST_ERROR_*
sql
-- 正在应用的事务距离它在 source 上提交过了多久(毫秒)
SELECT MAX(TIMESTAMPDIFF(MICROSECOND, APPLYING_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP, UTC_TIMESTAMP(6))) / 1000 AS applying_age_ms
FROM performance_schema.replication_applier_status_by_worker
WHERE APPLYING_TRANSACTION <> '';

心跳表的时间比较依赖 source 与 replica 的时钟同步,用序号差更稳妥:「落后多少条心跳」乘以写入间隔,就是业务上的延迟上限。开源工具 pt-heartbeat 就是这个思路。

七、延迟治理的顺序 ​

  1. 先定位是哪一段。 ① 与 ② 差距大,查网络、发送端和 replica_net_timeout;② 与 ④ 差距大,看正在应用的是什么事务。
  2. 大事务拆小。 批量删除、导入、回填都分批提交,单个事务控制在秒级以内。
  3. 消除热点依赖。 同一行的高频更新在 replica 上无法并行,需要在写入侧拆分或合并。
  4. 隔离 replica 上的查询负载。 重查询放到专门的 replica 或分析库,见 OLTP 与 OLAP。
  5. 再考虑并行度和硬件。 独立事务积压时,增加 replica_parallel_workers 和提升 I/O 能力才有效。
  6. 监控业务水位,而不只是线程状态。 告警条件同时包含心跳落后和接收集合不再增长。

八、常见误区 ​

  • 「IO 和 SQL 线程都是 Yes,复制就是正常的」:实测链路静默中断后约 15 秒内,线程状态一直正常,业务已经落后 281 条心跳。
  • 「Seconds_Behind_Source 为 0 就没有延迟」:链路中断未被发现时它就是 0。
  • 「延迟大就多加几个 worker」:热点行负载下 4 个 worker 与 1 个耗时相同,事务几乎全部落在一个 worker 上。
  • 「半同步复制之后 replica 就是最新的」:半同步只等 replica 收到事务,不等它应用,见 MySQL 故障切换。
  • 「replica 可以当备份用」:误删会被同样复制过去,见 MySQL 误删恢复。

小结 ​

复制延迟要按位置来看:事务是否已经到达 replica(接收)、是否已经对读可见(应用),两段的原因和手段都不同。监控时以业务心跳和 GTID 集合为准,把 Seconds_Behind_Source 当作参考;治理时先拆大事务、消除热点依赖、隔离 replica 上的查询,再谈并行度。副本「在线」只说明线程在运行,副本「新鲜」要用数据证明。


配套实验

参考资料

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