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 应主动摘除该从库。