18.4 复制延迟与读一致性

一句话

异步复制下,“写完立刻读” 有 60% 概率读到旧值(本机模拟);读一致性不是 “把从库加个开关”,而是要在 会话/版本 层面设计——你要的是 “读己之写”,还是 “最终一致”,必须先说清。

四种读一致性

级别 保证 实现手段 代价
强一致 读到的必是最新已提交 读主库 / 多数派读 主库压力、延迟
读己之写 自己写的,自己一定读到 会话粘性、写后读主、版本等待 需会话/版本信息
单调读 不会读到 “更旧” 的版本 绑定副本/版本单调 副本切换要小心
最终一致 一段时间后一致 异步复制 窗口内可能读到旧值

实测:无粘性 vs 会话粘性

2 万次 “写后 0–200ms 读”、复制延迟 120ms:

策略 陈旧读 比例
无会话粘性(随机读副本) 12,013 60.1%
会话粘性(写后读主库) 0 0%

60% 这个数字说明:只要 “读” 落在复制延迟窗口内,就大概率读到旧值——这不是 “偶发”,是默认行为。

// 写后 N 秒内读主库(最简单可靠)
func (s *Service) GetAfterWrite(ctx context.Context, id string) (User, error) {
    if s.session.WroteRecently(id, 2*time.Second) {
        return s.master.Get(ctx, id)      // 读主
    }
    return s.replica.Get(ctx, id)
}

三种 “读己之写” 实现

实现 做法 适用
写后读主(时间窗) 会话记录 “最近写过”,窗口内读主 简单、通用
版本等待 客户端带上 “我写到哪个版本”,副本等到 ≥ 该版本 精确、需版本号
会话粘性 同一会话固定路由到同一副本 负载均衡要支持

反模式

反模式 后果
写完立刻读从库,且不处理 用户看到 “刚改的没生效”,投诉
用 “复制延迟很小” 当理由 高峰期延迟变大,问题在高峰暴露
只做主从,不做会话一致性 60% 级别的陈旧读(见实测)
用 “读主库” 解决所有读 主库压力翻倍,扩展性丢失
副本切换后不校验版本 单调读被破坏,用户看到 “回退”
不监控复制延迟 不知道窗口有多长,无法设阈值

三条铁律

🔑 先声明一致性级别,再选实现;“最终一致” 也要写进契约。

🔑 读己之写靠会话/版本,不靠 “延迟很小”。

🔑 复制延迟要监控;它是 “陈旧窗口” 的唯一度量。

自检

Q1 为什么 “写完立刻读” 会读到旧值,而且概率这么高?

▸ 答案:因为异步复制的延迟窗口(本例 120ms)覆盖了 “写后读” 的时间范围(0–200ms):只要读发生在 “主库已提交、备库还没重放” 的窗口里,就会读到旧值。实测 60% 正是这个窗口与读时间的重叠比例。

Q2 “读己之写” 与 “强一致” 的区别?

▸ 答案:强一致要求所有读者都看到最新值;读己之写只要求写的人自己看到。前者成本高(多数派读/读主),后者可以用 “会话粘性/版本等待” 低成本实现。多数业务真正需要的是后者。

Q3 版本等待怎么实现?

▸ 答案:写成功时返回一个版本号/位点(如 LSN、GTID 或单调递增的版本),客户端后续读带上它;副本在 “已重放到 ≥ 该版本” 前不返回(等待或报错重试)。这样既不总是读主,也保证 “至少看到自己写的”。

Q4 为什么 “单调读” 也需要设计?

▸ 答案:因为多副本延迟不同:第一次读副本 A(较新)、第二次读副本 B(较旧),用户会看到 “数据回退”。单调读要求 “同一会话的读版本不倒退”,实现方式是 “绑定副本” 或 “记录已读版本,低于它的结果不接受”。

Q5 复制延迟多少算 “可接受”?

▸ 答案:取决于业务:读己之写要求窗口内能兜住(如 < 1s);报表/分析可以分钟级;资金/库存通常不能容忍,必须读主或用共识。没有统一阈值,必须按 “读到旧值的后果” 定。

Q6 主库压力大,又不能接受陈旧读,怎么办?

▸ 答案:三条:读写分离只用于可容忍陈旧的查询(列表、详情可缓存);关键读走主库(按接口分级);用共识/同步复制(代价是写延迟)。先按业务分级,再决定哪类读必须强一致。

Q7 怎么衡量 “读一致性有没有做好”?

▸ 答案:两个指标:陈旧读比例(采样:写后读,比对版本)与 P99 复制延迟。前者告诉你 “用户是否真的看到旧值”,后者告诉你 “窗口有多长”。只看延迟不看陈旧读,容易自我感觉良好。

关联