37.4 读写分离
一句话
读写分离把 “读” 的水平扩展交给多个副本,写仍然只有一个主库;它换来的读吞吐是有价格的——复制延迟导致的读不一致,且它永远解决不了写瓶颈。
先算一笔账:什么情况下有用
读吞吐上限 = 从库数量 × 单从库读能力
写吞吐上限 = 主库写能力(不变)
| 场景 | 读写比 | 读写分离的收益 | 结论 |
|---|---|---|---|
| 商品详情、内容 feed | 20:1 – 100:1 | 读能力 ×N,写压力不变 | ✅ 典型受益 |
| 订单查询后台 | 10:1 | 读能力 ×N | ✅ 受益 |
| 日志 / 埋点写入 | 1:100 | 读不是瓶颈 | ❌ 无效 |
| 秒杀扣库存 | 1:1,且写是瓶颈 | 主库仍是唯一瓶颈 | ❌ 无效,应分库分表 |
| 强一致账务查询 | 任意 | 读到旧数据就是事故 | ⚠️ 必须写后读主 |
🔑 读写分离是读扩展手段,不是通用扩容手段。读写比低于 5:1 时,多出来的从库只是增加了成本和延迟故障面。
整体结构
┌──────────────┐
应用 ──写──────▶ │ 主库(RW) │
│ └──────┬───────┘
│ │ binlog / WAL 异步复制
│ ┌──────┴───────┬──────────────┐
└──读──────▶ │ 从库 1(RO) │ 从库 2(RO) │ ...
└──────────────┴──────────────┘
路由层:客户端 SDK / 代理中间件(ProxySQL、MyCat、ShardingSphere)
三个必须想清楚的选择:
| 选择 | 选项 | 影响 |
|---|---|---|
| 路由在哪 | 客户端 SDK / 独立代理 / ORM 内置 | SDK 无额外跳数但要多语言实现;代理统一但多一跳(0.2–1ms) |
| 复制方式 | 异步 / 半同步 / 组复制 | 异步延迟最小但可能丢数据;半同步牺牲写延迟换不丢 |
| 一致性策略 | 全读从库 / 写后读主 / 按业务分级 | 决定用户会不会「刚下单查不到订单」 |
复制延迟从哪来:六种成因,量级差三个数量级
| 成因 | 典型延迟 | 说明 |
|---|---|---|
| 同机房异步复制 | 0.1–5ms | 网络 + 从库回放 |
| 跨可用区复制 | 1–3ms | 同城光缆 RTT |
| 跨地域复制 | 30–100ms | 距离决定,无法优化掉 |
| 大事务(一次改 10 万行) | 秒级到分钟级 | 从库必须整事务回放,期间所有表都滞后 |
| DDL(加索引 / 改表) | 分钟级 | 大表 DDL 可能锁住回放线程 |
| 从库上跑大查询 / 备份 | 不可预测 | 资源被抢,回放线程饿死 |
⚠️ 最关键的一条:复制延迟不是 “平均 3ms” 这么温和。它是长尾分布——平时 1ms,一次大事务就飙到 60s。用平均值做设计,等于假设 “大事务不会发生”。
读一致性:四档策略,按业务选
| 策略 | 做法 | 延迟影响 | 一致性 | 适用 |
|---|---|---|---|---|
| ① 全读从库 | 读永远走从库 | 最低 | 弱(可能读到旧数据) | 商品详情、评论列表、排行榜 |
| ② 写后读主(sticky) | 写过的会话在窗口 T 内读主库 | 该会话变慢 | 会话级读己之写 | 下单后查订单、改资料后回显 |
| ③ 位点等待 | 带上 GTID / LSN,等从库追到该位点再读 | 可能阻塞 | 因果一致 | 支付结果查询、库存校验 |
| ④ 强制读主 | 关键读直接走主库 | 主库压力上升 | 强 | 余额、库存扣减前的校验 |
策略 ② 的两个参数怎么定
窗口 T > 主从延迟 P99 + 客户端重试间隔
- 延迟 P99 = 200ms → 窗口至少 500ms;
- 跨地域复制 80ms → 窗口 1–2s;
- 窗口内该用户的所有读都压到主库:一个刚下单的用户会连续 3–5 次读主库,峰值时要把这部分压力算进主库容量 (通常占读流量 1%–5%)。
💡 窗口不要开太大。T=30s 意味着 “用户逛了半分钟,读的全是主库”,等于给主库加了 30% 读流量而收益趋近于零。
位点等待 (策略 ③) 为什么更精确
写主库时拿到 GTID / binlog 位点,塞进用户的会话 (cookie / token / 上下文)。读从库前比较从库已应用位点:
-- MySQL 8.0:等到从库追平指定 GTID 再读,超时则回主库
SELECT WAIT_FOR_EXECUTED_GTID_SET('3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5', 0.2);
追不上就回主库——用一次慢读换正确性。这比时间窗口精确,因为它盯的不是时间,是数据位点。
可运行的 Go 路由实现
package main
import (
"fmt"
"sync"
"time"
)
type node struct {
name string
data map[string]int64
}
func (n *node) get(id string) (int64, bool) {
v, ok := n.data[id]
return v, ok
}
// router 读写分离:写走主库、读走从库,
// 但「刚写过」的会话在 window 内强制读主库(read-your-writes)。
type router struct {
primary *node
replica *node
window time.Duration
mu sync.Mutex
sticky map[string]time.Time // 会话 -> 允许回落从库的时刻
}
func newRouter(p, s *node, window time.Duration) *router {
return &router{primary: p, replica: s, window: window, sticky: map[string]time.Time{}}
}
func (r *router) write(session, id string, amount int64) string {
r.primary.data[id] = amount
r.mu.Lock()
r.sticky[session] = time.Now().Add(r.window)
r.mu.Unlock()
return r.primary.name
}
func (r *router) read(session, id string) (int64, string, bool) {
r.mu.Lock()
until, sticky := r.sticky[session]
r.mu.Unlock()
target := r.replica
if sticky && time.Now().Before(until) {
target = r.primary // 写后读一致:窗口内回主库
}
v, ok := target.get(id)
return v, target.name, ok
}
// replicate 模拟从库复制追上主库(真实系统是 binlog 异步回放)。
func (r *router) replicate() {
for k, v := range r.primary.data {
r.replica.data[k] = v
}
}
func main() {
primary := &node{name: "主库", data: map[string]int64{}}
replica := &node{name: "从库", data: map[string]int64{}}
// ① window=0:纯读写分离,写后立刻读从库
naive := newRouter(primary, replica, 0)
naive.write("alice", "order-1", 9900)
if v, src, ok := naive.read("alice", "order-1"); ok {
fmt.Printf("① 写后立刻读:%s 命中 order-1 = %d\n", src, v)
} else {
fmt.Printf("① 写后立刻读:%s 未命中 ← 复制延迟,用户看到「订单不存在」\n", src)
}
// ② window=200ms:写后读主,窗口过后回落从库
r := newRouter(primary, replica, 200*time.Millisecond)
r.write("alice", "order-2", 19900)
v, src, ok := r.read("alice", "order-2")
fmt.Printf("② 写后 0ms 读:%s 命中=%v 金额=%d\n", src, ok, v)
time.Sleep(250 * time.Millisecond)
r.replicate() // 复制追上
v, src, ok = r.read("alice", "order-2")
fmt.Printf("③ 写后 250ms 读:%s 命中=%v 金额=%d\n", src, ok, v)
}
输出:
① 写后立刻读:从库 未命中 ← 复制延迟,用户看到「订单不存在」
② 写后 0ms 读:主库 命中=true 金额=19900
③ 写后 250ms 读:从库 命中=true 金额=19900
第 ① 行就是线上最典型的读写分离事故:下单成功后跳转详情页,用户看到 “订单不存在”,再刷新又出现了。第 ②③ 行说明修法不是 “一律读主库”,而是给刚写过的会话一个短暂的主库窗口。
延迟怎么监控:Seconds_Behind_Master 不可信
| 指标 | 是否可靠 | 原因 |
|---|---|---|
Seconds_Behind_Master | ❌ | 从库空闲时它就是 0;从库 IO 线程卡死时也可能显示 0 |
| 心跳表(每秒写一行,从库读) | ✅ | 直接测端到端可见延迟,NOW() - ts |
| GTID / LSN 差值 | ✅ | 精确到「还差多少个事务」,但不含时间 |
SHOW SLAVE STATUS 的 SQL_Delay | ⚠️ | 只反映配置的延迟复制,不是实际延迟 |
推荐组合:心跳表测时间 + GTID 差值测事务积压。告警线通常设在 P99 延迟 > 1s 或积压 > 1000 个事务。
从库数量不是越多越好
加从库会遇到四个上限:
| 瓶颈 | 表现 | 量级 |
|---|---|---|
| 主库上行带宽 | 每个从库都要发一份 binlog,主库网卡先满 | 一份 binlog 100MB/s × 5 从库 = 500MB/s |
| 主库 binlog dump 线程 | 每从库一个连接与线程 | 数十个从库后主库 CPU 明显上升 |
| 复制延迟放大 | 从库越多,主库写放大越严重 | 写 QPS 高时更明显 |
| 连接数 | 应用 × 从库数 | 20 个实例 × 5 从库 = 100 连接池 |
实践数字:单主库挂 3–5 个从库是舒适区,超过 5 个要考虑 “级联复制 (从库的从库)” 或直接上分库分表。
与其他扩展手段的分工
| 手段 | 解决什么 | 与读写分离的关系 |
|---|---|---|
| 读写分离 | 读吞吐 | 本节 |
| 加缓存 | 读吞吐 + 读延迟 | 命中时 0.1–1ms,成本低于加从库;应优先做缓存,但有一致性问题 |
| 分库分表 | 写吞吐 + 单表容量 | 读写分离解决不了的写瓶颈,靠它 |
| CQRS / 物化视图 | 复杂查询的读性能 | 读写分离的架构化版本,允许读写模型不同(32.1 / 32.3) |
| 多活 | 地域级容灾与就近读 | 读写分离的跨地域形态(36.4 两地三中心与多活) |
反模式
| 反模式 | 后果 |
|---|---|
| 全局强制读从库,不做写后读主 | 下单后查不到订单、改完资料不回显,用户反复重试 |
用 Seconds_Behind_Master 做告警 | 从库空闲显示 0,真滞后时也报 0;事故时监控是绿的 |
| 从库上跑报表 / 备份 / 大查询 | 回放线程被饿死,延迟从 ms 飙到分钟 |
| 认为读写分离能解决写瓶颈 | 写还是那一个主库,加从库毫无帮助 |
| 主库挂了直接把从库当主库用 | 从库可能还没追平,切完就丢数据(应有切换流程与 GTID 校验) |
| 从库数量无限加 | 主库上行带宽与 dump 线程先成为瓶颈,延迟反而变大 |
| 大事务(一次改 10 万行) | 从库回放期间所有读都是旧数据 |
| 用读写分离实现「强一致读」 | 异步复制下物理上不可能,只能靠写后读主或位点等待 |
| 路由层不识别事务 | 事务里的读被路由到从库,读到事务外的旧值 |
自检
Q1 产品经理说 “用户下单后跳详情页偶尔显示订单不存在,刷新一下就好”。你的系统刚上了读写分离。给出两条可能的原因和各自的验证方法。
▸ 答案:① 读走了从库且复制延迟——验证:在同一次请求链路里打印 read_from 与主从位点差,看失败请求是否都落在延迟 > 0 的从库上;也可以临时把该接口强制读主,若问题消失即确认。② 事务未提交就并发读了从库 (同一用户的另一个请求)——验证:看失败请求是否与写请求并发、且写事务耗时较长。修法不同:① 用写后读主窗口或位点等待;② 要么缩短事务,要么把 “同一会话的读” 也纳入主库窗口。
Q2 为什么 Seconds_Behind_Master = 0 不能证明从库数据是新的?
▸ 答案:这个值是 “从库当前回放的事件时间戳” 与 “从库本机时间” 之差。当从库没有新事件可回放 (主库没写入、或 IO 线程已断) 时,它恒为 0;当复制线程卡死时,也可能因为计算方式而显示 0。它衡量的是 “回放落后多久”,不是 “数据是否最新”。可靠做法是心跳表:主库每秒写一行带时间戳的记录,从库读出来算差值,直接得到端到端可见延迟。
Q3 你的库读写比是 30:1,主库 CPU 60%,从库 CPU 20%。同事建议再加 3 个从库。这个建议对吗?
▸ 答案:先看瓶颈在哪。从库 CPU 只有 20%,说明现在不是 “读能力不够”,而是读请求可能还在打主库 (路由没生效、事务内读、强制读主过多),或者瓶颈根本不在数据库 (应用层、缓存命中率低、慢查询)。盲目加从库只会同时增加主库的 binlog 发送压力和复制延迟。正确的下一步是:看主库的读 QPS 占比、慢查询 Top 10、缓存命中率——先确认 “读是否真的压在了从库上”。
Q4 一个从库延迟 60 秒,你的接口 P99 只涨了 50ms。为什么?这个状态可以忍多久?
▸ 答案:因为你的路由里有写后读主窗口和缓存,绝大多数读没打到那个从库;或者该从库权重被摘除了。P99 只涨 50ms 说明业务层还没被波及,但这是假安全:延迟 60s 意味着这个从库上的数据是 1 分钟前的,一旦有业务的读落到它、或者发生主库切换需要它顶上,就会立刻暴露。容忍时间取决于 “复制追平需要多久” 与 “主库还能撑多久”——通常延迟超过 10s 就该告警排查,超过 60s 应主动摘除该从库。