MySQL 主从读写分离 read-your-write 缺失 3 年酿成日均 12 万次读到旧值的复盘:sticky + hint + cache 三层一致性方案落地

读写分离上线 3 年默认所有 SELECT 走从,从未系统设计哪些场景必须读主。客诉 37 单挖出日均 12 万次读到旧值事件根因。5 天复盘 + 4 层物理延迟拆解 + 7 种 read-your-write 方案横向对比 + 决策树 + 9 条读写一致性纪律,真实事件量降到日均 180 次。

2026 年 4 月某个周一上午,客服群里一条投诉:"用户说他刚改了手机号,马上回到个人中心却显示老手机号,过了两三秒刷新才正确。这是 bug 吗?"运营把对话截图甩过来,我看了一眼时间戳——9:42:11 用户提交修改,9:42:13 再次刷新看到旧数据,9:42:16 再刷新看到新数据。3 秒。如果这是个偶发,我们会归因为"用户网络抖动";但接下来 2 个月,我们陆续收到 37 单类似投诉,场景五花八门——改密码后用旧密码还能登、删了优惠券还能用、新建项目找不到、批量操作完一半结果没生效。所有现象指向同一个事:"我刚写的数据,系统让我读到了一个旧的版本"

接下来 5 天我们带着架构组把读写分离的整体链路翻了个底朝天,定位到的不是某个 bug,是一个让所有 30+ 个微服务都"恰好踩中"的设计盲区:三年前为了应对读流量增长做了 MySQL 主从读写分离,默认所有 SELECT 走从库,但没有人系统性地设计"哪些场景必须读主"。每一个独立场景看起来都"不重要",合起来就成了用户体验的持续小刀刮——37 单投诉只是冰山一角,真实数据是日均 2000+ 用户感受到"我刚改的没生效"。这篇是完整复盘,涵盖主从复制延迟的物理来源、read-your-write 的 5 种实现路径、各方案的工程权衡、以及落地的《读写分离一致性纪律》。

背景:这个老服务的读写分离架构

维度 数值
业务 SaaS 多租户管理后台,涉及账户/项目/订单/工单
规模 日均请求 1.8 亿,读写比 9:1,峰值 QPS 5000
数据库 MySQL 8.0,1 主 + 3 从(异步复制),从库延迟典型值 50-500ms,峰值 3 秒
读写分离 2023 年加入,ShardingSphere + 默认所有 SELECT 走从
主从延迟监控 Seconds_Behind_Master,告警阈值 5 秒
事故现象 "刚修改后立即查询看到旧值"的偶发投诉,2 个月 37 单,实际触发约 12 万次/天

这套架构上线 3 年,大家一直认为"主从延迟 0.5 秒级别,业务无感"。直到积累的客诉触发深入排查,才发现"无感"是因为多数用户根本不会注意,但绝对数字其实大得惊人。

事故时间线:从一条投诉到根因落地的 5 天

时刻 事件
04-22 上午 客服群 1 条投诉,我让 DBA 查最近的主从延迟,正常 100-300ms
04-22 下午 翻最近 30 天客诉,发现 19 条类似描述,意识到这是系统性问题
04-23 埋点统计:在用户操作后 5 秒内,如果同账号又发生相同实体的 SELECT,且 SELECT 命中了未同步的数据,记录一次"读到旧值"。一天统计下来:12 万次
04-24 读 ShardingSphere 的路由配置,确认默认所有 SELECT 走从,无任何"写后立即读"的强制主库路由
04-25 调研业内方案:read-your-write 的 5 种实现路径,做对比表
04-26 选定方案——session 级 sticky + 显式 hint + Redis 写后标记缓存,三层组合
04-27 ~ 04-28 实现 + 单元测试 + 灰度
05-05 全量上线,后续两周客诉降到 0,埋点统计的"读到旧值"事件降到日均 ~ 200(几乎都是异常 case)

第一反应:"主从延迟那是正常的吧"

DBA 同事最初对这个问题的态度是"这是 read replica 的物理限制,业务自己处理"。这话技术上没错,但隐含的责任划分让"业务自己处理"变成了"没人处理"。事实是大多数业务开发完全不知道自己的 SELECT 走的是从库——ShardingSphere 在中间件层做路由,业务代码看不出区别。结果就是每一段"先 update 再 select"的代码都成了潜在 bug。

