一台规格不大的从库,在最初的"只读副本"用途之外,被逐步叠加了报表、在线业务等多类负载。每一次新增看起来都"够用",监控也显示 CPU 长期 < 20%。

但当某一天一条长查询恰好出现时,整条链路瞬间击穿:4 个并行 Worker 在反复重试后耗尽预算,SQL Thread 直接停止,主从延迟从 0 飙升到数小时。

这不是关于"哪一个负载有问题",而是关于多类负载如何在低配从库上层层叠加、最终压垮主从复制的故事。


1. 一台"看似空闲"的从库,背后承载了什么

很多团队的从库都经历过这样一个负载叠加史

                   ┌──────────────────────────────────────┐
   时间轴 →         │ 从单一复制回放 → 逐步叠加多类业务负载  │
                   └──────────────────────────────────────┘

   T0 上线         T1 接入读流量       T2 接入报表/BI
   ┌─────────┐    ┌──────────┐       ┌──────────────┐
   │ 复制回放 │    │ 在线只读  │       │ 报表 / BI 查询 │
   │  (原生)  │ → │ 复制回放  │  →    │ 在线只读      │
   └─────────┘    │  (原生)  │       │ 复制回放      │
                  └──────────┘       │  (原生)       │
                                     └──────────────┘

   CPU < 5%       CPU < 10%         CPU < 20%
   够用 ✅         够用 ✅            够用 ✅
                                    (直到某天)

每一次"新增负载"的决策都有充分的合理性

  • “现在从库 CPU 这么低,加个报表查询完全没问题”
  • “BI 用的是只读账号,又不会影响写入,接进来就行”
  • “在线业务只读这点流量根本算不上压力”

监控数据也支持这些决策——CPU 利用率长期 < 20%,看起来余量充足

但这是个统计陷阱:CPU 利用率反映的是"平均忙闲度",掩盖了一个关键事实——多类负载共享同一台实例的算力,任何一类的突然抖动都会侵占其他类的可用空间

而所有这些负载里,复制回放虽然是从库的"原生职责",却也是最敏感、最脆弱的那一个——它没法主动控制自己的工作量(由主库写入决定),也没法在算力紧张时"降级"。一旦其他负载吃光了算力余量,它岌岌可危


2. 各类负载的特征对比

每一类负载对从库的影响方式不同。按"原生 → 后加"的顺序逐层看:

2.1 第一层:复制回放(原生职责,也是最脆弱的)

特征:

  • 由主库写入流量决定,从库无法主动控制——主库写多少,从库就必须回放多少
  • MySQL 8 默认使用 MTS(多线程复制),多个 Worker 并行回放
  • 对单事务持锁时长极度敏感——任何延迟都会被 slave_preserve_commit_order 约束级联放大
  • 没有"降级"开关——不像业务读可以限流、报表可以错峰,复制不能"等忙完了再做",否则主从延迟直接累积

对从库的影响:平时几乎没存在感(很少占用大量 CPU),但一旦其他负载吃光了算力余量,复制回放会成为第一个崩溃的环节

具体机制详见附录 A,简单说:

  • 主库一段同表的批量写入,到了从库被 MTS 分给多个 Worker 并行回放
  • slave_preserve_commit_order = ON(保证从库提交顺序与主库一致)约束下,后做完的 Worker 即便事务完成了也不能 commit、锁不释放
  • 任何 Worker 因 CPU 紧张被切出,就会让整条流水线卡住

2.2 第二层:在线只读业务(后加)

特征:

  • 短查询(通常 < 100ms)
  • 高频但单次成本低
  • 业务高峰期 vCPU 占用有节奏波动

对从库的影响:基本可控,平时占用很少 CPU。但它会让"CPU 利用率"这个指标显得更平稳——掩盖后续叠加负载的真实影响。

2.3 第三层:报表 / BI 查询(后加,且不可控)

特征:

  • 中等到高复杂度的聚合查询
  • 单次执行时间跨度极大:从几秒(轻量报表)到数小时(失控的 BI 查询)
  • 查询复杂度由业务/分析师即时决定,不可预测
  • 可能存在客户端自动重试 / 仪表盘自动刷新机制

