2544 字
约 8 分钟
17
S11-A23 · 读写分离的延迟:刚写的数据读不到
2024-12-31
2026-11-10

一个用户改了昵称,页面刷新后还是旧的。再刷新一次变新了。第三次又变旧了——因为每次请求被负载均衡打到不同从库,而各从库的复制延迟不同。代码没有 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. 自测

  1. 读写分离打破的最基本假设是什么?为什么它在很多业务里"默认成立"?
  2. 五种延迟成因里,哪两项造成"突发型延迟"?为什么从库负载会造成正反馈?
  3. 为什么"会话内标记"在多实例部署下会失效?三种落地方式各有什么代价?
  4. 半同步复制把"提交成功"的含义改成了什么?它的降级行为为什么危险?
  5. "写后直接用返回值渲染"为什么最省钱?它要求写接口返回什么?
  6. 判断"是否需要处理延迟"的那个问题是什么?
S11-A23 · 读写分离的延迟:刚写的数据读不到
https://www.clxhxhhr.top/posts/6472/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。