这个责任划分的盲区可能是我们 3 年都没系统性 fix 的根本原因。修这个问题的第一步不是技术,是承认"读写一致性是架构层面的责任,不是业务层面"

真凶 1:MySQL 主从复制的物理延迟无法避免

先把"主从延迟从哪来"理清。MySQL 异步复制的基本流程:

  1. 主库写 binlog(同事务的所有变更打包)
  2. 从库 IO Thread 拉取 binlog 写到 relay log
  3. 从库 SQL Thread 读 relay log 执行

延迟来自每一步,典型贡献:

来源 典型延迟 峰值
主库 binlog 刷盘 1-5ms(同步刷盘) 50ms
网络传输(同机房) 0.1-1ms 10ms
从库 IO Thread 接收 1-5ms 30ms
从库 SQL Thread 重放 10-500ms 5-30 秒
从库执行慢查询 取决于负载 分钟级

SQL Thread 是延迟主要来源——它默认是单线程串行执行(8.0 起支持并行复制,但需要显式配置)。如果主库有一个大事务(比如批量更新 10 万行),从库要顺序执行完才能继续——这期间所有写都被阻塞在 relay log,延迟陡升。

大多数时候主从延迟在毫秒级,看起来无害。但有几类场景会瞬时拉到秒级:

  • 主库执行大事务(批量 import / 历史归档 / 大范围 UPDATE)
  • 主库流量陡升,从库 SQL Thread 跟不上
  • 从库刚启动追赶 binlog
  • 从库自身有慢查询占用资源

而我们的业务,只要主从延迟达到 200ms 以上,"用户改完立即刷新"就会大概率读到旧值。

因果链:为什么"无感"积累成大问题

这张图最关键的信息是"沉默累积"这一段——绝大多数用户不会因为一次"读到旧值"就开客诉,他们会刷新、抱怨两句、继续用。所以客诉数永远只是真实问题量的 1/1000 或更低。如果只盯客诉决策投入,永远修不到根本。我们后来内部立了规矩:任何"看起来体验类"的问题都要先做埋点估算真实发生量,再决定投入级别。

真凶 2:"先写后读"的反模式在代码里到处都是

我们扫描了一下所有业务代码,搜UPDATEINSERT 后 N 行内有 SELECT 同表的模式,发现 1400+ 处。其中真正需要"读自己刚写的数据"的不到 200 处——其他都是无意识的反模式。典型代码:

// Java 业务代码典型反模式
public UserDTO updatePhone(Long userId, String phone) {
    // 1. 更新数据库
    userMapper.updatePhone(userId, phone);

    // 2. 立即查询返回最新值
    // ❌ 走从库, 可能读到旧 phone
    return userMapper.selectById(userId).toDTO();
}

这种"update 后 select 返回"的代码是太多 ORM 教程的默认推荐了——理论上简单清晰。但在读写分离架构下完全是定时炸弹。修法有几种:

修法 代价
① update 后直接返回内存里的 DTO,不再 select 需要业务逻辑改造
② 强制 select 走主库 读流量回到主库,放大主库负载
③ 写后短期 (e.g. 5s) 读主库 实现复杂但平衡好
④ 用半同步复制,写后等从库 ACK 写延迟变高

没有银弹,需要按场景选择。

真凶 3:读写分离的"透明性"成了缺陷

ShardingSphere 这类中间件的卖点是"读写分离对业务透明"——业务写普通 SQL,中间件自动路由。这个透明性是双刃剑:

  • 好处:业务代码不变,无需感知主从
  • 坏处:业务无法精细控制"哪些读必须走主",出了问题不知道是哪里

对比一些更"显式"的方案,比如直接在 MyBatis 注解里标 @DataSource("master"),业务侧能清楚看到自己读的是主还是从。这种"显式优于隐式"的设计在出问题时更容易排查、更容易控制。

但我们已经在 ShardingSphere 上 3 年了,完全切换成本太高。我们的选择是在 ShardingSphere 上加 hint 机制——支持业务通过线程上下文显式指定路由:

// ShardingSphere 提供了 HintManager
public UserDTO updatePhone(Long userId, String phone) {
    userMapper.updatePhone(userId, phone);

    // 在当前线程显式指定: 接下来的查询走主库
    try (HintManager hint = HintManager.getInstance()) {
        hint.setMasterRouteOnly();
        return userMapper.selectById(userId).toDTO();
    }
}

