MySQL 复制与延迟:一笔事务怎样抵达副本
客户端在 source 上收到
COMMIT成功,只说明事务在 source 上提交了。它还要被发送、写进 relay log、被调度、在 replica 上提交,任何一段积压,都会让「副本在线」和「副本数据新鲜」变成两回事。
先看一个实测现场。1 个 source、2 个 replica,source 每 50ms 写一条带序号的心跳。把 replica2 从复制网络上断开,客户端和监控仍能正常连接它:
| 断开后 | IO 线程状态 | Seconds_Behind_Source | replica2 落后的心跳 |
|---|---|---|---|
| 1 秒 | ON | 0 | 18 条 |
| 7 秒 | ON | 0 | 135 条 |
| 14 秒 | ON | 0 | 266 条 |
| 约 15 秒后 | CONNECTING | NULL | 继续增加 |
前 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 秒。
二、先把复制拆成四个位置
| 位置 | 数据在哪里 | 怎么观察进度 | 中断后从哪里继续 |
|---|---|---|---|
| ① source 已提交 | source 的 binlog | source 的 gtid_executed | — |
| ② replica 已接收 | replica 的 relay log | replication_connection_status.RECEIVED_TRANSACTION_SET | 按 GTID 自动定位,只拉缺少的事务 |
| ③ 正在应用 | coordinator 分发给 worker | replication_applier_status_by_worker.APPLYING_TRANSACTION 及其提交时间戳 | relay log 中尚未应用的部分 |
| ④ replica 已应用 | replica 的数据文件与自己的 binlog | replica 的 gtid_executed | — |
四个位置分别由不同的线程推进:source 上的 binlog dump 线程负责发送,replica 上的 receiver(也叫 IO 线程)负责接收并写 relay log,coordinator 按事务之间的依赖把事务分给 worker,worker 执行并提交。
这张表同时回答了三类问题:
- 数据丢了吗? 看 ② 是否包含 source 已确认的事务,这是故障切换关心的,见 MySQL 故障切换;
- 数据新不新? 看 ④ 与 ① 的差距,这是读 replica 的业务关心的;
- 卡在哪一段? ① 与 ② 差得多是网络或发送端问题,② 与 ④ 差得多是回放问题。
用 GTID 集合可以直接算出「已接收但尚未应用」的事务:
-- 在 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 秒 | 0 | 0 | — | 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 秒。
判定断线后,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 线程计时直到追平:
| 负载 | worker 数 | 追平耗时 | 每个 worker 执行的事务数 |
|---|---|---|---|
| 更新 1 万个独立账户 | 1 | 11.9 s | 20,000 |
| 更新 1 万个独立账户 | 4 | 6.2 s | 5,084 / 5,043 / 4,972 / 4,905 |
| 全部更新同一个账户 | 1 | 10.7 s | 20,000 |
| 全部更新同一个账户 | 4 | 10.9 s | 19,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_* |
-- 正在应用的事务距离它在 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 就是这个思路。
七、延迟治理的顺序
- 先定位是哪一段。 ① 与 ② 差距大,查网络、发送端和
replica_net_timeout;② 与 ④ 差距大,看正在应用的是什么事务。 - 大事务拆小。 批量删除、导入、回填都分批提交,单个事务控制在秒级以内。
- 消除热点依赖。 同一行的高频更新在 replica 上无法并行,需要在写入侧拆分或合并。
- 隔离 replica 上的查询负载。 重查询放到专门的 replica 或分析库,见 OLTP 与 OLAP。
- 再考虑并行度和硬件。 独立事务积压时,增加
replica_parallel_workers和提升 I/O 能力才有效。 - 监控业务水位,而不只是线程状态。 告警条件同时包含心跳落后和接收集合不再增长。
八、常见误区
- 「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 上的查询,再谈并行度。副本「在线」只说明线程在运行,副本「新鲜」要用数据证明。
配套实验
- codesphere-labs/storage/mysql-replication-lag:1 source + 2 replicas 的稳态、大事务、并行回放对照、replica 查询负载与链路静默中断,保留每 250ms 的采样时间线(验证记录)
参考资料