前言

主从延迟是 MySQL 高可用架构中最高频的问题之一。很多人对延迟的理解停留在"大事务导致延迟"这个层面,遇到问题时无从下手。本文从复制链路的两个阶段出发——IO 线程和 SQL 线程——分别梳理常见延迟原因,并结合真实案例深入分析原理。

一、主从复制链路简述

理解延迟,先要清楚复制的完整链路:

主库写入事务
    ↓
主库 Dump 线程:读取 binlog 发送给从库
    ↓  (网络传输)
从库 IO 线程:接收 binlog,写入 relay log
    ↓
从库 SQL 线程(或并行 Worker):读取 relay log,回放事务
    ↓
从库数据与主库一致

延迟分两类:

  • IO 延迟:从库 IO 线程落后于主库,relay log 还没接收完整
  • SQL 延迟:relay log 已收到,但 SQL 线程回放跟不上

SHOW SLAVE STATUS\G 区分:

1
2
3
4
5
6
7
8
SHOW SLAVE STATUS\G

-- 判断是 IO 延迟还是 SQL 延迟:
-- Master_Log_File / Read_Master_Log_Pos       → IO 线程已读到的位置
-- Relay_Master_Log_File / Exec_Master_Log_Pos → SQL 线程已执行的位置

-- 如果 Read_Master_Log_Pos 远落后于主库最新位置 → IO 延迟
-- 如果 Read_Master_Log_Pos 接近主库,但 Exec_Master_Log_Pos 落后 → SQL 延迟

二、IO 线程延迟的常见原因

原因 1:网络带宽不足

原理:

主库的 Dump 线程将 binlog 通过 TCP 推送给从库 IO 线程。如果主库写入量大,binlog 生成速率超过网络传输速率,从库就会持续落后。

排查方法:

1
2
3
4
5
6
7
8
9
# 在主库上查看 binlog 生成速率
mysqladmin -uroot -p extended-status | grep Binlog_bytes_written

# 测试主从之间实际带宽
iperf3 -s                      # 从库执行(服务端)
iperf3 -c <主库IP> -t 30      # 主库执行(客户端)

# 查看当前网络流量
sar -n DEV 1 5

案例场景:

某公司主从之间走公网专线,带宽 100Mbps。某天主库执行了一批图片数据迁移,binlog 瞬间增长到 150Mbps,从库 IO 线程开始积压,Seconds_Behind_Master 快速攀升到 600 秒。

解决方案:

1
2
3
4
5
# 开启 binlog 压缩传输(MySQL 8.0.20+)
binlog_transaction_compression = ON

# 旧版本使用压缩协议(从库配置)
slave_compressed_protocol = ON

压缩对 CPU 有轻微消耗,但可将 binlog 传输量降低 30%~70%,对文本类数据效果显著。


原因 2:主库 Dump 线程压力大

主库的每个从库对应一个 Dump 线程。从库数量多时,主库磁盘读压力增大,每个从库获取的 binlog 速度都会下降。

排查方法:

1
2
3
4
5
6
-- 查看主库当前有多少 Dump 线程
SHOW PROCESSLIST;
-- 筛选 Command = 'Binlog Dump' 或 'Binlog Dump GTID' 的行

-- 查看主库磁盘 IO 情况(在主库 OS 上执行)
iostat -x 1 5

解决方案: 使用级联复制(主 → 中继从 → 多个从),减少主库 Dump 线程数量。


三、SQL 线程延迟的常见原因

SQL 线程延迟是最常见的延迟类型,原因也更复杂。

原因 1:大事务 + ROW 格式 + 无索引 → 全表扫描地狱

这是最典型、最容易被忽视的延迟场景,值得深入分析。

案例:主库 1000 万条数据无主键无索引,ROW 模式删除 100 万行

场景还原:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- 主库上有这样一张表
CREATE TABLE user_log (
    uid      INT,
    action   VARCHAR(50),
    log_time DATETIME
) ENGINE=InnoDB;
-- 注意:没有主键,没有任何索引,共 1000 万行

-- 主库执行删除操作
DELETE FROM user_log WHERE uid = 1001;
-- 假设符合条件的有 100 万行

ROW 模式下从库回放的原理:

ROW 格式 binlog 记录的是每一行的变更。主库删除 100 万行,binlog 里就有 100 万条 delete event,每条记录了该行的完整前镜像(所有字段的值)。

从库 SQL 线程回放时,针对每一条 delete event,需要找到对应的行并删除。InnoDB 通过以下优先顺序来定位行:

  1. 主键
  2. 唯一索引
  3. 如果都没有 → 全表扫描

没有主键、没有索引,从库每删除 1 行,就要对 1000 万行进行一次全表扫描!

延迟估算:

100 万次全表扫描 × 每次扫描耗时

假设 1000 万行扫描一次需要 0.1 秒(内存充足时):
100 万 × 0.1 秒 = 10 万秒 ← 约 27 小时的延迟

即使有缓存加速,实际延迟也会是数小时级别

验证方法:

1
2
3
4
5
-- 在从库上查看 SQL 线程正在执行什么
SHOW PROCESSLIST;

-- 查看当前正在回放的事件
SHOW RELAYLOG EVENTS IN 'relay-bin.000012' LIMIT 10;

临时缓解(已经在延迟中):

1
2
3
-- 开启哈希扫描,对无索引大表回放有明显加速效果
SET GLOBAL slave_rows_search_algorithms = 'INDEX_SCAN,HASH_SCAN';
-- 哈希扫描会将一批 event 先构建哈希表,批量匹配,避免逐行全表扫描

根本解决:

1
2
3
4
5
-- 任何业务表都必须有主键
ALTER TABLE user_log ADD COLUMN id BIGINT AUTO_INCREMENT PRIMARY KEY FIRST;

-- 或者在 WHERE 条件字段上建索引
ALTER TABLE user_log ADD INDEX idx_uid (uid);

防范参数:

1
2
# 主库配置:要求所有表必须有主键,否则拒绝建表
sql_require_primary_key = ON   # MySQL 8.0.13+

原因 2:从库未提交事务 + 主库 DDL → MDL 锁等待

场景还原:

1
2
3
4
5
6
7
-- 从库上,某个业务连接正在执行(未提交)
BEGIN;
SELECT * FROM orders WHERE status = 0 FOR UPDATE;
-- 事务未提交,持有 orders 表的 MDL 读锁

-- 此时,主库执行了 DDL:
ALTER TABLE orders ADD COLUMN remark VARCHAR(200);

从库回放 DDL 的原理:

从库 SQL 线程回放 DDL 时,需要获取该表的 MDL 写锁(Metadata Lock)

而从库上那个未提交的 SELECT ... FOR UPDATE 事务,在整个事务期间持有 MDL 读锁

锁冲突:

SQL 线程需要:MDL 写锁(互斥锁)
    ↑ 等待
未提交事务持有:MDL 读锁

SQL 线程被阻塞,relay log 积压,Seconds_Behind_Master 持续增长,直到从库上的未提交事务提交或回滚,MDL 读锁释放,SQL 线程才能继续回放。

排查方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- 查看 MDL 锁等待(MySQL 5.7+)
SELECT * FROM performance_schema.metadata_locks
WHERE LOCK_STATUS = 'PENDING';

-- 查看持有 MDL 锁的线程
SELECT * FROM performance_schema.metadata_locks
WHERE LOCK_STATUS = 'GRANTED' AND OBJECT_NAME = 'orders';

-- 找到阻塞的事务
SELECT * FROM information_schema.INNODB_TRX;

解决方案:

1
2
3
4
5
6
7
8
9
-- 找到阻塞线程,将其 kill
KILL <thread_id>;

-- 设置从库事务超时,防止长事务长期持有 MDL 锁
SET GLOBAL interactive_timeout = 300;
SET GLOBAL wait_timeout = 300;

-- 设置 DDL 等待锁超时(超时后 DDL 报错,但 SQL 线程不会卡死)
SET GLOBAL lock_wait_timeout = 30;

根本预防:

  • 从库应设置 super_read_only = ON,禁止业务连接开启写事务
  • 主库执行 DDL 前,先检查是否有长事务:SELECT * FROM information_schema.INNODB_TRX
  • 使用 pt-online-schema-change 代替直接 DDL

原因 3:单线程回放 vs 主库并发写入

原理:

主库是多线程并发写入,N 个线程同时提交事务。而默认情况下,从库 SQL 线程是单线程串行回放。

主库:线程1、线程2、线程3 并发提交 → binlog 串行记录
从库:单个 SQL 线程串行回放

主库并发优势在从库完全消失,写入压力越大,延迟越明显。

解决方案:并行复制(Parallel Replication)

MySQL 5.7 引入基于 LOGICAL_CLOCK 的并行复制。核心思想:在主库上同一组提交(Group Commit)内的事务,没有锁冲突,可以在从库并行回放。

如何确定合适的 Worker 数量?

通过分析 binlog 中的 last_committed 字段来判断主库组提交的批次大小:

1
2
3
4
5
6
7
8
9
# 解析 binlog,查看组提交分布
mysqlbinlog --verbose /var/lib/mysql/binlog.000023 | grep last_committed | head -50

# 输出示例:
# last_committed=0  sequence_number=1
# last_committed=0  sequence_number=2
# last_committed=0  sequence_number=3   ← 同一组(last_committed=0),3 个事务可并行
# last_committed=3  sequence_number=4
# last_committed=3  sequence_number=5   ← 同一组(last_committed=3),2 个事务可并行

last_committed 相同的事务属于同一个 Group Commit,可以并行回放。

统计每组平均事务数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 统计每个 last_committed 值对应多少个事务
mysqlbinlog --verbose /var/lib/mysql/binlog.000023 \
  | grep "last_committed" \
  | awk '{for(i=1;i<=NF;i++) if($i~/last_committed=/) print $i}' \
  | sort | uniq -c | sort -rn | head -20

# 输出示例(第一列是该组事务数,第二列是 last_committed 值):
#  8 last_committed=5     ← 这一组有 8 个事务可并行
#  6 last_committed=12
#  5 last_committed=3
# 平均每组约 6 个事务

根据分析结果设置 Worker 数(通常设置为平均组大小的 1~2 倍):

1
2
3
4
5
6
-- 如果平均每组有 6 个事务,Worker 设置为 8
SET GLOBAL slave_parallel_type = 'LOGICAL_CLOCK';
SET GLOBAL slave_parallel_workers = 8;

-- 保证提交顺序与主库一致(避免数据不一致问题)
SET GLOBAL slave_preserve_commit_order = ON;

主库端:优化 Group Commit 批次大小

1
2
3
4
5
6
7
8
# my.cnf 主库配置
# 延迟 1ms 等待更多事务进入同一组(单位:微秒)
binlog_group_commit_sync_delay = 1000

# 或者达到 10 个事务就立即提交,不再等待
binlog_group_commit_sync_no_delay_count = 10

# 注意:sync_delay 会轻微增加主库事务响应时间,需根据业务延迟容忍度调整

验证 Group Commit 效果:

1
2
3
4
5
6
7
-- 主库查看 Group Commit 统计
SHOW GLOBAL STATUS LIKE 'Binlog_commits';
SHOW GLOBAL STATUS LIKE 'Binlog_group_commits';

-- 平均每组事务数 = Binlog_commits / Binlog_group_commits
-- 接近 1 → Group Commit 效果差,大部分事务单独提交,并行复制收益有限
-- 达到 5~10 → Group Commit 效果好,并行复制能充分发挥

原因 4:从库慢查询占用资源

从库 SQL 线程和业务查询共享 CPU、内存、IO 资源。如果从库上有大量慢查询(报表、全表扫描等),会导致 SQL 线程资源不足。

排查:

1
2
3
4
5
6
7
8
9
-- 查看从库当前执行的慢查询
SELECT * FROM information_schema.PROCESSLIST
WHERE TIME > 30 AND COMMAND != 'Sleep'
ORDER BY TIME DESC;

-- 查看 InnoDB buffer pool 命中率
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
-- Innodb_buffer_pool_reads(物理读)/ Innodb_buffer_pool_read_requests(总读请求)
-- 命中率 < 95% 说明有大量扫描操作破坏了缓存

解决方案:

1
2
3
4
5
6
7
8
-- 从库设置只读,禁止业务写操作干扰
SET GLOBAL read_only = ON;
SET GLOBAL super_read_only = ON;