这是显式控制,但需要业务一个一个加——不可能 1400+ 处都改。所以我们还需要一套自动化机制。

修法:三层组合 — sticky + hint + cache

修法 1:Session 级 sticky(短期读主)

核心思想:用户做完写操作后,在一定窗口期内,这个用户的所有读请求都走主库。这是 read-your-write 最经典的实现。

具体实现:写操作完成后,在 Redis 里给这个用户标记一个"刚写过"的 key,5 秒后过期。所有读路径先查这个 key,有就走主库。

// 写路径
public void updateUser(Long userId, UserUpdate update) {
    userMapper.update(userId, update);

    // 在 Redis 标记"该用户刚写过", 5 秒过期
    redisTemplate.opsForValue().set(
        "rw_sticky:" + userId,
        "1",
        Duration.ofSeconds(5)
    );
}

// 读路径 - AOP 拦截器自动处理
@Aspect
public class MasterRouteAspect {
    @Around("@annotation(ReadWithConsistency)")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        Long userId = ContextHolder.getCurrentUserId();
        if (userId != null &&
            Boolean.TRUE.equals(redisTemplate.hasKey("rw_sticky:" + userId))) {
            try (HintManager hint = HintManager.getInstance()) {
                hint.setMasterRouteOnly();
                return pjp.proceed();
            }
        }
        return pjp.proceed();
    }
}

这套机制的好处:

  • 业务代码改动小:只需在关键写路径加 redis 标记,读路径靠 AOP 自动处理
  • 对主库压力小:只有"刚写过"的用户的查询走主,绝大多数读流量仍在从
  • 5 秒窗口期足够覆盖主从延迟(99% 的情况下从库 5 秒内已同步)

代价:Redis 调用一次额外开销 ~ 1ms,可以接受。

修法 2:显式 hint(关键场景)

对于"必须读最新"的核心路径(支付、订单状态、安全相关),还是用显式 hint 强制读主,不依赖 sticky 窗口:

@ReadFromMaster
public OrderDTO getOrderForPayment(Long orderId) {
    // 这个方法被注解, 进来就走主库, 不依赖 sticky
    return orderMapper.selectById(orderId).toDTO();
}

这种场景占总查询的不到 1%,完全可控。

修法 3:Redis 缓存兜底

对于"频繁读、读后改"的场景,我们引入了缓存层:写主库的同时写缓存,读优先查缓存。缓存数据用主库版本,从库延迟不影响。

// 写: 双写主库 + 缓存(写后立即更新缓存, 不是失效)
@Transactional
public void updateUser(Long userId, UserUpdate update) {
    userMapper.update(userId, update);
    User newUser = userMapper.selectById(userId);   // 在事务内查, 一定能拿到主库最新
    redisTemplate.opsForValue().set(
        "user:" + userId, newUser, Duration.ofMinutes(10)
    );
}

// 读: 先查缓存, 缓存有就用缓存
public UserDTO getUser(Long userId) {
    User cached = redisTemplate.opsForValue().get("user:" + userId);
    if (cached != null) return cached.toDTO();

    User user = userMapper.selectById(userId);
    if (user != null) {
        redisTemplate.opsForValue().set(
            "user:" + userId, user, Duration.ofMinutes(10)
        );
    }
    return user != null ? user.toDTO() : null;
}

用缓存替代部分主从路由,既解决一致性也优化性能。

这套方案的几个权衡

权衡点 说明
Redis 引入新的故障点 Redis 挂掉时降级走从库,失去 read-your-write 保证,但服务还在
5 秒窗口期是否够 过去 30 天主从延迟 P99 是 0.8 秒, 5 秒是 6 倍安全 margin, 够
跨用户场景不能保证 用户 A 写,用户 B 读,这种跨用户的 read-your-write 不在我们的范围内(原本也不该有)
批量操作怎么办 批量操作完成后,所有涉及的用户都设 sticky 标记
读写比变化 预计 5-10% 的读流量回到主, 主库负载有上限, 后续可能要扩容

对其他几种方案的研究

我们调研了业内的其他 read-your-write 方案,做了对比:

