结论先行:你的三个怀疑全部成立,且可以合并成一句话——根因是「单一大事务」,SLEEP 对 undo 峰值、对主从延迟、对在线查询慢,三件事全都没帮上忙。把 400 批拆成 400 个独立事务 + 每批之间显式 COMMIT,undo 峰值预计从 12GB 降到 1–2GB 量级,主从延迟峰值预计从 40s 降到个位数秒,在线查询那条 200ms→1.2s 大概率也一并消失。下面逐条展开依据和可执行验证。
1) purge 与 SLEEP 的关系:你的理解完全正确
InnoDB 的 purge 线程回收 undo 的判据是「该 undo 记录对应的版本是否已经没有任何活跃事务需要读」,而判断「是否还有活跃事务」依赖 trx_purge_view,其可见性由 MIN_TRX_ID 决定——只要那个 800 万行的大事务还没 commit,MIN_TRX_ID 就被钉死在事务开始前的值,期间所有被它覆盖行的旧版本 undo 一条都不能回收。
所以:
- SLEEP 0.5s / 1s 对 undo 峰值没有帮助——它只是让应用线程睡一会儿,事务边界没变,purge 视角里「活跃事务」还是那一个。
- 拆批(400 → 800)能降 undo,本质是降单事务覆盖行数(每事务 2 万 → 1 万),不是 SLEEP 的功劳。
- 真正能压 undo 的是「每批独立事务 + COMMIT」。COMMIT 之后 MIN_TRX_ID 前进,purge 线程才能批量回收该事务产生的 undo。
验证命令(批处理跑的过程中,主库上同时跑):
-- 看长事务列表,确认是不是那一个事务钉着 MIN_TRX_ID
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
-- 看 purge 视图(8.0.19+ 有)
SHOW ENGINE INNODB STATUS\G -- 找 "Purge done for trx" 和 "History list length"
你会看到批处理期间 innodb_trx 里始终只有一个大事务在跑,age 持续增长——这就是 undo 涨到 12GB 的直接证据。
改造建议(最小改动):
-- 伪代码:应用侧批处理循环
for batch in batches: -- 400 批
BEGIN;
UPDATE orders SET status='archived' WHERE id IN (...2 万行...);
DELETE FROM orders WHERE id IN (...2 万行...);
COMMIT; -- 关键:显式 COMMIT,让 MIN_TRX_ID 前进
SLEEP(0.2); -- SLEEP 可以保留,但作用从「让 purge 跟得上」变成「让主从半同步 ACK 追上来」
end for
改造后预期:undo 峰值 ≈ 单批 2 万行的 undo 量(12GB / 400 ≈ 30MB,实际会略高),History list length 峰值从 800 万降到几千级别,purge 能跟上。
2) 从库 CPU 30% + 延迟 40s:更可能是「单事务串行回放」,不是锁冲突
判断依据:
- 行锁 / gap 锁冲突的特征是 apply 线程长时间阻塞在
lock_wait上,CPU 接近 0%(不是 30%),且performance_schema.events_waits_summary_global_by_event_name里wait/io/table/sql/handlock或wait/synch/mutex/innodb会显著上升。 - 单事务串行回放的特征是 CPU 有占用(30% 正好是「在干活但干得慢」),且
SHOW REPLICA STATUS里Seconds_Behind_Source持续增长但Read_Source_Log_Pos与Exec_Master_Log_Pos差值也持续增长——说明不是没在 apply,是 apply 速度跟不上。
为什么 LOGICAL_CLOCK + 8 workers 救不了你:
slave_parallel_type=LOGICAL_CLOCK 的并行度依赖「不同事务的 GTID 提交顺序」来分组并行。你的大事务在 binlog 里是一个完整的 BEGIN...COMMIT 单元,从库的 applier 拿到这个事务后必须整体串行回放(单事务内 InnoDB 的行级并行在 8.0 还没开放),8 个 worker 线程对这个事务一个都帮不上忙。这就是「CPU 30% 但延迟 40s」的完整解释——不是算力不够,是并行度被事务边界锁死在 1。
直接验证命令(从库上跑,批处理期间):
-- 1) 看 apply 线程在等什么
SELECT EVENT_NAME, COUNT_STAR, SUM_TIMER_WAIT/1000000000 AS total_ms
FROM performance_schema.events_waits_summary_global_by_event_name
WHERE EVENT_NAME LIKE 'wait/synch/%'
ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;
-- 2) 看 replica 状态,确认 Exec 落后 Read
SHOW REPLICA STATUS\G -- 关注 Read_Source_Log_Pos / Exec_Master_Log_Pos / Seconds_Behind_Source
-- 3) 看 applier 线程状态
SELECT thread_id, state, info
FROM information_schema.processlist
WHERE command = 'Slave SQL' OR user = 'system user';
如果 events_waits 里 lock_wait 占比很低、Exec_Master_Log_Pos 滞后 Read_Source_Log_Pos 持续增长、applier 线程 state 是 Waiting for slave SQL thread to read or execute 之类,就是串行回放,不是锁冲突。
改造后这个症状会自动消失:拆成 400 个独立事务后,LOGICAL_CLOCK 能把它们分到 8 个 worker 并行 apply(前提是这些事务的提交顺序在 binlog 里是交错的,你的批处理是单连接顺序提交,所以并行度仍然受限于提交顺序——这点要额外注意)。
进一步提并行度的可选方案:
- 把批处理改成多连接并发提交(比如 4 个连接各跑 100 批),binlog 里事务提交顺序交错,LOGICAL_CLOCK 才能真正并行。
- 或者从库开
slave_preserve_commit_order=OFF(有乱序风险,慎用)。 - 8.0.27+ 可以试
slave_parallel_workers=16+ 多连接并发,但收益递减。
3) 在线查询变慢但扫描行数不变:undo 链过长是主因,但还有 2 个次要嫌疑
主因:undo 链过长导致回表变慢——你的怀疑是对的,但「EXPLAIN ANALYZE 扫描行数没变」这个观察其实不能排除 undo 链问题,因为:
EXPLAIN ANALYZE的「扫描行数」统计的是逻辑行数(索引/表扫描的行数),不统计每行回表时读 undo 版本的次数。- InnoDB 的 MVCC 读取路径:先读聚簇索引拿到当前版本,如果当前版本不可见(被未提交事务修改过),就沿 undo 链往回找可见版本。你的大事务在批处理期间持续修改 orders 表,那条在线大查询如果走二级索引回表,每行都可能要沿 undo 链走 N 步(N = 该行的修改历史长度),这一步的耗时 EXPLAIN ANALYZE 不单独显示,只体现在「每行耗时」上。
验证手段(低成本,批处理期间跑):
-- 1) 看那条慢查询的实际扫描行数与耗时
SELECT query_id, sql_text, rows_examined, total_latency,
total_latency / NULLIF(rows_examined, 0) AS latency_per_row
FROM sys.statements
WHERE sql_text LIKE '%orders%'
ORDER BY total_latency DESC LIMIT 5;
-- 2) 对比批处理期间与批处理结束后的同一查询
-- 关键指标:latency_per_row 在批处理期间应该显著升高
-- 如果升高 5–10 倍,基本就是 undo 链问题
-- 3) 直接看 undo 链长度(8.0 没有直接指标,但可以间接看)
SHOW ENGINE INNODB STATUS\G -- History list length
-- History list length 800 万+ 时,单行 undo 链长度平均可达几十到几百
两个次要嫌疑(按概率排序):
- Buffer Pool 污染:批处理把 800 万行 orders 数据的页刷进 Buffer Pool,把在线查询热页挤出去,导致在线查询回表时更多走磁盘。验证:
SHOW ENGINE INNODB STATUS看Buffer pool hit rate,批处理期间如果从 99.9% 掉到 95% 以下,就是这个原因。 - ** redo log 刷盘竞争**:大事务产生大量 redo,刷盘时 ESSD 的 IO 队列被占,在线查询的 commit 等待变长。验证:
iostat -x 1看批处理期间 ESSD 的await和avgqu-sz。
一句话总结
把 400 批拆成 400 个独立事务 + 每批 COMMIT,undo 峰值 / 主从延迟 / 在线查询慢三个问题会一起消失。SLEEP 保留 0.2s 足够(作用变成「让半同步 ACK 追上来」),批处理总耗时预计回到 50 分钟左右(因为 COMMIT 之后的 purge 开销被均摊到 400 个事务,单事务的 COMMIT 开销远小于 400 事务打包成一个的开销)。
参考:MySQL 8.0 官方 InnoDB undo logs、Replication and Binary Log。