对从库的影响:这是从"可控"变成"不可控"的关键转折。两个典型风险:

风险一:执行时间会随数据量增长而漂移

  • 今天 10 秒的报表查询,半年后可能变成 100 秒
  • 监控不一定能及时发现这个变化
  • 等"突然"出问题时,其实是慢慢恶化的结果

风险二:单条失控查询就能砍掉大半算力

  • 一条全表扫描或缺少索引的 SELECT 可以独占 1 个 vCPU 持续数小时
  • 在 2~4 vCPU 的小规格实例上,意味着直接砍掉 25%~50% 的总算力
  • 即便 DBA 手动 kill,BI 客户端的自动重试也会让查询很快再起来

这是事故剧本中最常见的"引爆点"


3. 压垮时刻:临界值是如何被击穿的

到这里所有"零件"都集齐了。让我们看一次完整的击穿过程:

3.1 触发条件

某天早上,前 N 天都好好的

┌────────────────────────────────────────────────────┐
│ 从库状态(平时):                                       │
│   vCPU 使用率:  ████░░░░░░░░░░░░ 15%                 │
│   - 复制回放:    ███ 5%                              │
│   - 在线只读:    ███ 5%                              │
│   - 报表/BI:     ██  3%                              │
│   - 系统/其他:   ██  2%                              │
│                                                    │
│ 单事务持锁时间: ~5ms (10000× 安全余量)               │
│ 复制状态:      Healthy                              │
└────────────────────────────────────────────────────┘

然后某个分析师在 BI 工具里跑了一条全表扫描的查询,这条查询持续了 23 小时不结束(可能是 SQL 写错了、可能是数据量爆炸、可能是 BI 工具的卡片刷新机制)。
在这里插入图片描述

┌────────────────────────────────────────────────────┐
│ 从库状态(BI 长查询出现后):                            │
│   vCPU 使用率:  ████████████████ ~60%               │
│   - BI 长查询:  ████████ 50% (独占 1 整个 vCPU)      │
│   - 其他业务:   ████░ 10% (挤在剩余 vCPU 上)         │
│                                                    │
│ 关键: 假设这是 2 vCPU 实例                            │
│   → 1 vCPU 被 BI 完全占满                            │
│   → 剩下 1 vCPU 要承载所有其他负载                    │
│   → 4 个复制 Worker + 业务读 + 系统进程 全挤一核      │
└────────────────────────────────────────────────────┘

3.2 业务批量insert,触发并行回放

业务程序对 t_user_tag_mapping 发起一段密集 INSERT(疑似批量授权刷新)。这些事务在主库被 group commit 在同一个 commit_parent 下,到了从库就可以被多 worker 并行回放。
在这里插入图片描述

  • Coordinator 把 4 个事务派发给 4 个 Worker,各自尝试加行锁 4 个 worker
  • 收到事务后并发执行:W1→行A,W2→行B,W3→行C,W4→行A+B(注意 W4 要写两行,且其中之一与 W1 重叠)。这就是冲突的种子。
    在这里插入图片描述

3.3 资源争用,持锁放大

特别注意这里的CPU,实际使用率已经100%,触发频繁争用。为了说明其中一个被大查询持续占用,vCPU这里没有画成争用状态,实际是一样的。
在这里插入图片描述

innodb_lock_wait_timeout 默认 50 秒。平时每个 Worker 持锁 ~5ms,离阈值有 10000× 安全余量。但现在每个 Worker 在 1 vCPU 上时分复用,拿到 CPU 的时间片很碎,持锁期间频繁被切出:

正常情况:  ▓▓▓▓▓ (5ms 拿锁→执行→释放,一气呵成)

CPU 紧张:  ▓▓░░░░░░░░░░▓▓░░░░░░░░░░▓▓░░░░░░░░░░▓▓
           拿锁  被切出  得到CPU  又被切出   再得到   释放
           ↑                                       ↑
           持锁时间 = 几秒 (拿锁到释放的总挂钟时间)

持锁时间被拉到秒级,离 50s 阈值只剩 10× 余量,撞上只是时间问题
在这里插入图片描述

