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 TPSWRITESET 并行 ~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 区间

解读顺序固定为四步:

  1. Slave_IO_Running / Slave_SQL_Running——先排除断流/报错型故障(任一为 No 走另一套排查:Last_Errno、网络、磁盘)。
  2. Retrieved_Gtid_Set 与 Executed_Gtid_Set 的区间差——量化积压:收到 10290、只回放到 10112,中间 178 个事务在排队。这是比 SBM 更精确的积压刻度。
  3. Seconds_Behind_Master 的趋势而非瞬时值:每秒 +1 的线性爬升 = 确定性积压;反复跳变 = 毛刺型延迟。
  4. 主库与从库的 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 &lt; #{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.6schema 级并行不同库的事务可并行单库应用几乎无效
MySQL 5.7LOGICAL_CLOCK同一组提交(group commit)内的事务可并行主库组提交越密收益越大
MySQL 8.0WRITESET基于行冲突检测,无冲突即并行,跨组也可收益最大,首选

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 workerslave_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
公众号:服务端技术精选
    评论
    0 评论
avatar

取消