一个用户改了昵称,页面刷新后还是旧的。再刷新一次变新了。第三次又变旧了——因为每次请求被负载均衡打到不同从库,而各从库的复制延迟不同。代码没有 bug,数据也没有丢:写已成功,读发生在一个还没追上的副本上。
这个假设在单库时代是免费的:同一个连接读写,天然读得到自己刚写的数据。分离之后它变成一件要显式维护的事,而代码里没有任何地方标记"这里依赖了它"。
读写分离的代价几乎全部落在这一处:主从延迟把"读自己的写"这条最基本的一致性假设打破了。而这个假设在很多业务里是默认成立的,直到分裂之后才被发现。
1. 延迟的成因与量级
| 成因 | 典型量级 |
|---|---|
| 网络与传输 | 毫秒级 |
| 从库单线程重放 | 十毫秒到秒级 |
| 大事务 | 秒级到分钟级 |
| 从库负载过高 | 不确定 |
| DDL 与批量操作 | 分钟级 |
第三项与第五项是"突发型延迟"的主因:一个批量更新在主库几秒完成,在从库可能要几十秒重放,这段时间里读到的全是旧数据。
第二项(单线程重放)在 MySQL 5.7 之前是硬约束,之后有了并行复制,但并行度受"同一事务/同一库"的限制——热点表的写入仍然串行重放,所以"并行复制"并不能消除延迟。
第四项容易被忽略:从库承担了全部读流量时,它的负载可能高于主库,于是复制线程抢不到 CPU,延迟进一步扩大——形成正反馈。
2. 五种应对策略
| 策略 | 代价 |
|---|---|
| 写后一段时间内读主库 | 需要标记"刚写过什么" |
| 关键读全部走主库 | 主库压力上升 |
| 半同步复制 | 写入延迟增加 |
| 等待指定 GTID/位点 | 实现复杂但精确 |
| 业务容忍旧数据 | 需要产品同意 |
窗口长度怎么定有个经验做法:取复制延迟的 P99 再加一点余量。定得太短等于没生效,定得太长会把主库变成读库;而这个数应当由监控给出,不该凭感觉设成"1 秒"。
// 窗口长度定法
监控:Seconds_Behind_Source 的 P99 = 420ms
窗口 = 420ms × 2 ≈ 800ms(覆盖绝大多数情况)
若 P99 经常 > 2s → 先解决复制延迟,而不是把窗口设成 5s
第一项是最常用的:在会话里记一个"最近写过的表与时间",几秒内该表的读走主库。实现简单,代价是这几秒内主库压力增加。
第四项最精确:写操作返回主库位点,读时要求从库已同步到该位点,否则等或走主库。它的成本是一次额外查询与少量等待。
// 读写路由:写后短窗口内读主库
public class ReadWriteRouter {
private final ThreadLocal<Map<String, Long>> wroteAt = ThreadLocal.withInitial(HashMap::new);
public void markWrite(String table) { wroteAt.get().put(table, System.currentTimeMillis()); }
public boolean shouldReadMaster(String table) {
Long t = wroteAt.get().get(table);
return t != null && System.currentTimeMillis() - t < 800; // 800ms 窗口
}
}
// 注意:ThreadLocal 只在同一次请求内有效;跨请求需放到会话或 cookie
3. 会话内标记的失效点
| 失效点 | 现象 |
|---|---|
| 跨请求丢失 | 下一次请求又读从库 |
| 多实例共享 | 标记只在写过的那台机器上 |
| 异步写 | 写完成时间与标记时间不一致 |
| 缓存了旧值 | 走了主库仍拿到缓存 |
还有一种"半失效":标记存在但被负载均衡器绕过——比如同一用户的不同请求走了不同区域,而标记只在一个区域内共享。这类问题只在多区域部署里出现,且极难复现。
第二项最容易被忽略:应用是多实例的,标记存在本机内存里,用户下一次请求打到另一台实例时标记就没了。所以跨请求场景必须把标记放进共享存储(会话、Redis)或 cookie。
第三项在消息驱动的写入里很常见:"写"是发消息,真正落库在消费者里,此时在生产者侧标记毫无意义。
// 跨请求标记的三种落地方式
1. cookie 带 "last_write_at"(简单,但客户端可伪造)
2. 会话存储(Redis),按 user_id 查(推荐)
3. 强制该用户 N 秒内读主库(最简单,代价是主库压力)
4. 半同步复制的取舍
| 模式 | 写入延迟 | 数据安全 |
|---|---|---|
| 异步 | 最低 | 主库崩溃可能丢 |
| 半同步(一从确认) | 增加一个网络往返 | 至少一从有 |
| 全同步 | 最高 | 全部有 |
第二项是多数金融类系统的选择:它把"提交成功"的含义从"写入主库"改成"至少一个从库收到"。代价是每次写入多一个往返,通常几毫秒。
还有一个"看起来半同步其实不是"的情形:多从库时只有一个确认就返回,如果那个从库恰好落后,你仍然可能读到旧数据——一致性保证是"至少一个从库有",而不是"所有从库都有"。
要注意半同步的降级行为:从库超时时,很多实现会自动降回异步,此时一致性保证消失而无人知晓。所以要有"当前复制模式"的监控。
-- 检查当前复制状态与延迟(MySQL 8)
SHOW REPLICA STATUS\G -- 关注 Seconds_Behind_Source、Replica_IO_Running
-- 半同步状态
SHOW STATUS LIKE 'Rpl_semi_sync%';
-- 关注 Rpl_semi_sync_source_status(ON 表示仍在半同步)
延迟的可见性本身就是个工程问题:多数团队没有"我在读哪个副本"的日志,于是重现问题时无法确认"这次读是不是打到延迟的从库上"。加一个字段(db_target: replica-2)成本极低,排障时直接省掉一轮猜测。
// 在数据访问层记录实际使用的数据源,供日志与排障使用
MDC.put("db_target", router.targetFor("orders")); // master / replica-1 ..
// 日志里带上 db_target → 用户报"数据不对"时,一眼确认读的是哪个副本
5. 业务层面的容忍设计
| 设计 | 做法 |
|---|---|
| 写后回显 | 用写请求的返回值渲染 |
| 乐观更新 | 前端先改,后台校准 |
| 明确"可能有延迟" | UI 提示"稍后生效" |
| 关键路径读主 | 支付、余额 |
第四项(关键路径读主)要列清单而不是"感觉重要的都读主":支付、余额、库存扣减后的查询通常读主,而"我的订单列表"可以读从。清单应当写进代码注释或架构文档,否则新人会按直觉加。
第一项最省钱:写完直接用返回值渲染页面,不去读库,用户不会看到旧数据。这需要在写接口返回足够的信息,而不能只回 {"ok": true}。
第二项把延迟藏起来:前端先显示新值,若后台读到旧值也不回滚。它适合"最终一定一致"且"短暂不一致无害"的场景。
6. 排错:五种"读到的数据不对"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 刷新几次才对 | 读到不同从库 | 写后窗口读主 |
| 只有部分用户受影响 | 会话标记缺失 | 标记放共享存储 |
| 延迟越来越大 | 从库负载过高 | 扩从库或分流 |
| 大批量操作后延迟暴涨 | 大事务重放 | 拆小批量 |
| 已读主库仍旧 | 缓存了旧值 | 清缓存或双删 |
第五项是最容易误判的:排查方向一直是复制延迟,实际是应用缓存没失效。判据很简单:直接查数据库(绕过缓存)看是否为新值。
7. 边界:什么情况可以不管延迟
| 场景 | 为什么 | 做法 |
|---|---|---|
| 报表与统计 | 容忍分钟级 | 读从库即可 |
| 列表与搜索 | 短暂旧值无感 | 读从库 |
| 只读展示(无写) | 无"读自己的写" | 读从库 |
| 有缓存层兜住 | 缓存已是权威 | 关注缓存一致性 |
最后一条与"观测"有关的边界:复制延迟要按从库分别看,而不是看平均值。五个从库中有一个延迟 8 秒,平均值可能只有 1.6 秒——而那个从库上的用户全都在看到旧数据。
判断口径一句话:这次读会不会是"某个用户刚写的东西"。会,就必须保证读到主库或已同步的副本;不会,从库足够。
8. 自测
- 读写分离打破的最基本假设是什么?为什么它在很多业务里"默认成立"?
- 五种延迟成因里,哪两项造成"突发型延迟"?为什么从库负载会造成正反馈?
- 为什么"会话内标记"在多实例部署下会失效?三种落地方式各有什么代价?
- 半同步复制把"提交成功"的含义改成了什么?它的降级行为为什么危险?
- "写后直接用返回值渲染"为什么最省钱?它要求写接口返回什么?
- 判断"是否需要处理延迟"的那个问题是什么?