刚改完数据刷新就不见了?聊聊主从延迟下的“读后写”(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. 一些经验之外的经验
我做读写分离改造时,最常被问的一句话是:读主库会不会把主库打爆?我的回答是,你不需要把所有读都搬到主库,只需要把“写完马上读”的那些搬到主库。一个真实用户写完数据后,短时间内的读请求量往往是有限的,不会产生数量级上的压力。真正需要担心的,是你把用户整个会话内所有读请求都无条件走主库,导致主库连接数暴涨。
还有一个很实用的小技巧:写接口的返回结果里,尽量带上写入后的最新数据。前端拿到这个数据直接更新界面,而不是再次向后端发起读请求。这像一个“契约”,既让用户立刻看到正确内容,又减少了一次读压力。很多看似诡异的数据刷新问题,用这一招就能从交互层面消解掉。
如果你现在正被“刚改完刷新就不见了”折磨,我的建议是:先量延迟,再分数据等级,然后选择合适的路由方案。不要幻想某一套配置能一劳永逸,延迟是分布式的常态,我们要做的不是消灭延迟,而是让用户和关键业务不被延迟打扰。抓住“谁写、谁读、什么时候读”这三个要素,问题基本就能拆解得清清楚楚。