编程 MySQL 开了并行复制仍延迟:40 万行单事务让 4 个 worker 只剩 1 个在忙

2026-10-21 20:04:48

MySQL 开了并行复制仍延迟:40 万行单事务让 4 个 worker 只剩 1 个在忙

replica_parallel_workers 已经设置,replica_parallel_type 是 LOGICAL_CLOCK,副本仍然落后几秒到几分钟。原因是 MySQL 把一个完整事务交给一个 applier worker,绝不会把同一个事务拆给多个 worker;单个大事务会让其余 worker 空闲。另一种情况是表没有唯一键:源端没有键值可比较,依赖信息退回提交顺序,连续提交的事务看起来互相依赖。先读 per-worker 表里的 APPLYING_TRANSACTION,区分是哪一种。

问题在哪

一个 worker 应用一整个事务,不能只应用一半,两个 worker 也不能共享一个事务,因为事务必须在副本上像在源端一样原子提交。因此,追赶过程中如果包含一个最大事务,该事务的实际应用耗时就是一段串行段,增加 worker 数量不会缩短这段串行段。

这并不限制整条流的并行度:这个事务前后的事务仍然可以并行应用。提高 worker 数,在流里存在独立事务时有效,在事务互相依赖时无效。per-worker 表能显示当前属于哪种情况。

实测现场

MySQL 8.4.11,源端与副本容器在同一台主机上,2026-09-20。副本保持出厂默认:replica_parallel_workers=4、replica_parallel_type=LOGICAL_CLOCK、replica_preserve_commit_order=ON,三个值都从运行中的 server 读回。源端一条语句插入 40 万行,单个事务。

应用期间每两秒执行一次 SHOW REPLICA STATUS,Seconds_Behind_Source 序列为:

0 0 0 7 9 11 0 0

事务到达副本前三次采样为 0,应用过程中升高,提交后第一次采样回到 0,中间没有部分进度。文档对此的说明是:副本当前没有处理事件时值为 0。事务只有在副本提交后才可见,因此该字段在 worker 执行到一半时没有部分进度可报。

同一窗口、同一台服务器、同一天,每两秒采样四个 worker:

  • worker 1:LAST_APPLIED_TRANSACTION 先为 ...:46,后为 ...:47;APPLYING_TRANSACTION 为 ...:47,首次尝试时间戳 18:11:18.299512;SERVICE_STATE 为 ON
  • worker 2:两列皆空,SERVICE_STATE 为 ON
  • worker 3:两列皆空,SERVICE_STATE 为 ON
  • worker 4:两列皆空,SERVICE_STATE 为 ON

worker 1 从 18:11:18.299512 一直持有这个 40 万行事务,直到 18:11:22.716 与 18:11:24.905 两次采样之间结束。其他三个 worker 在任何采样里两列都为空。

第二列能识别空闲 worker,第一列不能。LAST_APPLIED_TRANSACTION 是“该 worker 最后应用的事务 GTID”,值不动只说明两次采样之间没有新事务完成;worker 正在执行长事务时看起来一样。APPLYING_TRANSACTION 是“该 worker 当前正在应用的事务 GTID”,为空表示 worker 手上没有事务。

SERVICE_STATE 也不能说明问题。手册把该表上的 ON 定义为“线程存在,处于 active 或 idle”,四个 worker 都是 ON 只说明四个线程已经启动。健康检查停在这一列,会看到四个“健康”worker,不管它们在做什么。

怎么在副本上确认

先看状态:

SHOW REPLICA STATUS\G

然后查 per-worker 表,它解释延迟来源。隔几秒采样两次:

SELECT WORKER_ID, SERVICE_STATE, LAST_APPLIED_TRANSACTION, APPLYING_TRANSACTION
FROM performance_schema.replication_applier_status_by_worker
ORDER BY WORKER_ID;

先读 APPLYING_TRANSACTION。这一列区分忙碌 worker 和空闲 worker:

采样现象含义下一步查什么
一个 worker 连续多个采样持有 GTID,其余为空一个长事务正在应用,无法拆分该 GTID 对应事务在源端的大小
所有 worker 都为空,延迟继续增长applier 不是瓶颈,查 receiverSHOW REPLICA STATUS 的 receiver 状态与 relay log 积压
worker 持有 GTID,但同一时刻总只有一个流里没有副本可利用的并行度被写入的表是否有唯一键
每个 worker 同时都持有 GTID,延迟仍增长副本确实追不上磁盘,以及是否有行事件在扫表
worker 成批持有 GTID,中间有间隙可能是 replica_preserve_commit_order 让已完成 worker 等待,也可能是源端 group commit 形态或锁等待;单看这一项不能定性worker 行上的 LAST_ERROR_MESSAGE,再看锁等待

如果改读 LAST_APPLIED_TRANSACTION,前两行会混在一起:冻结的值和空值在该列看起来一样。

coordinator 也有自己的行,用来判断 applier 是没活可干,还是被堵住了:

SELECT SERVICE_STATE, PROCESSING_TRANSACTION, LAST_ERROR_MESSAGE
FROM performance_schema.replication_applier_status_by_coordinator;

加 worker 到底能买到多少

能测出差别,但对上面的 40 万行单事务没有帮助。仍是同一对 MySQL 8.4.11,2026-09-20:向空表插入 20000 条单行记录,每条插入自成一个事务,主键各不相同。源端构建完整 backlog 时先停掉副本 applier,然后启动并计时,直到执行完源端所有 GTID,因此源端不在计时区间内。运行顺序交替为 4、1、4、1、4、1 个 worker。