-- 限制单条查询最大执行时间(MySQL 5.7.8+)
SET GLOBAL max_execution_time = 30000;   -- 30 秒

-- 大型报表查询路由到专用报表从库,不和复制混用

四、Seconds_Behind_Master 的局限性

Seconds_Behind_Master 不是真正的延迟时间,它的计算方式是:

当前时间 - relay log 中正在执行事务的 binlog 时间戳

它会失真的场景:

  1. 网络中断后恢复:IO 线程重连后快速拉取大量 relay log,SQL 线程回放时事务时间戳是历史值,Seconds_Behind_Master 显示很大(如 3600),但 relay log 其实已经追上了
  2. 主从时钟不同步:服务器时间差会导致该值持续偏大或偏小
  3. IO 线程停止期间:值为 NULL,重启后可能瞬间显示为 0

更准确的方式(GTID 模式):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- 主库查看已执行的 GTID 范围
SELECT @@GLOBAL.GTID_EXECUTED;
-- 输出示例:abc:1-10000

-- 从库查看已执行的 GTID 范围
SELECT @@GLOBAL.GTID_EXECUTED;
-- 输出示例:abc:1-9500

-- 差距:abc:9501-10000,共 500 个事务未回放
-- 结合每秒回放 TPS,估算真实延迟

或者使用 pt-heartbeat 工具进行精准心跳检测:

1
2
3
4
5
# 主库写入心跳
pt-heartbeat --update --host=主库IP --user=root --password=xxx --daemonize

# 从库监控延迟
pt-heartbeat --monitor --host=从库IP --user=root --password=xxx

五、延迟排查决策流程

发现延迟(Seconds_Behind_Master > 阈值)
    ↓
区分 IO 延迟 vs SQL 延迟
    ├── Read_Master_Log_Pos 落后主库很多 → IO 延迟
    │       ↓
    │   检查网络带宽 → iperf3 测试
    │   开启压缩传输 / 升级带宽 / 级联复制
    │
    └── Read 接近主库,但 Exec 落后 → SQL 延迟
            ↓
        SHOW PROCESSLIST 查看 SQL 线程状态
            ├── 等待 MDL 锁 → 找到阻塞事务,KILL 之
            ├── 正在执行单条 SQL 很慢 → 检查是否无索引全表扫描
            │       → 临时开启 HASH_SCAN
            │       → 根本上为表添加主键/索引
            ├── SQL 线程空闲但延迟大 → 检查并行复制配置
            │       → 分析 binlog last_committed 分布
            │       → 调整 parallel_workers
            └── 从库 CPU/IO 跑满 → 隔离业务查询到专用从库

六、关键参数速查

参数说明推荐值
slave_parallel_type并行复制模式LOGICAL_CLOCK
slave_parallel_workers并行 Worker 数根据 Group Commit 分析,通常 4~16
slave_preserve_commit_order保证提交顺序ON
slave_compressed_protocol启用压缩传输ON(跨机房/公网)
slave_rows_search_algorithms行查找算法INDEX_SCAN,HASH_SCAN
binlog_group_commit_sync_delay主库 Group Commit 等待(微秒)100~1000
binlog_group_commit_sync_no_delay_count达到 N 个事务立即提交10~50
sql_require_primary_key强制表必须有主键ON(MySQL 8.0.13+)
lock_wait_timeoutMDL 锁等待超时(秒)30
super_read_only从库禁止所有写入ON

七、总结

主从延迟不是一个单一问题,而是一个症状。同样是"延迟增大",背后的原因可能完全不同:

延迟类型典型场景核心原因
IO 延迟大批量写入、跨机房复制带宽不足,binlog 传输慢
SQL 延迟无索引大表 ROW 模式删除从库回放变成逐行全表扫描
SQL 延迟从库有未提交事务时主库执行 DDLMDL 读写锁互斥,SQL 线程卡死
SQL 延迟主库高并发写入从库单线程回放追不上主库并发
SQL 延迟从库跑大量报表查询CPU/IO 资源被业务查询抢占

核心原则:先定位是 IO 延迟还是 SQL 延迟,再根据具体原因针对性处理。不要上来就无脑调大 slave_parallel_workers,首先搞清楚瓶颈在哪里。