
刚改完数据刷新就不见了聊聊主从延迟下的“读后写”Read Your Writes陷阱你有没有遇到过这种场景管理员后台刚把一条商品状态改成“下架”切到前台页面一看商品还挂在那儿用户刚提交了一条评论回到评论区却看不到自己的评论过两三秒再刷新才出现。多数人第一反应是缓存没清理但排查完缓存一切正常。其实很多情况下这是主从延迟引发的“读后写”问题——用户写完数据后立刻读自己刚写入的内容结果读到的是从库上的旧数据给人一种“数据丢了”“白写了”的错觉。今天这篇就专门聊这个 Read Your Writes 陷阱。我会从主从复制延迟的成因讲起把为什么刷新会“变出来”解释清楚再给出几条可落地的技术路线和避坑经验适合正在做读写分离、微服务拆分以及任何把读流量往从库引流后遇到“数据时有时无”问题的人参考。1. 从“数据不见了”讲起1.1 一个很常见的“灵异事件”先还原一下现场。某个日活不低的业务系统配置了主从复制和读写分离写操作去主库读操作走从库。某天运营人员提出编辑一个活动页的标题后立刻刷新页面看到的还是旧标题等几秒再刷新就变成新标题了。开发同事的第一反应是 CDN 缓存过期时间太长检查后发现 CDN 没毛病有人怀疑 Redis 缓存没删翻了一遍代码发现缓存删了但还是有延迟。最后定位下来问题出现在“读从库”这条链路上。编辑动作成功写入了主库但主从复制还没把这条 binlog 同步到从库紧接着的页面请求被路由到了从库从库当然只能返回旧数据。站在用户视角这就是“我改了但它不认”。这种问题并不是偶发的大故障而是复制延迟不高但确实存在的常态。只要从库有几十毫秒到几百毫秒的延迟业务上又刚好是“写完马上读”的高频场景就会反复触发。时间一长用户不会觉得是技术问题只会认为系统有 bug连数据一致性都没法保证。1.2 读后写一致性到底是个什么标准读后写一致性在分布式系统里有个很经典的英文名Read Your Writes。要求很简单一个客户端写入了新值它后续的读操作应当能看到这个新值而不是旧值。注意这里强调的是“该客户端”自己。并不要求别人立刻看到你的写入也不要求所有节点同时一致只要写的人自己能看到就行。这个约束看起来非常基础但恰恰是读写分离架构最容易破坏的。因为读写分离的本质是试图同时享受主库的写入能力和从库的扩展能力代价就是读到的数据可能“比别人慢半拍”。对人这个“客户端”来说他自己写的数据如果也慢半拍就会在交互上产生极强的违和感。我经常用一个类比你往公司前台快递架放了份文件紧接着你去取文件结果前台说没有。过一会儿再取文件出现了。放文件这个动作你已经完成了取文件又是你亲自发起的那么“立刻取到”就是顺理成章的预期。读后写一致性约定就是保证这种“放完立刻取”的体验。1.3 为什么它和主从复制天生容易冲突主从复制的常见形态是异步复制主库提交事务、写 binlog从库拉取 binlog 并回放。这个链路天生就存在时间差。主库提交成功只代表数据进了主库不代表从库已经追上。从库拿到 binlog、执行完事务才算真正“有这份数据”。关键在于很多业务框架在做读写分离时并不区分“这次读是不是跟刚才那次写有关”。它只按规则把读请求均匀分发到从库觉得这样能分摊压力。至于刚刚发生写入的那个客户端读请求会不会落到还没同步数据的从库路由层通常不管。于是问题被切成了两半写入路径是“只有主库知道”读取路径是“从库可能不知道”。两条路径没有对齐读后写一致性自然就破了。要修复这个问题思路通常也是从对齐这两条路径入手要么让相关的读请求回主库要么让路由逻辑识别出“这个用户刚才写过”。2. 主从延迟是怎么一步步拖垮读后写的2.1 从提交到从库可见的完整链路要理解延迟带来的坑先把从主库提交到从库可见的时间段拆开。主库写完事务并提交后会顺序写 binlog从库的 IO 线程通过网络拉取 binlog写入自己的 relay log从库的 SQL 线程再读取 relay log在本地重放事务重放完成数据才对从库的查询可见。这里面每一环都可能产生延迟网络抖动会导致拉取变慢从库所在机器磁盘性能差写入 relay log 也会慢SQL 线程在重放大事务时可能需要几秒钟甚至更久从库自身的查询负载高CPU 和 IO 被抢也会拖慢回放速度。在延迟窗口内主库和从库的数据是不一致的。写入者如果立刻查询一个被路由到从库的请求读到的就是旧值。窗口越小问题越隐蔽窗口越大用户感知越强烈。最气人的是这个窗口不是固定值它可能是 10 毫秒也可能是 3 秒这导致了“时好时坏”的随机感。2.2 读写分离路由策略的盲区读写分离的路由策略常见的有这么几种按 SQL 类型区分select 走从库、insert/update/delete 走主库按自定义注解区分标注强制主库的方法走主库按事务状态区分事务内的读走主库事务外的读走从库。这些策略有个共同特点它们都只盯着“当前这条 SQL 是什么类型”没有记住“这个客户端之前干了什么”。即使你在事务里刚插入了一条订单事务提交后紧接着一个普通查询走了从库这个查询也许根本不关心刚才的订单但它也完全可能去查订单表。如果从库还没回放完用户看到的就是订单列表缺了一条。更麻烦的是在一些老项目里路由规则写得很粗暴一个方法只要方法名包含 get 或 query 就强制走从库。结果用户在 App 上刚提交了一个投票刷新详情页想看票数变化详情查询被分到从库从库上的投票数根本没更新。下游还以为是代码 bug查了半天才发现是复制延迟。2.3 延迟的测量不能只看一个指标很多团队说到延迟第一反应是去看 MySQL 的Seconds_Behind_Master。但单看这个指标不够。它表示的是从库 SQL 线程相对主库 binlog 写入位置的落后秒数受系统时钟影响而且当主库长时间没有新写入时它会显示 0不代表延迟不存在。衡量延迟更实用的办法是对比位点看主库当前 binlog 文件名和位置再看从库已经回放到哪个 binlog 位置两者之间的差值才是真实滞后量。还可以用心跳表辅助每隔几十毫秒在业务写入口更新一条心跳记录从库读这条记录的时间差就能比较直观地反映端到端延迟。真正要关注的不是“平均延迟”而是“峰值延迟”。业务高峰期从库负载上去了或者一个大事务压过来延迟可能瞬间飙到几秒。平均延迟只有 50 毫秒的系统也可能在某个时间点制造出“改了刷不出来”的坏体验。3. 解决读后写问题的四条主流路线3.1 关键路径强一致读读主库最直接的解法就是把“写完马上可能会读”的请求强制路由到主库。比如用户修改了自己的昵称紧接着页面上要展示昵称这一条读完全可以走主库又比如支付回调更新了订单状态用户立刻刷新订单详情这个详情查询也建议强制主库。这样做的好处是简单、可预测。代码里只要指定某个查询走主库即可不需要感知从库延迟也不会因为延迟抖动而出现偶发问题。代价是主库读压力上升。所以它不是让你把整个系统的读流量都搬到主库而是只挑那些“强一致要求高、用户能立刻感知”的读操作。实践中比较稳妥的做法是给数据做分级。第一类是核心用户数据例如账号信息、余额、订单状态、权限配置写后读走主库第二类是可以容忍短时间不一致的数据例如文章阅读数、点赞总数、排行榜正常走从库第三类是纯静态或可缓存数据连从库都不一定需要直接走缓存。3.2 会话级粘滞路由如果不想把太多读请求压到主库可以用会话级粘滞路由来解决同一个用户的读后写问题。思路是同一会话内一旦发生过写操作接下来的读请求就都走主库直到会话结束或经过一个合理的时间窗口。实现上有两种常见姿势。一种是网关层根据用户标识做路由用户在短时间内写过数据就把这个用户标记为“热用户”在标记有效期内该用户的所有读请求都发往主库。另一种是在应用层通过 ThreadLocal 或请求上下文记录“本会话是否有写操作”有则直接让后续读走主库直到某个请求显式清除标记。这种做法的核心逻辑是我们不需要让所有人的读都强一致只需要让“刚动过数据的人”读到自己写的内容。这样主库的额外压力被限制在写过数据的用户群体内而不是全量读流量。实际业务里单用户写操作频率通常不高粘滞窗口也不会太长主库压力相对可控。3.3 版本号与时间戳兜底有一些场景下写操作和读操作并不在同一个请求链路里比如运营后台改配置用户端读配置。此时运营人员编辑完立刻在小程序里验证就很容易读到从库旧值。用会话粘滞这种方案并不合适因为“写入者”和“读取者”不是同一个客户端会话。这时候可以考虑版本号兜底写入时把数据版本号或更新时间戳同时写进缓存比如 Redis 或本地内存读取时先从缓存拿到“期望版本号”再去查数据库如果从库读出来的版本号小于期望版本号说明数据还没同步需要重试或者走主库。这类方案的落地成本稍高但效果好。它实际上是把“数据同步状态”显式存了下来让查询方有据可依。当然要小心缓存本身成为新的不一致源版本号缓存必须和数据库写入在同一条事务或者同一套流程里更新否则缓存更新失败又会引入新的问题。3.4 写后等待复制确认如果业务对读后写要求极高并且写入频率可控可以考虑使用半同步复制来缩短延迟窗口。半同步复制要求主库在事务提交时等待至少一个从库确认收到 binlog 后才返回客户端成功这样主库提交成功时数据大概率已经同步到了从库从库虽然还没回放完但差距已经显著缩小。需要注意的是半同步复制在从库不可用时会退化为异步模式并且它保证的是 binlog 已传输不代表从库已经执行完事务。所以它只能减少延迟不能完全消除延迟。对瞬时强一致要求非常高的场景还是需要在代码层面做双保险。还有一类“等待”方案是应用层写入后主动等待几十毫秒再返回或者在返回结果里带上新数据让前端先覆盖显示。这个办法不能盲目用因为等待时间只对当前延迟有效一旦高峰期延迟超过等待时间问题又会冒出来。它只适合作为临时方案或者辅助手段。4. 落地实践一套可复现的读写分离改造4.1 先给数据定个强弱等级我在动手改这类问题之前不会急着改路由代码而是先拉着业务把数据理一遍。大致按两个维度分类用户能多快感知到不一致以及不一致会造成多大损失。举几个典型例子用户个人资料的“我的昵称”、订单支付后的“订单状态”、管理后台刚保存的商品上下架状态都是强一致级别而文章的“阅读数”、视频的“播放量”、热搜榜的“热度排序”完全可以接受稍后一致至于 Feed 流推荐列表本身就是异步生成的更不需要读后写强一致。我的经验是自下而上做分级先把“用户写完立刻会看”的数据全部列出来再把这些数据涉及的读接口单独标出来。宁可刚开始多标几个也不要漏掉核心闭环。因为漏一个线上就可能出现“改了看不到”的投诉。4.2 路由实现的三种编码方式路由改动不用一口气重构整个框架可以从最省事的方案开始。我见过比较有效的做法是在数据访问层增加一个路由标记例如在查询方法上增加注解指定该查询使用主库数据源。这种方式侵入性最小业务代码几乎不用大改。第二种方式是在服务层增加一个请求上下文的标记。写入方法执行成功后在上下文中设置一个“本会话最近发生写入”的标识随后调用读接口时如果读接口关心强一致数据就检测这个标识并切换数据源。可以用一段伪代码示意public OrderVO getOrderAfterWrite(String orderId) { // 如果当前会话刚写过订单读取走主库 if (SessionContext.hasRecentWrite(order)) { return queryFromMaster(orderId); } return queryFromSlave(orderId); } public void updateOrderStatus(String orderId, int status) { masterDao.updateOrderStatus(orderId, status); // 记录当前会话近期发生过订单相关写入 SessionContext.markRecentWrite(order); }第三种方式是接入现成的路由中间件比如在数据库代理层配置强制主库规则或者按用户维度做读写分离。这种方式适用性强但对团队的基础设施要求也更高。核心思路是一致的把路由规则从“按 SQL 类型”升级为“按业务一致性要求”。4.3 前端交互的体验兜底技术层面解决之后前端交互也要配合。否则哪怕代码已强制走主库用户还是可能在写操作后的一瞬间看到旧缓存。最典型的场景是用户提交评论后接口返回里其实已经带了评论内容但前端没有直接用而是重新拉取列表结果列表走的还是缓存于是评论区看不到新评论。更合理的做法是写接口成功后前端直接用返回值里的最新数据做本地插入或替换不等下一次请求。比如评论提交成功后把返回的评论对象追加到列表头部修改昵称成功后用返回的新昵称更新页面展示。这样即使后端有极短的同步延迟用户也感知不到。如果数据量比较大或者无法通过接口返回也可以在前端做一个“乐观占位”。提交后先显示一个“提交中”的占位状态等列表数据刷新后替换为真实数据。这种交互不仅掩盖了潜在的一致性问题也让用户觉得系统响应更快。4.4 复制延迟监控不能缺失解决读后写问题不是一次性的改代码延迟是动态变化的必须有监控。最基础的是监控主从复制是否中断、延迟秒数是否超过阈值。延迟长期超过 5 秒或 10 秒的系统就算路由做得再好也会因为突发问题让用户遇到异常。比较推荐的监控项包括主库 binlog 位点和从库回放位点的差值、IO 线程和 SQL 线程的运行状态、从库的 CPU 和磁盘 IO 使用率以及复制报错次数。业务侧还可以监控“检测到数据未同步”的次数比如版本号兜底逻辑触发了多少次走主库或重试这个数字能直接反映线上实际碰撞概率。延迟监控的意义不只是事后排查它还能反过来指导路由策略的阈值设置。比如某段时间内延迟总是超过 500 毫秒那粘滞窗口就不能设置得太短代码里等待复制确认的时间也要相应调整。5. 常见坑与排查实录5.1 问题速查表平时排查这类问题我习惯先按下面的表格过一遍确认到底是不是主从延迟导致的读后写问题。症状可能原因初步排查方向写后刷新偶尔看到旧数据主从复制延迟对比主从 binlog 位点写后刷新一直看到旧数据缓存未更新或路由错误检查缓存和读库路由规则刷新多次后变新数据延迟窗口内的数据回放完成查看延迟监控确认窗口时长只有特定用户看到问题用户维度路由未生效查看粘滞路由标记是否存在写接口返回成功但读接口报错写主库读从库从库缺数据检查数据是否同步到从库只要现象符合“过一会儿自己就好”大概率就是复制延迟如果持续不好重点查缓存、路由或数据本身是否正确写入了主库。5.2 一个典型的排查过程有一次排查线上问题用户反馈在 App 里提交问卷后立即查看答案汇总数字没变。因为汇总查询读的是从库而提交操作写的是主库所以第一怀疑对象就是复制延迟。我先查了从库的延迟监控发现平均值只有 100 毫秒左右按理说不会持续看不到。后来抓了完整的请求链路日志才发现问题出在更上层用户提交问卷后App 端并没有重新请求后端接口而是读取了本地缓存的一份汇总快照缓存更新策略又是“下次启动时刷新”。所以根本不是数据库读后写问题而是客户端缓存逻辑的问题。这个过程给我最大的启发是定位问题时先看数据链路在哪一层被“挡住”了不要一上来就在主从复制里死磕。写接口确认成功之后读请求到底打到哪儿、走了什么缓存、读了哪个库每一步都要有日志可查。5.3 容易踩的坑和处理细节我遇到过几个比较隐蔽的坑顺手记下来。第一个是主从复制延迟监控里的Seconds_Behind_Master受时钟影响主库和从库的系统时间不一致时这个值可能不准。更可靠的做法是记录 binlog 位点对比或者用业务心跳表。第二个坑是半同步复制并不是万能的。它退化为异步模式时不会明显告警如果团队完全依赖它来保证读后写故障时就会露出破绽。我建议在半同步复制之外再保留一层应用路由兜底。第三个坑和路由规则有关粘滞路由标记如果跨线程传递不正确或者异步线程里执行读操作很容易丢失上下文。比如一个写请求提交后异步通知里查询数据这个查询已经不在原来的线程上下文中标记判断就会失效。第四个坑是不要用“写后 sleep 固定时间”来解决问题。表面上看等了 1 秒后数据基本就同步了但高峰期延迟可能超过 1 秒而且这种方案是在浪费每一个请求的响应时间。控制不了延迟就应该用能感知延迟的方案。6. 一些经验之外的经验我做读写分离改造时最常被问的一句话是读主库会不会把主库打爆我的回答是你不需要把所有读都搬到主库只需要把“写完马上读”的那些搬到主库。一个真实用户写完数据后短时间内的读请求量往往是有限的不会产生数量级上的压力。真正需要担心的是你把用户整个会话内所有读请求都无条件走主库导致主库连接数暴涨。还有一个很实用的小技巧写接口的返回结果里尽量带上写入后的最新数据。前端拿到这个数据直接更新界面而不是再次向后端发起读请求。这像一个“契约”既让用户立刻看到正确内容又减少了一次读压力。很多看似诡异的数据刷新问题用这一招就能从交互层面消解掉。如果你现在正被“刚改完刷新就不见了”折磨我的建议是先量延迟再分数据等级然后选择合适的路由方案。不要幻想某一套配置能一劳永逸延迟是分布式的常态我们要做的不是消灭延迟而是让用户和关键业务不被延迟打扰。抓住“谁写、谁读、什么时候读”这三个要素问题基本就能拆解得清清楚楚。