3.4 重试机制加速崩溃

MySQL 默认 replica_transaction_retries = 10,Worker 触发锁等待后会自动 rollback + 重试。

10 次重试在正常环境下绰绰有余——单次冲突大概率第 1~2 次重试就过了。但在"CPU 持续紧张"的环境下:

重试 1: 拿锁 → 被切出 → 等 50s 超时 → rollback (消耗 50s + 一些 CPU)
重试 2: 同样的 Worker、同样的锁分布、CPU 更紧张
       → 拿锁 → 被切出 → 等 50s 超时 → rollback
重试 3: 重试本身消耗的 CPU 让其他 Worker 更拿不到 CPU
       → ...
...
重试 10: 最后一次,仍然失败
        → Coordinator 收到永久错误
        → SQL Thread 停止 (Slave_SQL_Running = No)

重试本身消耗 CPU,进一步让其他 Worker 拿不到 CPU,形成正反馈陷阱。约 25 分钟内 10 次重试全部耗尽。
在这里插入图片描述

反复重试中,worker 持锁顺序混乱:W1 持 A 等 C;W2 持 B 等 A;W3 持 C 等 B → 形成闭环。InnoDB 死锁检测器选 Worker 2 为牺牲者强制回滚(错误 1213)。
在这里插入图片描述

3.5 重试耗尽,复制停止

  • Worker 2 的 1213 报错传到 Coordinator
  • 几乎同一秒 Worker 1 和 Worker 3 也打到 10/10 的 1205
  • Coordinator 收到第一个永久性错误 → 整条 SQL Thread 停止
  • IO Thread 仍在运行 → relay log 继续被灌入但不再消化 → 延迟无限累积
    在这里插入图片描述

4. 完整因果链

把整个过程串起来:

┌──────────────────────────────────────────────────────────────────┐
│ 长期叠加 (数月~数年):                                                │
│   原生职责:  复制回放                                                │
│   + 后加:    在线只读  →  报表/BI 查询                               │
│   每次新增看起来都"够用",但累积侵占了算力余量                          │
│   ── CPU 利用率指标"看起来正常",掩盖了真实余量                        │
└──────────────────────────┬───────────────────────────────────────┘
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│ 引爆瞬间 (某次):                                                   │
│   一条 BI 长查询出现,独占 1 个 vCPU 持续数小时                       │
│   ── 在 2 vCPU 实例上,直接砍掉 50% 算力                       │
└──────────────────────────┬───────────────────────────────────────┘
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│ 复制回放被首先击穿:                                                 │
│   MTS 多 Worker 在剩余算力上时分复用                                │
│   → 单事务持锁时间从 ms 级拉到秒级                                  │
│   → 撞 innodb_lock_wait_timeout = 50s 阈值                          │
│   → 触发 Worker 自动重试                                            │
│   → 重试本身又消耗 CPU,形成正反馈                                    │
│   → replica_transaction_retries = 10 次预算耗尽                     │
│   → SQL Thread 停止                                                 │
└──────────────────────────┬───────────────────────────────────────┘

这条链子上每一环单独看都"问题不大"

  • 长期叠加负载 → 看监控觉得余量充足
  • BI 长查询 → 偶发,平时少见
  • MTS + 默认 retries → MySQL 出厂配置

只有同时全部成立,才会触发事故。这就是为什么这类问题在事故发生前几乎不会被预警系统识别——每一环都"看起来正常"。

5. 实践应对建议

针对这条因果链,可以从四个维度做防御:

5.1 容量评估:

  • 从库配置不应死板地仅for成本节约用最低配,当从库用途扩张(新接入 BI / 报表 / 新业务)时,业务应重新评估容量——不要"温水煮青蛙"。

5.2 架构隔离:剥离不可控负载

BI / 报表 / 长查询是"不可控负载"的典型——查询复杂度由用户即时决定,单条查询可以独占 1 个 vCPU 数小时。