replica_parallel_workers应用同一份 20000 事务 backlog 的秒数
468.440、72.959、75.158
1139.572、198.640、139.279

4 worker 的每次运行都比 1 worker 的任意一次快超过一分钟。因此“加 worker 完全没用”对这条流不成立;但对上面那个 40 万行单事务仍然成立,四个 worker 里有三个在任何采样中都没有持有事务。两个事实来自同一台副本、同一天。决定结果的是事务形状,不是参数本身。

前提:表要有键

目标表1 worker4 workers
(id bigint PRIMARY KEY, pad char(100))139.572、198.640、139.27968.440、72.959、75.158、59.517、65.040
(id bigint, pad char(100))145.526、148.819145.645、143.317

没有主键时,四个 worker 没有带来收益:143.317 和 145.645 秒,对比单 worker 的 145.526 和 148.819 秒。无主键表写入本身并不慢:单 worker 下无主键运行与有主键中位数 139.572 秒相差不到十秒。缺少的是并行度。

这个过程可以直接观察到。6000 个事务的 backlog,每 50 ms 采样一次 replication_applier_status_by_worker,统计每次采样中 APPLYING_TRANSACTION 非空的 worker 数:

目标表采样数同时忙的 worker 数
有主键136131 次四个同时忙,1 次三个,1 次两个,3 次没有
无主键326310 次只有一个,16 次没有,从未超过一个

有主键那行给无主键那行提供了对照。

为什么并行度由源端决定

副本不负责判断哪些事务互相独立。源端把依赖信息写进 binary log,副本照着执行。

在 MySQL 8.4 上,这是基于 writeset 的,没有开关:“在 MySQL 8.4(及以后),使用多线程副本时,源端 mysqld 始终使用 writesets 为 binary log 生成依赖信息;这等同于旧版本把 binlog_transaction_dependency_tracking 设为 WRITESET。”

规则不是“不同表就能并行”,虽然很多人会留下这个印象。比较维度是行,由唯一键值标识。同一张表里不同键的两次写入可以一起执行;不同表上的两次写入仍可能被它们所在的事务挡住。

binlog_transaction_dependency_tracking 在 8.4 已不存在;在运行中的 8.4.11 上读该变量返回空。留下的相关变量是 binlog_transaction_dependency_history_size,该服务器上为 25000,也是文档默认值:“设置内存中保留的行哈希数量上限,这些哈希用于查找最后修改某行的事务。达到这个数量后,历史会被清除。”依赖比保留的历史更老时,源端找不到,只能给安全答案:少并行,而不是错误并行。

8.0 上变量还在,默认是慢的那个

在运行中的 8.0.46 上,binlog_transaction_dependency_tracking 读到 COMMIT_ORDER,与文档默认一致;另外两个值是 WRITESET 和 WRITESET_SESSION。同一份 backlog,20000 条单行插入、每条一个事务、写入有主键的表,8.0.46 源端与副本容器,2026-09-20,全程四个 worker,只改源端跟踪模式,交替运行:

binlog_transaction_dependency_tracking秒数
WRITESET79.059、110.464
COMMIT_ORDER192.307、208.029

真正有效的做法与代价

拆大事务。原本一条语句插入一百万行的回填,改成按主键区间循环,每批提交。副本于是有一百个可以调度的事务,而不是一个不能拆的事务。

这有代价,代价不在性能:一百万行拆成一百次提交后,不再是一个全有或全无的操作,可能停在半完成状态。因此操作必须可重入、可安全重复执行,半完成状态对读取方可见。按主键区间分批,而不是按 LIMIT 分批,重试才会落到同一批行上。如果操作无法容忍半完成,缩小批次也解决不了;要把工作放到不经过复制链路的路径上。

给每张表建主键。没有唯一键值可供比较时,源端退回提交顺序。这个代价在源端不可见,因为无论有没有键,源端都提交了相同的事务。

评估副本持久化。sync_binlog=1 和 innodb_flush_log_at_trx_commit=1 是安全默认值,代价是每次提交一次 fsync。如果副本只承担只读副本的角色,放宽这两项是合理取舍,但也是在决定崩溃时愿意丢多少。

保留 replica_preserve_commit_order,除非已经决定副本上的读取可以看到与源端不同的事务顺序。8.4 默认开启;为了追延迟关掉它,是用正确性换吞吐。

在 per-worker 表显示 worker 空闲、后面又有积压时,才提高 replica_parallel_workers。实测中位数 68.440 对 139.572 秒,但它对单个大事务和无主键表写入没有作用。这是人们最先改的参数;等 per-worker 表说明 worker 没活可干时再改。

AI 生成代码常造成这种形状

第一种是批量写入。要求回填一列或导入一份数据时,自然产物是一条语句做完所有事,因为这在源端正确、原子、高效。请求里不会提到副本,成本落在一台代码从未引用的机器上。第二种是为了安全,把迁移包在显式事务里。这在源端是好实践,却正好产生无法并行应用的形状。

告警与评审

把事务大小上限写进迁移评审。给一个数字,例如每个提交一万行,上限就可检查。

告警以 APPLYING_TRANSACTION 为依据,而不是只看延迟秒数。需要知道“读到的数据有多旧”时,用源端写心跳行的方式判断。评审要求每张表有主键,并给事务行数设上限。

来源:https://www.dbgorilla.com/learn/mysql/why-is-mysql-replication-lagging-with-parallel-replication-enabled/

复制全文 生成海报 MySQL 复制 运维 性能排查

推荐文章

程序员茄子在线接单