方案 原理 优点 缺点
Session sticky(我们选的) 写后短期内同一 session 读主 实现简单,代价小 窗口期有边界
半同步复制(semi-sync) 主库等至少一个从库 ACK 才返回 强一致性 写延迟变高,从库挂掉影响主
主从延迟监测 + 路由 每次查询查从库延迟,延迟 < 阈值才用从 动态适应 每次查询额外开销
读自己写的 binlog position 写完记录 binlog pos,读时检查从库 pos >= 写 pos 精确控制 实现复杂,需中间件支持
双写主+缓存(我们补充) 所有读优先走缓存 读最快 缓存一致性问题
读写都走主 放弃读写分离 简单可靠 主库压力大
用 NewSQL(TiDB / CockroachDB) 分布式一致性数据库 架构简洁 迁移成本极高

对中小规模团队,sticky + hint + cache 的组合是最平衡的方案。对超大规模(读 QPS 几十万)团队可以考虑用 binlog position 的精确方案,但实现复杂度高很多。

决策树:遇到读路径该走主还是从

这棵树后来被写进团队 onboarding 文档,任何新同学接入读写分离前都要按这个流程走一遍每条 SELECT。3 个月下来,新代码的"读到旧值"事件几乎为 0,旧代码也按这棵树批量做了 review。把"经验"沉淀成"决策树"是这次复盘里 ROI 最高的产物——比写多少代码都值。

3 天调研中被否决的方案

方案 看起来可行 否决理由
全面回到读写都走主 彻底消除一致性问题 主库 QPS 会涨 9 倍,扩容代价远高于改造代价;且否定了 3 年的读写分离投资
强制半同步复制 主库等从库 ACK 才返回,理论上完美 写延迟会涨 30-80ms,核心写路径不能接受;从库网络抖动会拖死主库
引入 TiDB / OceanBase 替换 MySQL 分布式数据库天然一致性 整套生态迁移成本至少 1 年,且新系统未必比成熟 MySQL 稳;不是 fix 这个问题的合理路径
每次 select 前查从库延迟决定路由 动态适应,延迟低时享受从库 每次额外网络往返,在高 QPS 下成本不可忽视;且延迟会突变,查到的延迟不代表下次也低
所有 update 后强制 sleep 200ms 再 select 实现极简 用户体验灾难,完全不工程化

每条都让我们想清楚"为什么不"。后来产品 / 老板问"为什么不上 TiDB 一劳永逸",我们直接甩这张表,5 分钟把决策理由讲清楚——这种"否决记录"在长期来看比"选定方案"的价值还大。

验证:埋点数据 + 客诉跟踪

指标 修复前 修复后 14 天
"读到旧值"事件(埋点) 日均 12 万次 日均 180 次(剩余都是真异常)
客户投诉(相关描述) 月均 18 单 0 单
主库 QPS(读写比变化) 9:1 变成 8.2:1.8(读流量增 8%)
主库 CPU 35% 42%(可接受)
从库 CPU 45% 40%(略减)
P99 延迟 80ms 76ms(略好,缓存命中)

主库负载增加 7%(从 35% 到 42%),完全在容量内。剩下 180 次/天的"读到旧值"事件,排查下来是几个边缘 case:

  • Redis 故障期间(降级走从)的少量"漏过"
  • 未登录用户的写操作没法关联 userId(改成用 deviceId 标记 sticky)
  • 跨用户场景(我们不保证,用户 A 写,B 读旧值是预期行为)

立的《读写分离一致性纪律》

  • "读写一致性"是架构责任,不能甩给业务自己处理。中间件必须提供机制,业务必须有清晰的 API 标注一致性需求。
  • 读写分离架构必须配套 read-your-write 机制,推荐 session sticky + 显式 hint 组合。
  • 写后 sticky 窗口必须 ≥ 主从延迟 P99 的 5 倍,推荐 5 秒。
  • 核心路径(支付/订单/认证/安全)必须显式标 @ReadFromMaster,不依赖 sticky。
  • 必须监控主从延迟分布(P50/P99),不只是 max 值,异常分布触发告警。
  • 必须埋点统计"读到旧值"事件,作为读写一致性健康度的客观指标。
  • 批量操作完成后必须给所有受影响的用户/实体设 sticky 标记
  • 禁止"update 后 select 同表"的代码模式(linter 检查),改用"update 后用内存对象返回"或者"显式 hint 读主"。
  • 新服务接入读写分离前必须做 review,确认所有读路径的一致性要求已经标注。

