监控面板上 Seconds_Behind_Source 从 0 跳到 3000,报警响了,业务方说"从库读到的数据是十分钟前的"。主从延迟不是一个数字问题,而是一条链路上四个环节的相对速度问题:主库写 binlog、从库 IO 线程拉取、SQL 线程重放、以及主从硬件与负载的差异。这篇按链路拆解,顺带把"为什么这个指标会撒谎"讲清楚。以下按 MySQL 8.0 核对(8.0.22 起 MASTER/SLAVE 术语改为 SOURCE/REPLICA)。
1. 三步链路与每一段的延迟来源
SHOW REPLICA STATUS\G
-- Replica_IO_State: Waiting for source to send event
-- Source_Log_File / Read_Source_Log_Pos → IO 线程读到哪
-- Relay_Log_File / Relay_Log_Pos → relay log 落盘到哪
-- Relay_Source_Log_File / Exec_Source_Log_Pos → SQL 线程重放到哪
-- Seconds_Behind_Source, Retrieved_Gtid_Set, Executed_Gtid_Set
| 阶段 | 动作 | 可能的延迟来源 |
|---|---|---|
| 主库 | 事务提交写 binlog(受 sync_binlog 约束) |
大事务一次写几百 MB;binlog 落盘慢 |
| 传输 | 从库 IO 线程连主库拉 binlog | 网络带宽/抖动;主库 dump 线程被大事务占住 |
| 落盘 | 写入 relay log 并更新 master.info |
从库磁盘慢;relay_log 所在盘 IO 饱和 |
| 重放 | SQL 线程(或 worker 组)执行事件 | 单线程重放 vs 主库并发;从库硬件弱;从库上跑了重查询 |
判断卡在哪一段,看三个位点是否同步推进:Read_Source_Log_Pos 停住 → 传输段;Relay_Log_Pos 追上了但 Exec_Source_Log_Pos 不动 → 重放段。这个三分法比盯着 Seconds_Behind_Source 有用得多。
2. 异步、半同步与组复制
| 模式 | 主库何时返回成功 | 可用性边界 | 对延迟的影响 |
|---|---|---|---|
| 异步 | 本地提交即返回 | 主库宕机可能丢最后若干事务 | 无额外等待 |
| 半同步(AFTER_SYNC) | 至少一个从库确认收到 binlog 后返回 | 超时后退化为异步 | 每次提交多一个网络往返 |
| 组复制(MGR) | 多数派认证通过后提交 | 需要多数派存活,否则不可写 | 认证 + 传播,对网络质量敏感 |
-- 半同步相关(8.0 插件名带 source/replica)
SHOW VARIABLES LIKE 'rpl_semi_sync_source_enabled';
SHOW VARIABLES LIKE 'rpl_semi_sync_source_timeout'; -- 默认 10000 ms
SHOW VARIABLES LIKE 'rpl_semi_sync_source_wait_point'; -- AFTER_SYNC / AFTER_COMMIT
SHOW STATUS LIKE 'Rpl_semi_sync_source_status';
SHOW STATUS LIKE 'Rpl_semi_sync_source_no_tx'; -- 退化为异步的次数
Rpl_semi_sync_source_no_tx 持续增长,说明"半同步已经名存实亡"——超时后自动退回异步,监控却还在显示"半同步已开启"。这是可用性与一致性的交换点:要严格一致就得让它阻塞,要低延迟就得接受退化,中间没有第三种。
组复制把"多数派"写进了提交路径:少数派分区下的节点会拒绝写入,这保证了不脑裂,代价是任意时刻必须有多数派在线。三节点集群只能容忍一个节点故障,这是容量规划问题,不是调参问题。
3. 延迟怎么量:为什么 Seconds_Behind_Source 会撒谎
它的算法是:当前时间 − 正在重放的事件在 binlog 里的时间戳。三个失真场景:
| 场景 | 表现 | 原因 |
|---|---|---|
| 从库长时间空闲 | 显示 0,实际可能落后 | IO 线程没拉到新事件时该值为 NULL 或沿用旧值 |
| 主库突发大事务 | 数字很小然后突然暴涨 | 事务内的事件时间戳相同,重放中途不体现进度 |
| 主库时钟与从库不一致 | 恒为 0 或恒为大数 | 该指标依赖两端时钟 |
| 级联复制 | 中间节点显示正常,末端落后 | 指标只反映"相对直接上游" |
更可靠的度量有三种:
-- 1. GTID 差值:Executed 与 Retrieved 的集合差
SELECT @@GLOBAL.gtid_executed;
-- 2. 心跳表:主库每秒更新一行,从库读该行时间差
CREATE TABLE IF NOT EXISTS heartbeat (
id TINYINT PRIMARY KEY,
ts DATETIME(6) NOT NULL
) ENGINE=InnoDB;
-- 主库定时:REPLACE INTO heartbeat VALUES (1, NOW(6));
-- 从库读取:SELECT TIMESTAMPDIFF(MICROSECOND, ts, NOW(6))/1000 AS lag_ms FROM heartbeat;
-- 3. 写后读校验:写入带时间戳的探针行,从库读回比对
pt-heartbeat 就是第 2 种的成熟实现,它绕开了"事件时间戳"这一层的全部失真,代价是多一张表和每秒一次写入。
4. 四个常见成因与并行复制
| 成因 | 识别信号 | 处理 |
|---|---|---|
| 大事务(一次改几百万行) | Exec_Source_Log_Pos 长时间不动,事务结束后瞬间追平 |
拆批:WHERE id BETWEEN ? AND ? 循环,每批几千行 |
| 从库单线程重放 vs 主库并发 | 主库 QPS 高、从库 CPU 只有一个核忙 | 开并行复制 |
| 从库硬件更弱 | 从库 IO await 高、Relay_Log_Pos 推进慢 |
从库配置不低于主库,至少 IO 能力对齐 |
| 从库上跑了重查询 | 突发延迟与报表任务时间吻合 | 报表走独立从库/离线库,加资源隔离 |
并行复制的原理是:只要两个事务在主库上不冲突,从库就可以并行重放。8.0 用 LOGICAL_CLOCK 记录事务的提交组,配合 WRITESET 让冲突检测更细:
SHOW VARIABLES LIKE 'replica_parallel_workers'; -- 8.0 默认 4
SHOW VARIABLES LIKE 'replica_parallel_type'; -- LOGICAL_CLOCK
SHOW VARIABLES LIKE 'replica_preserve_commit_order'; -- 需为 ON 才能保证提交顺序
SHOW VARIABLES LIKE 'binlog_transaction_dependency_tracking'; -- WRITESET
限制同样明确:同一个提交组内的事务不能并行(它们互相可见)、DDL 只能单线程、大事务整体是一个并行单位。所以"开了并行复制还是延迟",多半是写路径上有个几百 MB 的大事务,或者一堆 DDL 排队。
5. 读写分离下的一致性
| 策略 | 实现 | 代价 | 适用 |
|---|---|---|---|
| 强制走主 | 写后 N 秒内的读路由到主库 | 主库压力上升 | 支付、下单等强一致场景 |
| 延迟阈值切主 | 从库延迟 > 阈值时该库摘除 | 阈值内的不一致仍存在 | 通用列表页 |
| 会话一致性 | 记录本会话最后写入的 GTID,读前等待 | 需要中间件支持 | 用户"改完立刻看" |
| 关键读走主 | 只把详情页/我的订单走主 | 需按接口标注 | 成本最低的折中 |
-- 会话级等待:确保本会话之前的写入已在从库执行
SET @gtid = '3e11fa47-71ca-11e1-9e33-c80aa9429562:1-5';
SELECT WAIT_FOR_EXECUTED_GTID_SET(@gtid, 1); -- 超时 1 秒,返回 0 表示已追平
第三种是最精确的:它不猜延迟,而是问"我关心的那个事务到了没"。代价是应用要传递 GTID,且等待本身会拉长响应时间。
6. 监控告警与主从切换
告警阈值不该按"秒数"拍,而该按业务可接受的陈旧度:商品列表能忍 5 秒,余额不能忍 0.5 秒。同一个集群里不同接口的阈值本来就不同,用一个全局 lag > 10s 报警,结果只能是要么漏报要么天天响。
-- 健康检查四件套
SHOW REPLICA STATUS\G -- IO/SQL 线程是否 Running,Last_Error 是否为空
SHOW SLAVE HOSTS; -- 8.0 仍可用,看拓扑
SELECT @@GLOBAL.gtid_executed; -- 与主库比对差集
SHOW STATUS LIKE 'Replica_running_%';
切换的注意点按顺序:
| 步骤 | 动作 | 漏做的后果 |
|---|---|---|
| 1 | 确认从库 Executed_Gtid_Set 已追平主库 |
切过去丢最后几个事务 |
| 2 | 主库 SET GLOBAL super_read_only=ON |
双写,数据分叉 |
| 3 | 提升从库:SET GLOBAL read_only=OFF |
新主仍只读,业务全挂 |
| 4 | 其余从库 CHANGE REPLICATION SOURCE TO 指向新主 |
复制链断 |
| 5 | 应用重连(改 VIP/DNS/连接串,重启连接池) | 应用连着旧主,写入丢失 |
第 5 步是最常被忘的一步:数据库切完了,连接池里的旧连接还指向老主库,应用侧表现为"写入成功但查不到"。切换预案里必须包含"连接池刷新"这一条,并且要能被验证。
7. 排错:五种复制异常
| 现象 | 原因 | 处理 |
|---|---|---|
Seconds_Behind_Source 长期 0 但数据旧 |
指标失真(空闲/时钟/级联) | 改用心跳表或 GTID 差集 |
| 延迟周期性打满后瞬间归零 | 大事务/大批量更新 | 拆批提交,单批几千行以内 |
Last_IO_Error 报认证失败 |
账号权限、密码、caching_sha2_password 插件 |
用 mysql_native_password 或配好 TLS |
Last_SQL_Error 报主键冲突 |
双写或从库被直连写入 | 定位写入源,重建从库 |
| 切换后应用报"表不存在/数据旧" | 应用未重连,或连到了错误的实例 | 刷新连接池,校验 @@server_id |
| 并行复制开了仍单线程 | 大事务、DDL、或 WRITESET 未生效 |
查写路径,确认 binlog_transaction_dependency_tracking |
8. 边界:什么时候复制方案本身不合适
| 场景 | 更合适的方案 | 理由 |
|---|---|---|
| 跨地域强一致读 | 一致性读 + 就近写,或分布式数据库 | 异步复制无法给跨地域强一致 |
| 读多写少的报表 | 列式仓库 / 只读分析副本 | 行存从库扛不住大聚合 |
| 秒级 RPO 容灾 | 半同步/组复制 + binlog 异地归档 | 异步复制必然有窗口 |
| 分库分表后的聚合查询 | 汇总表 / 宽表 / 搜索索引 | 复制解决不了跨库 join |
| 单机写入瓶颈 | 分片(sharding) | 加从库只扩读不扩写 |
复制解决的是"读扩展 + 备份 + 容灾"三件事,扩不了写。把它当写扩展方案用,最终一定会卡在主库的单点上。
9. 自测
- 复制链路的四个阶段分别是什么?看到
Relay_Log_Pos追上但Exec_Source_Log_Pos不动,卡在哪一段? - 半同步超时后退化为异步,监控上怎么发现这件事?
Seconds_Behind_Source在哪三种情况下会失真?更可靠的两种替代度量是什么?- 并行复制为什么"同提交组内不能并行"?大事务为什么无法被拆开并行?
- 会话一致性用什么机制实现?它比"延迟阈值切主"精确在哪?
- 主从切换的五步里,哪一步最容易被漏掉?漏掉后应用侧表现是什么?