MySQL 主从延迟排查:从库落后 3 小时,用户读到了三个月前的旧数据
引言
两个投诉,隔着不到十分钟打进来,把一个普通的周四下午变成了事故复盘会:
14:40,用户 A:"我刚下的订单,列表里怎么找不到?刷新也没用。"
14:48,用户 B(内测商家):"你们后台给我的报表是三个月前的吧?我上周的销量呢?"
排查结果比投诉更扎心:订单从库延迟 3 小时 12 分钟(用户 A 的问题),而排查时顺手做的全量巡检还揪出一个更吓人的——报表归档从库的复制位点停在三个月前(用户 B 的问题),期间没有任何告警,因为监控清单里压根没有这台"冷从库"。
根因不在什么高深的东西:14:02 一个定时任务发了单个 50 万行的大事务 UPDATE,主库 6 分钟执行完,从库却要整事务串行回放 42 分钟——在这 42 分钟里,这个事务之后的所有 binlog 都只能排队,单线程回放的从库被彻底堵死。更早的 1 个半小时积压叠上去,就是 3 小时 12 分钟的账。
这篇文章按真实排查顺序展开:事故还原 → 复制原理补课(延迟是架构属性不是故障)→ Seconds_Behind_Master 的读法与三个坑 → 定位大事务 → 三重修复(大事务拆小 + 并行复制 + 关键读走主库)→ 可抄的监控脚本。先放结论总表:
| 层面 | 事故前 | 事故后 |
|---|---|---|
| 大事务治理 | 单事务 50 万行,无人审查 | 单事务 ≤5000 行 / binlog ≤10MB,CI 拦截 |
| 从库回放能力 | 单线程 ~6,000 TPS | WRITESET 并行 ~35,000 TPS |
| 写后读一致性 | 盲目读从库 | 写后短窗口读主库 + GTID 等待 |
| 监控覆盖 | 2 台核心从库 | 全部 7 台从库(含报表/归档) |
一句话破题:主从延迟不是"异常",是异步复制的常态——真正的事故是"没有感知延迟的手段、没有约束大事务的规范、没有兜底读一致性的策略",三缺一,问题迟早变成投诉。
一、事故还原:读写分离架构下的三重漏洞
1.1 业务架构与故障点
┌── 订单从库 A(C 端读)── 延迟 3h12m ← 本次事故
订单写请求 ──▶ 主库 ──binlog──▶ 订单从库 B(商家端读)── 延迟 2h05m
│ └── 报表归档从库 C ──── 延迟 3 个月 ← 巡检才发现
│
└── 下单写主库:写成功(主库毫秒级)
订单列表读从库 A:读不到(事务还在 relay log 里排队)
三重漏洞逐一看:没有大事务规范(50 万行 UPDATE 直接过审)、没有并行复制(从库全是单线程回放配置)、读路由无一致性兜底(下单成功后立刻查列表,读打到 3 小时前的从库)。
1.2 事故时间线
14:02 券过期批处理任务:UPDATE coupon_user SET status=3 WHERE end_time < NOW() -- 50万行,单事务
14:02 主库执行中(6 分钟),binlog 以单事务落盘
14:08 主库提交完成,binlog event 开始传向从库
14:08 从库 A/B 的 SQL 线程开始回放这个大事务(回放不可中断、不可并行)
14:15 订单从库 A:Seconds_Behind_Master 开始线性增长(每秒 +1)
14:40 用户 A 投诉;此时延迟 28 分钟
14:48 用户 B 投诉(归档从库 C 的历史欠账被顺带引爆)
15:10 定位大事务 + 从库单线程回放(详见第三、四章)
15:30 拆小事务重跑 + 确认从库追赶中
16:15 延迟归零;当晚复盘,新增三重修复与监控脚本
注意 14:15 那个细节:从库延迟从大事务开始回放的那一刻起就是"每秒 +1"的线性爬升——这不是网络抖动式的毛刺,是回放速率 < 生产速率的确定性积压。看到这种曲线形状,就该直接往"单点瓶颈"方向查。
二、先补课:主从复制与"延迟"的架构本质
2.1 复制链路三个角色
【主库】
事务提交 → 写 binlog(顺序追加)
│ ①dump 线程(Binlog Dump)
▼ 推送
【从库】
②IO 线程:接收 binlog → 写 relay log(中继日志,顺序写,很快)
③SQL 线程:读 relay log → 回放事务 → 更新从库数据
延迟 = 事件到达速度(IO) 与 事件回放速度(SQL) 的差值累积
关键认知一:IO 线程几乎不会是瓶颈(顺序写 relay log,速度≈磁盘顺序写,参见《Kafka 为什么这么快》的顺序写章节——MySQL 从库的中转层同样是顺序 IO 逻辑)。瓶颈九成在 SQL 线程的回放能力。
关键认知二:默认异步复制下,延迟是架构属性。主库提交不等从库,从库回放永远慢一拍——正常业务压力下这一拍是毫秒级,感知不到;但任何"回放速率骤降"(大事务、锁等待、从库资源不足)都会让这一拍放大成分钟和小时。
2.2 为什么"一个事务"能堵死整条链路
这是本次事故的核心机制,值得单独讲透:
binlog 里一个事务的边界:
BEGIN ← GTID: 3a1f:10086
Update_rows (1000 行)
Update_rows (1000 行)
... × 500 ← 50 万行事件连续打包
COMMIT ← Xid event
SQL 线程视角:
读到 BEGIN 之后,必须把 500 个 event 全部回放完、看到 COMMIT,
才能去取下一个事件 —— 中间无论多久都不能"跳去处理别人"
(事务原子性要求:不回放完不能提交,也不能与其他事务交错)
后果:主库 6 分钟的事务 → 从库回放 42 分钟
→ 这 42 分钟里排在后面的所有事务(含用户新订单)全部排队
→ 延迟线性增长,直到大事务回放完毕才消化
放大倍数的来源:回放没有主库那样的多连接并发(单线程),且从库常常还要做行查找(没有主键的表回放更是全表扫描级灾难,见 FAQ 2)。主库执行时间与从库回放时间的比值,就是这次事故的"延迟放大系数"——7 倍。
2.3 与 PG 流复制的一句话对照
写过《PG 高可用实战》的读者会问 PG 怎么处理这件事:PG 是物理复制,WAL 直接在磁盘块层面重放,不需要逐条解析行事件,大事务回放通常比 MySQL 逻辑复制快得多,但同样单进程回放。MySQL 逻辑复制的优势是"可感知每行变化"(可做并行、可过滤),代价是回放成本高——所以大事务治理对 MySQL 是必修课,对 PG 更多是 WAL 堆积问题。机制差异决定各自的高频故障不同。
三、排查现场:Seconds_Behind_Master 怎么读、三个坑
3.1 第一现场
mysql> SHOW SLAVE STATUS\G
*************************** 1. row ***************************
Slave_IO_State: Waiting for master to send event
Master_Host: 10.0.0.11
Master_Port: 3306
Slave_IO_Running: Yes ← ① IO 线程正常:问题不在传输
Slave_SQL_Running: Yes ← ② SQL 线程活着:不是报错型停止
Seconds_Behind_Master: 10800 ← ③ 3 小时,持续增长中
Read_Master_Log_Pos: 10234567890
Relay_Log_Space: 8234567890 ← relay log 还在涨:IO 没断
Last_SQL_Errno: 0 ← 无报错:纯粹"跑不快"
Replicate_Ignore_Server_Ids:
Retrieved_Gtid_Set: 3a1f:10080-10290 ← 已收到的 GTID 区间
Executed_Gtid_Set: 3a1f:10001-10112 ← 已回放的 GTID 区间
解读顺序固定为四步:
Slave_IO_Running / Slave_SQL_Running——先排除断流/报错型故障(任一为 No 走另一套排查:Last_Errno、网络、磁盘)。Retrieved_Gtid_Set与Executed_Gtid_Set的区间差——量化积压:收到 10290、只回放到 10112,中间 178 个事务在排队。这是比 SBM 更精确的积压刻度。Seconds_Behind_Master的趋势而非瞬时值:每秒 +1 的线性爬升 = 确定性积压;反复跳变 = 毛刺型延迟。- 主库与从库的 binlog 速率对比(下文脚本),确认"生产速率 > 回放速率"。
3.2 SBM 的三个坑,每个都坑过人
坑一:SBM 不是"数据新旧"的时间差,是"事件时间戳"的差。它 = 当前从库时钟 − 当前正在回放的事件在主库生成时的时间戳。意味着:如果 SQL 线程正在回放一个一小时前生成的大事务,SBM 会正确显示 ~3600+;但如果 relay log 短暂没有新事件、SQL 线程空闲,SBM 显示 0,不代表没延迟。
坑二:IO 线程断流时,SBM 反而"显示 0"。断流瞬间 relay log 不再增长,SQL 线程把存量回放完就 idle 了——SBM 冻结在 0,看起来健康,实际数据停在断流时刻。这就是只盯 SBM 的监控必然漏报的原因(本次事故的归档从库 C 被漏掉 3 个月,监控缺失是主因,SBM 误导是帮凶)。
坑三:大事务回放期间 SBM "虚高但不准"。一个 50 万行事务的 binlog 事件共用事务开始时的时间戳,回放 42 分钟期间 SBM 线性增长到 42 分钟——数字方向对,但精度被"事务粒度"糊住了,无法告诉你"还剩多少"。事务粒度的精确积压要看 GTID 区间差(坑内 2 的方法)。
3.3 更准的度量:心跳表(第四章脚本的原理)
主库:每秒 UPDATE repl_heartbeat SET ts = NOW(6)
从库:延迟 = 从库当前时间 − 心跳表里的 ts
优点:粒度恒定(1 秒一个采样点),大事务回放期间照常更新?不——
心跳写入也要排队!大事务堵住时,从库读到的心跳 ts 停留在
大事务开始前 → 延迟读数照样正确增长。
结论:心跳表方案能同时覆盖"回放慢"和"传输断"两类故障,SBM 覆盖不了后者。
四、定位根因:揪出那个 50 万行的大事务
4.1 证据链三步
第一步:确认积压形状。GTID 区间差 178 个事务、SBM 线性爬升——指向"回放速率 < 生产速率"的确定性瓶颈,而不是网络/报错。
第二步:找主库侧"异常大的事务"。看主库当前正在执行的长事务:
-- 主库:当前运行超过 60 秒的事务
SELECT trx_id, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS run_s,
trx_rows_modified, -- ★ 已修改行数
trx_query
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;
-- 命中:trx_rows_modified = 501238, trx_query = UPDATE coupon_user ...
再看 binlog 侧有没有巨型事务的证据(从库或主库的 binlog 文件里找 GTID 边界跨度):
# binlog 里看事务边界:找到 BEGIN 到 Xid 之间的事件量
mysqlbinlog --include-gtids='3a1f:10086' mysql-bin.000123 | \
grep -c '^### UPDATE' # 输出:501238 ← 50万行 row event,实锤
第三步:回放速率账。从库 SHOW GLOBAL STATUS LIKE 'Com_update' 采样 60 秒,算出回放 TPS ≈ 6,000;而该时段主库写入 ≈ 9,000 TPS——缺口明确,大事务只是把缺口引爆的导火索,单线程回放能力不足才是底火。两个都要修。
4.2 定位结论
根因一(导火索):定时任务单事务 UPDATE 50 万行
→ 从库被迫 42 分钟串行回放,期间一切事务排队
根因二(底火): 从库未开启并行复制,回放能力 6,000 TPS
→ 日常高峰已有 1.5 小时隐性积压
根因三(兜底缺):读写分离无一致性策略,下单后立刻读从库
→ 延迟一旦出现,必然演变成用户可见的故障
三根都拔,就是下一章的三重修复。
五、修复一:大事务拆小——治理"延迟放大系数"
5.1 拆小模板(可直接抄)
// 修复前:一个事务干完所有事(事故元凶)
// UPDATE coupon_user SET status=3 WHERE end_time < NOW(); -- 50 万行
// 修复后:按主键分批 + 间歇,每批独立事务
@Component
public class CouponExpireJob {
private static final int BATCH_SIZE = 2000;
private static final long BATCH_INTERVAL_MS = 200; // 给从库回放留喘息
@Transactional(transactionManager = "txManager", rollbackFor = Exception.class)
public void run() {
long maxId = 0;
int total = 0;
while (true) {
// 每批独立事务:只圈 2000 行,主键索引定位,顺序推进
int affected = couponMapper.expireBatch(maxId, BATCH_SIZE,
LocalDateTime.now());
if (affected == 0) break;
maxId = couponMapper.lastMaxId(); // 记录本批推进到的 ID
total += affected;
TimeUnit.MILLISECONDS.sleep(BATCH_INTERVAL_MS);
}
log.info("券过期完成 total={}", total);
}
}
<!-- 对应 Mapper:WHERE 条件必须能走索引(end_time 辅助 maxId 边界),禁止全表偏移分页 -->
<update id="expireBatch">
UPDATE coupon_user SET status = 3
WHERE id > #{maxId} AND end_time < #{now} AND status = 1
ORDER BY id LIMIT #{limit}
</update>
三个工程要点:按主键推进(不用 OFFSET 深分页)、每批独立事务(拆掉"整事务不可并行"的死结)、间歇让位(对主库和从库都友好)。锁的持有时间从"6 分钟一把大锁"变成"毫秒级小锁",顺带把业务高峰的锁冲突风险也降了——大事务的坏处从来不止延迟。
5.2 前后对比(同量 50 万行实测)
| 指标 | 拆前(单事务) | 拆后(250 批) |
|---|---|---|
| 主库执行耗时 | 6 分 02 秒 | 8 分 41 秒(含间歇) |
| 锁持有模式 | 6 分钟连续持锁 | 每批毫秒级 |
| 从库回放方式 | 整事务串行 42 分钟 | 每批 <1s,与主库近实时 |
| 延迟影响 | 峰值 +3 小时(叠加旧账) | 波动 <3 秒 |
| 业务高峰期可否执行 | 不敢 | 随时可跑 |
代价只有主库总时长多 40%——用一点批处理速度,买回从库近实时与锁安全,这笔账在高峰期尤其划算。
5.3 把规范立起来
【大事务红线(写进 Code Review 与 CI)】
1. 单事务修改行数 ≤ 5000;批处理类一律走分批模板
2. 单事务 binlog ≤ 10MB(可用 SHOW BINLOG EVENTS 估算或压测度量)
3. 单事务执行时长 ≤ 500ms;定时任务必须带分批 + 间歇
4. UPDATE/DELETE 必须带索引条件(无主键表回放是全表扫描级灾难,FAQ 2)
5. CI 静态检查:正则扫描 Mapper XML 中无 LIMIT 的批量 UPDATE/DELETE 模板
六、修复二:并行复制——把回放能力从 6K 提到 35K
6.1 版本演进一张表
| 版本 | 方案 | 并行粒度 | 说明 |
|---|---|---|---|
| MySQL 5.6 | schema 级并行 | 不同库的事务可并行 | 单库应用几乎无效 |
| MySQL 5.7 | LOGICAL_CLOCK | 同一组提交(group commit)内的事务可并行 | 主库组提交越密收益越大 |
| MySQL 8.0 | WRITESET | 基于行冲突检测,无冲突即并行,跨组也可 | 收益最大,首选 |
WRITESET 的原理一句话:主库为每个事务记录"改了哪些行"(write set),从库回放时按行冲突判定依赖——你改订单 A、我改订单 B,无交集就并行回放。它把"能不能并行"的判定从时间维(5.7)进化到数据维(8.0),正是本次事故场景需要的。
6.2 配置(主从两侧都要动)
# 主库 my.cnf —— 写 write set 元数据
binlog_transaction_dependency_tracking = WRITESET
transaction_write_set_extraction = XXHASH63
binlog_row_image = FULL # WRITESET 需要完整行镜像(默认即 FULL,确认即可)
# 从库 my.cnf —— 并行回放
slave_parallel_workers = 16 # 建议 = 从库 CPU 核数
slave_parallel_type = LOGICAL_CLOCK # WRITESET 模式下也配它
slave_preserve_commit_order = 1 # ★ 从库按主库顺序提交,保证 binlog 消费(canal 等)读到的顺序一致
replica_parallel_workers = 16 # 8.0.30+ 参数名前缀更名 replica_*,与上行按版本二选一
注意三点:binlog_transaction_dependency_tracking 是主库参数(决定 binlog 里写多少依赖信息);slave_preserve_commit_order=1 没开的话并行回放可能乱序提交,下游 binlog 消费( canal 等)会读乱;8.0.30+ 官方把 slave_* 前缀更名 replica_*,旧名保留别名,生产配置按实际版本统一。
6.3 实测效果
回放能力压测(从库 16C,混合负载:70% 点查类小事务 + 30% 行更新):
├── 单线程(事故前) ~6,000 TPS
├── LOGICAL_CLOCK(5.7 语义) ~18,000 TPS
└── WRITESET(8.0) ~35,000 TPS ← 5.8 倍
高峰期延迟表现:
├── 事故前:高峰 1.5h 隐性积压,偶发分钟级尖刺
└── 事故后:高峰延迟稳定 < 1s,P99 < 2s
诚实边界:WRITESET 对两类场景无效——① 大事务本身(单事务内部依然串行,所以修复一必须先做);② 无主键/无唯一键的表(write set 依赖行标识,没有就退化)。所以并行复制的正确顺序是:先拆大事务、再补主键、最后开并行。
七、修复三:关键读走主库——读一致性的四道防线
7.1 四方案对比
| 方案 | 实现 | 一致性强度 | 成本 | 适用场景 |
|---|---|---|---|---|
| ① 写后短窗口读主库 | 拦截器记录"该用户 3s 内刚写",窗口内读路由主库 | 强 | 主库多扛一小部分读 | 订单列表(本次事故场景) |
| ② GTID 等待 | 读前 WAIT_FOR_EXECUTED_GTID_SET | 强(可超时降级) | 每次读多一次协商 | 支付回执、关键详情 |
| ③ 延迟阈值动态路由 | 延迟 > 阈值时读请求整体切主库 | 最终一致性兜底 | 依赖延迟采集准确性 | 全局兜底开关 |
| ④ 强制主库 Hint | 注解/HintManager 指定 | 强 | 粒度粗,主库压力大 | 对账、客服后台 |
我们的组合:① 为默认(覆盖 C 端"写后读")+ ③ 为全局兜底;② 用在支付回执页(示例见 FAQ 4)。
7.2 方案①的实现骨架
// 读写分离路由层的核心判断(伪代码,ShardingSphere 场景用 HintManager 实现)
public boolean shouldRouteMaster(long userId) {
Long lastWriteAt = writeTracker.get(userId); // 写操作时记录时间戳
return lastWriteAt != null
&& System.currentTimeMillis() - lastWriteAt < 3_000; // 写后 3 秒窗口
}
// 写操作埋点(AOP 切在写方法上)
@Around("@annotation(com.xxx.MasterWrite)")
public Object recordWrite(ProceedingJoinPoint pjp) throws Throwable {
writeTracker.put(currentUserId(), System.currentTimeMillis());
return pjp.proceed();
}
窗口大小怎么定:取你们从库 P99 延迟的 5~10 倍。修复后 P99 < 2s,配 3s 窗口——即绝大多数"写后读"天然被兜住,主库读增量只占 C 端读量的 ~5%,完全可承受。窗口期读走主库不是"浪费",是买"用户永远不看到自己刚下的单消失"这一条。
7.3 监控联动的自动兜底(方案③)
延迟采集(第八章脚本)接入配置中心:延迟 > 5 秒自动把读比例从 100% 从库降为 50/50,> 60 秒全部切主库——延迟恢复自动回切。这一条让"延迟从用户故障降级为内部指标",是三重修复里对用户体感最直接的一道。
八、监控体系:心跳表 + 巡检脚本(可直接抄)
8.1 心跳表与定时写入
-- 主库:建表 + 事件调度器每秒写入
CREATE TABLE repl.repl_heartbeat (
id TINYINT PRIMARY KEY,
ts TIMESTAMP(6) NOT NULL
) ENGINE=InnoDB;
INSERT INTO repl.repl_heartbeat VALUES (1, NOW(6));
SET GLOBAL event_scheduler = ON;
CREATE EVENT ev_heartbeat ON SCHEDULE EVERY 1 SECOND
DO UPDATE repl.repl_heartbeat SET ts = NOW(6) WHERE id = 1;
-- 主库的这张表会随 binlog 复制到所有从库
前提纪律:主从 NTP 对时(偏差 > 1 秒就失去意义),时钟监控本身也该有告警。
8.2 全从库巡检脚本(含 P0/P1/P2 分级)
#!/bin/bash
# check_replication_delay.sh —— 全从库延迟巡检
# crontab: * * * * * /opt/ops/check_replication_delay.sh >> /var/log/repl_check.log 2>&1
SLAVES=("10.0.0.21:3306" "10.0.0.22:3306" "10.0.0.23:3306"
"10.0.0.31:3306" "10.0.0.32:3306" "10.0.0.41:3306" "10.0.0.42:3306")
MUSER="monitor"; MPASS="Mon_Pass_2026"
P2_S=5 # 提醒级:延迟>5s
P1_S=60 # 告警级:延迟>60s
# P0:IO/SQL 线程断(必发电话告警)
ts=$(date '+%F %T')
for addr in "${SLAVES[@]}"; do
host=${addr%%:*}; port=${addr##*:}
st=$(mysql -h"$host" -P"$port" -u"$MUSER" -p"$MPASS" -e "SHOW SLAVE STATUS\G" 2>/dev/null)
[ -z "$st" ] && { echo "$ts P0 $addr 查询失败(库不通/权限)"; continue; }
io=$(echo "$st" | awk '/Slave_IO_Running:/{print $2}')
sql=$(echo "$st" | awk '/Slave_SQL_Running:/{print $2}')
sbm=$(echo "$st" | awk '/Seconds_Behind_Master:/{print $2}')
# 心跳延迟:与从库本机时间差(依赖 NTP 对时)
hb=$(mysql -h"$host" -P"$port" -u"$MUSER" -p"$MPASS" -N -e \
"SELECT ROUND(TIMESTAMPDIFF(MICROSECOND, ts, NOW(6))/1000000, 1) \
FROM repl.repl_heartbeat WHERE id=1" 2>/dev/null)
delay=$(echo "$sbm $hb" | awk '{v=($1>$2)?$1:$2; print (v<0)?0:v}') # 取大者,SBM=0 时靠 hb 补盲区
if [ "$io" != "Yes" ] || [ "$sql" != "Yes" ]; then
echo "$ts P0 $addr IO=$io SQL=$sql 线程断,立即介入"
elif [ "$(echo "$delay > $P1_S" | bc)" -eq 1 ]; then
echo "$ts P1 $addr 延迟=${delay}s (SBM=$sbm HB=$hb) 查积压:GTID 区间差"
elif [ "$(echo "$delay > $P2_S" | bc)" -eq 1 ]; then
echo "$ts P2 $addr 延迟=${delay}s 观察"
else
echo "$ts OK $addr 延迟=${delay}s"
fi
done
# 输出接告警网关:P0 电话 / P1 群机器人 / P2 记录
8.3 大事务哨兵(延迟之前就报警)
-- 每分钟跑:抓正在执行的长事务,在大事务"主库执行中"就发现,
-- 而不是等它变成从库 42 分钟的积压
SELECT trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS run_s,
trx_rows_modified, trx_query
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
OR trx_rows_modified > 5000;
-- 命中即 P1 告警 + 通知提交人:这条路是大事故的入口
监控设计的思想与《用好这 5 个 Linux 命令》一致:工具负责把证据摆出来,分级负责把注意力排好队——P0 电话(数据断流)、P1 群响(延迟/大事务)、P2 记录(趋势观察)。
九、常见问题
9.1 Seconds_Behind_Master 显示 0,但用户还在读旧数据?
三种可能按序排查:① SQL 线程空闲时 SBM 不更新(坑一)——IO 断流后存量回放完就是"假 0",用心跳表读数交叉验证;② 延迟在阈值以下但业务敏感(毫秒级延迟对"下单后立刻读"依然可见)——上写后短窗口读主库(第七章方案①),别指望把延迟压到 0;③ 读路由配错(本该走主库的流量进了从库池)——查路由中间件的 trace 日志。心跳表 + SBM 双指标是底线,单指标必有盲区。
9.2 开了 WRITESET 并行复制,大事务还是堵?
正常,不是配置错。并行复制的并行单位是事务间的行冲突检测,单个事务内部依旧串行回放——50 万行事务该堵 42 分钟还是 42 分钟。这就是为什么修复顺序是"先拆大事务、再开并行":拆小让事务粒度可并行,并行让小事务跑得快,两者是乘法不是替代。另外检查两点:表必须有主键(无主键退化且全表扫描回放)、slave_preserve_commit_order=1 开了后并行度受提交顺序约束(要吞吐可评估关掉,但下游 binlog 消费会乱序,需权衡)。
9.3 半同步复制(semi-sync)能解决延迟吗?
不能,它解决的是另一个问题。半同步保证"至少一个从库收到 binlog 才给客户端返回成功"——提升的是不丢数据(RPO),不改变回放速度(RTO):binlog 到达从库照样要排队回放,用户读旧数据的问题一分没少。顺带一提,半同步还增加主库提交延迟(等 ACK)。正确认知:半同步管"丢不丢",并行复制管"快不快",大事务治理管"堵不堵",三件事三个开关。
9.4 GTID 等待方案具体怎么实现"写后读"?
// 写操作返回后,拿到该次写入产生的 GTID(可通过连接属性或框架拦截器获取)
String myGtid = "3a1f:10295"; // 实际由框架从连接的 gtid_executed 差集计算
// 读之前(打到从库的连接上):
// SELECT WAIT_FOR_EXECUTED_GTID_SET('3a1f:10295', 2);
// 返回 0 = 从库已回放到本次写入,数据可见;超时则降级读主库
优点是"等到即读,强一致且几乎不多等"(从库追上的一瞬间就放行);代价是每次读多一次往返。适合支付回执这类"必须读到最新"的点状场景,不适合全量默认开启——全量场景用方案①的窗口路由更省。
9.5 relay log 堆满磁盘 / 主库 binlog 被清理会怎样?
relay log 无限增长会写满从库磁盘 → IO 线程停止 → 延迟变成"断流",且主库侧 binlog 若已按 expire_logs_days 滚动删除,恢复只能重建从库(全量备份 + binlog 重放,小时级)。防线三条:从库磁盘告警阈值 70%、relay_log_purge=ON 确认开启(回放完即删)、积压超阈值(如 GTID 差 > 5000)触发人工评估是否直接重搭。延迟拖过某个点,修复成本会从"等一等"跳变到"重建",这就是 P1 告警必须 60 秒级响应的原因。
9.6 大事务的量化标准怎么定?有没有通用值?
经验基线:单事务 ≤5000 行 / binlog ≤10MB / 执行 ≤500ms。但别照抄数字,按你们从库回放能力反推:回放 35,000 TPS 的从库,一个 5 万行的事务约 1.4 秒回放(尚可),6,000 TPS 的从库就是 8 秒起步、叠加排队后果严重。正确姿势:先开并行复制抬高基线,再按"高峰回放能力 ≥ 高峰写入量 × 2"留冗余,最后用 trx_rows_modified 哨兵把超标事务在主库执行时就拦下。规范跟着能力走,数字只是快照。
十、总结
排查决策树
从库延迟怎么查:
├── SHOW SLAVE STATUS 第一步
│ ├── IO/SQL 任一 No → 断流型:Last_Errno/网络/磁盘,修复或重搭
│ └── 都 Yes,SBM 增长
│ ├── GTID 区间差大 + SBM 线性爬升 → 回放型(本次)
│ │ ├── 主库 INNODB_TRX 抓大事务(trx_rows_modified)
│ │ ├── binlog 估算事务体积 → 确认根因
│ │ └── 回放 TPS vs 主库写入 TPS → 确认底火
│ └── SBM 反复跳变 → 毛刺型:从库负载/锁等待/备份任务抢占
├── 延迟出现后的止血顺序
│ ① 停/暂停大事务源(批处理任务先杀)
│ ② 读流量按延迟阈值切主库(保护用户)
│ ③ 从库并行复制拉高回放(若未开)
│ ④ 观察积压消化,恢复读路由
└── 修复后的三道永久防线
大事务红线(CI)+ 并行复制(配置)+ 写后读一致性(架构)
速查卡
| 项 | 关键点 | 配置/命令 |
|---|---|---|
| 排查入口 | SHOW SLAVE STATUS 四步读法 | IO/SQL → GTID 差 → SBM 趋势 → TPS 对比 |
| 积压刻度 | GTID 区间差(事务粒度,比 SBM 精确) | Retrieved_Gtid_Set vs Executed_Gtid_Set |
| 度量双指标 | SBM(事件时间差)+ 心跳表(真实延迟) | 补 SBM 的"假 0"盲区 |
| 根因定位 | INNODB_TRX 抓 trx_rows_modified | >60s 或 >5000 行即告警 |
| 大事务拆小 | 按主键分批 + 独立事务 + 间歇 | 批 2000 行、间歇 200ms 模板 |
| 并行复制 | 主库 WRITESET + 从库 16 worker | slave_parallel_workers=16, preserve_commit_order=1 |
| 读一致性 | 写后 3s 窗口读主库 + 延迟阈值兜底 | 窗口 = 从库 P99 × 5~10 |
| 监控分级 | P0 断流电话 / P1 延迟+大事务群响 / P2 记录 | 巡检脚本每分钟 |
一句话
主从延迟从来不是"故障发生了",而是"异步复制的债被兑现了":MySQL 的 binlog 复制把"传输"和"回放"拆成两个速率完全不同的引擎,IO 线程顺序写 relay log 快如流水,SQL 线程却要按事务原子性一条条串行回放——于是单个 50 万行的大事务就能让 42 分钟的回放堵死后面所有事务,延迟以每秒 +1 的线性曲线确定性地爬向投诉;SBM 这个最常用的指标自带三个盲区(空闲假 0、断流假 0、事务粒度糊化),单靠它监控等于半瞎,心跳表补上真实延迟、GTID 区间差补上事务粒度,三级指标交叉才是完整的测量;修复的顺序不能颠倒——先拆大事务(让回放单元变小,50 万行单事务 42 分钟变成 250 批各 1 秒内),再开并行复制(WRITESET 把回放能力从 6K 提到 35K TPS,5.8 倍),最后架读一致性防线(写后 3 秒窗口读主库 + 延迟阈值动态切流),让"延迟"从用户故障降级为内部指标;而巡检中顺手揪出的那个落后三个月、无人知晓的归档从库提醒我们最后一课:复制拓扑里的每一台从库都是生产资产,监控清单外的从库不是没有延迟,只是没有人知道——延迟管理的上限,取决于你监控清单的完整性。
关键数据
- 50 万行单事务:主库 6 分钟 → 从库串行回放 42 分钟,放大 7 倍
- 事故峰值延迟:订单从库 3h12m;归档从库积压 3 个月(无监控)
- 并行复制回放能力:单线程 6,000 → LOGICAL_CLOCK 18,000 → WRITESET 35,000 TPS(5.8 倍)
- 大事务拆小:从库延迟影响从 +3h → <3s,代价仅主库总时长 +40%
- 写后读窗口:3 秒(= 从库 P99 延迟 × 5~10),主库读增量仅 ~5%
- 告警分级:P0 断流(电话)/ P1 延迟>60s 或大事务(群响)/ P2 延迟>5s(记录)
给团队的建议
| 项 | 建议 |
|---|---|
| 规范 | 大事务红线进 CI(行数/时长/binlog 体积三闸),批处理强制分批模板 |
| 配置 | 8.0 集群默认 WRITESET + 16 worker + preserve_commit_order,新从库即开即用 |
| 架构 | 读写分离必须自带一致性策略(写后读窗口),"读从库"不能是裸的 |
| 监控 | 心跳表 + SBM 双指标全覆盖,从库清单与部署清单定期对账(防冷从库) |
| 哨兵 | INNODB_TRX 长事务哨兵每分钟跑,大事务在主库执行时就拦截 |
| 演练 | 每季度做一次"人为大事务 → 延迟攀升 → 切流 → 恢复"的演练,把脚本练熟 |
互动话题:你们踩过主从延迟的坑吗——是"刚下的订单查不到",还是更隐蔽的"报表永远慢半拍"?你们的大事务红线定在多少行?评论区聊聊,被冷从库坑过的举个手,我把故事收进下一篇复盘的"读者案例"专栏。
标题:MySQL 主从延迟排查:从库落后 3 小时,用户读到了三个月前的旧数据
作者:jiangyi
地址:http://jiangyi.space/articles/2026/10/22/1792333168076.html
公众号:服务端技术精选
- 引言
- 一、事故还原:读写分离架构下的三重漏洞
- 1.1 业务架构与故障点
- 1.2 事故时间线
- 二、先补课:主从复制与"延迟"的架构本质
- 2.1 复制链路三个角色
- 2.2 为什么"一个事务"能堵死整条链路
- 2.3 与 PG 流复制的一句话对照
- 三、排查现场:Seconds_Behind_Master 怎么读、三个坑
- 3.1 第一现场
- 3.2 SBM 的三个坑,每个都坑过人
- 3.3 更准的度量:心跳表(第四章脚本的原理)
- 四、定位根因:揪出那个 50 万行的大事务
- 4.1 证据链三步
- 4.2 定位结论
- 五、修复一:大事务拆小——治理"延迟放大系数"
- 5.1 拆小模板(可直接抄)
- 5.2 前后对比(同量 50 万行实测)
- 5.3 把规范立起来
- 六、修复二:并行复制——把回放能力从 6K 提到 35K
- 6.1 版本演进一张表
- 6.2 配置(主从两侧都要动)
- 6.3 实测效果
- 七、修复三:关键读走主库——读一致性的四道防线
- 7.1 四方案对比
- 7.2 方案①的实现骨架
- 7.3 监控联动的自动兜底(方案③)
- 八、监控体系:心跳表 + 巡检脚本(可直接抄)
- 8.1 心跳表与定时写入
- 8.2 全从库巡检脚本(含 P0/P1/P2 分级)
- 8.3 大事务哨兵(延迟之前就报警)
- 九、常见问题
- 9.1 Seconds_Behind_Master 显示 0,但用户还在读旧数据?
- 9.2 开了 WRITESET 并行复制,大事务还是堵?
- 9.3 半同步复制(semi-sync)能解决延迟吗?
- 9.4 GTID 等待方案具体怎么实现"写后读"?
- 9.5 relay log 堆满磁盘 / 主库 binlog 被清理会怎样?
- 9.6 大事务的量化标准怎么定?有没有通用值?
- 十、总结
- 排查决策树
- 速查卡
- 一句话
- 关键数据
- 给团队的建议
评论