给读者的几条自查清单

  1. 你的服务有读写分离吗?如果有,先 grep "UPDATE" 或 "INSERT" 后 5 行内的 "SELECT" 模式,估算"先写后读"的代码量。
  2. 知道你的主从延迟 P99 是多少吗?查 Seconds_Behind_Master 的分布,不要只看瞬时值。
  3. 有没有埋点统计"读到旧值"的真实发生率?没有的话,根本不知道这个问题有多严重。
  4. 你的中间件支持 hint 强制读主吗?ShardingSphere、Vitess、Mycat、ProxySQL 都有类似机制,确认你用的中间件能怎么做。
  5. 核心路径有没有显式标注?如果"获取支付状态"也走从库,出问题影响巨大。
  6. 如果用 MySQL 半同步,确认 rpl_semi_sync_master_wait_for_slave_count 和 timeout 配置合理,不要因为从库挂导致主库阻塞。
  7. 压测一下高并发写场景下的主从延迟,看会不会陡升到几秒——这是真实业务峰值时最容易踩雷的。

这次事故让我对"读写分离"有了新的认知:它不是免费的性能优化,它是一个"用最终一致性换吞吐"的明确取舍。3 年前我们引入读写分离时,大家关注的是"读流量分散"的好处,完全没人讨论"放弃了什么"——放弃的是 read-your-write 这个用户隐含期待的语义。事后看,引入读写分离的 PR 应该附一份"一致性影响评估",说清楚哪些场景会受影响、要怎么应对。这种"先享受好处再被坑"的工程债,可能是分布式系统里最普遍的反模式。

另一个心得:"系统性问题"和"偶发 bug"经常长得一样。37 单客诉看起来是 37 个不相关的 bug,根因是同一个架构缺陷。如果只把每条客诉当独立 bug 处理,永远修不完根。要养成的习惯是:当看到 3 条以上"看起来类似"的客诉,停下来问自己"是不是同一个系统性问题在表面化"——这往往是发现真正大问题的入口。我们后来把这个习惯固化成 SRE 周例会的一个固定环节——每周翻一遍客诉,挑出 3 条以上重复模式做架构层归因。半年下来挖出了 4 个类似量级的根因问题,口碑指标一路上涨。

第三个心得:读写分离 / 缓存 / 异步化这些性能优化,本质上都是在用一致性换性能。引入时大家只看到性能收益,放弃了什么往往讲不清楚。我们现在内部规定,任何"性能优化"类的架构改动,PR 模板里必须有一节叫《Consistency Impact Assessment》,强制写清楚:这个改动放弃了什么级别的一致性保证 / 在什么场景下会观察到这种放弃 / 业务侧需要做什么补偿。光这一个模板写半年下来,挡掉了至少 3 次"看起来很美但其实埋雷"的架构改动。技术债不仅是"代码写得不好",更多是"决策时没想清楚 trade-off"——而模板化的 trade-off 记录是对抗这种债的最便宜手段。

最后一条:用户对系统的耐心远低于工程师以为的。一次"读到旧值"用户会刷新解决,十次他会去找替代品。SaaS 行业的续费率本质上由"无数小细节"叠加决定,这种 3 秒级的体验差距长期累积下来可能就是 5% 的续费率差异——这在 ARR 上是巨大的数字。读写一致性看着是个"小事",在客户决策维度其实是"大事"。这次复盘后我们把"读写一致性"列入年度技术 KR,和 P99 延迟、可用率并列——这种"看不见的体验指标"才是真正决定产品口碑的护城河。

如果你也在维护一个 3 年以上的读写分离老系统,真心建议抽出半天时间做三件事:先按照本文的"埋点统计真实发生量"看一眼数字,再按决策树审视一下核心写后读路径,最后在团队周会上把数字和方案摊出来讨论一次——绝大多数团队这一摊,都会发现这是个 ROI 极高、值得当季排进 sprint 的项目。期待看到更多团队把这个"看似不重要"的工程问题列入年度规划,把读写分离架构的最后一块也是最容易被忽视的一块拼图真正补齐。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

LLM 客服流式输出被 Nginx + Cloudflare + uvicorn + HTTP/2 四层代理悄悄变成批量的 3 天复盘:SSE 链路全栈优化 + 零缓冲发布模板

2026-5-26 12:07:26

技术教程

ASP.NET Core 报表导出 90k 行 Pod 内存 90 秒冲到 6.1GB OOM 的 5 天复盘:EF Core ChangeTracker + LazyProxies + N+1 三层叠加根因 + projection 流式优化全过程

2026-5-26 12:15:22

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索