积羽沉舟 —— 多负载叠加如何压垮 MySQL 主从复制
一台规格不大的从库,在最初的"只读副本"用途之外,被逐步叠加了报表、在线业务等多类负载。每一次新增看起来都"够用",监控也显示 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 的暂时卡顿"都可能级联放大。在算力充足时不显,在算力紧张时致命。
更多推荐




所有评论(0)