建议:

  • 给这类负载建独立的"分析副本",与"在线读副本 + 复制回放"物理隔离
  • 即便 BI 副本被失控查询打满,也不会影响主复制链路
  • 给 BI 用户加 MAX_EXECUTION_TIME 限制(如 30 分钟)
  • 客户端侧自动重试设置上限,避免 kill 操作失效

5.3 复制参数:把默认值改成"为本场景准备"的值

参数 默认值 建议 理由
replica_transaction_retries 10 32 或更高 给 Worker 更多容错预算,避免临时性 CPU 紧张直接打死 SQL Thread
slave_preserve_commit_order ON 保留 ON 关闭存在数据一致性风险(详见附录 A)
innodb_lock_wait_timeout 50 保持默认 调大会掩盖问题

5.4 监控告警:SBM 不是可靠指标,要用组合告警

不要只配 Seconds_Behind_Master > N 这一种告警——SQL Thread 停止时 SBM 可能变 NULL,告警可能根本不触发。

- Slave_SQL_Running != Yes
  → 立即告警 (oncall)

- Seconds_Behind_Master > 60s 持续 2min
  → 延迟告警

- 主库 BinLogDiskUsage 持续增长至xx G
  → 侧面证据,可作为参考,同时感知主库事务变化量
  → 原理: SQL Thread 停止 → relay log 不再消费 → 主库 binlog 持续累积


附录 A:MTS 多 Worker 并行回放与 commit_order 机制

本附录解释正文 §2.4 提到的"复制回放对持锁时长极度敏感"的具体机制——为什么 MTS 多 Worker 在 slave_preserve_commit_order=ON 下容易卡。

A.1 MTS(多线程复制)原理

MySQL 8 默认使用 MTS:把主库 binlog 中同一个 commit 组的事务分给多个 Worker 并行执行。这是为了提升复制吞吐——单线程复制在主库高并发场景下会成为瓶颈。

binlog (主库写入顺序):
  T1 (user=100, tag=10)
  T2 (user=100, tag=15)    ← 这 4 个事务被打到同一个 commit 组
  T3 (user=100, tag=20)
  T4 (user=100, tag=25)

从库 Coordinator 分配:
  Worker 1 ← T1
  Worker 2 ← T2
  Worker 3 ← T3
  Worker 4 ← T4

A.2 slave_preserve_commit_order 的两面性

这个参数保证从库 commit 顺序与主库严格一致

  • 必要性:failover 安全性、外键 / 触发器一致性的基础。关闭存在数据不一致风险——从库提升为主库时可能出现数据逻辑错误,对依赖外键 / 触发器的应用尤其危险
  • 副作用:任一 Worker 卡住(重试 / 锁等待)就会阻塞所有 Worker
Worker 2 先做完 T2,想 commit
  → commit_order=ON 强制等 Worker 1 (T1) 先 commit
  → Worker 2 持着 T2 的锁不能释放
  → Worker 1 正好需要 T2 持有的某个锁来完成 T1
  → 死结!

这就是 “后做完的不能 commit、锁不释放、前面的等不到锁、整条流水线卡死” 的根本原因。

A.3 主库为什么没有这个问题

主库没有 commit 顺序约束。多个事务遇到锁竞争时:

T1: INSERT → 拿锁 5ms → commit → 释放锁
                                  ↓
T2: INSERT → 等锁 5ms → 拿锁 → commit → 释放锁
                                          ↓
T3: ...

T1 一旦做完就立刻 commit、释放锁,T2 紧接着就能拿到。整个等待链以"流水线"方式自然解开,单事务的锁竞争窗口在 ms 级。

A.4 主从行为对比

维度 主库 从库(MTS + commit_order=ON)
commit 顺序约束 必须按 binlog 顺序
锁释放时机 事务一完成立刻释放 必须等所有前序事务 commit 后才能释放
锁等待解决方式 先到先得自然解开 容易形成"后序持锁等前序 commit"死结
单事务持锁窗口 ms 级 可能秒级(被强制等待)
innodb_lock_wait_timeout=50s 概率 几乎为 0 视算力而定

结论:从库比主库多了一个 commit 顺序约束,这让"任何 Worker 的暂时卡顿"都可能级联放大。在算力充足时不显,在算力紧张时致命。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