主从延迟会造成什么问题?订单系统"读走从、判断走主"的一致性策略
承接上一篇《MySQL 读写分离完整链路》:那篇讲怎么把读写分离搭起来,这篇只讲搭建之后必然要还的那笔债——主从延迟。文中策略与判定全部来自笔者的微服务订单系统
order-system-cloud的落地实践;属于备选方案、未落地的部分会显式标注。
一、业务背景:一个"删不掉的购物车"
先说一个真实场景,它比任何原理图都更能说明问题:
用户点"删除购物车商品"→ 请求打到主库,DELETE 成功,接口返回200→ 用户秒刷购物车列表(这个查询走了从库) → 刚才删掉的那一项,又冒出来了用户看到的现象是"删除失败",但数据库里数据是对的、接口也返回成功了。如果没有意识到主从延迟,这个 bug 会非常难查——因为你在任何一台库上单独查,都是对的。
同样的形态还有很多:
用户完成支付 → 主库把订单改成 PAID → 用户秒进订单详情(走从库)→ 仍显示"待支付"用户下单成功 → 主库写入订单 → 用户秒进"我的订单"(走从库)→ 列表里没有这一单这三个场景有一个共同点:“刚刚写过的数据,马上就要读”。这就是主从延迟唯一会咬人的地方,也是本文全部策略的出发点。
二、主从延迟的本质:异步复制
主从复制靠三个线程接力:
主库 Dump 线程 ──推 binlog──▶ 从库 IO 线程 ──▶ relay log ──▶ 从库 SQL 线程 逐条重放关键在于:主库提交事务时,并不等待从库重放完成。主库只要把 binlog 写出去就返回了,从库什么时候追平是它自己的事。这中间的时间差,就是主从延迟(replication lag)。
所以延迟不是一个"故障",而是异步复制的固有属性。它不是"会不会发生"的问题,而是"这一瞬间有多长"的问题——正常情况是毫秒级,但只要有放大因素(下一节会讲),它会突然拉长到秒级甚至分钟级。
这条认知很重要:如果你把延迟当成故障来处理,你会去"修"它;但它是设计的一部分,你只能在业务上决定哪些操作不能容忍它。
下面是主从复制的完整链路与延迟产生的位置:
三、延迟会造成的四类线上问题
把延迟的后果归类,比零散记场景更有用:
| 类型 | 表现 | 危险程度 |
|---|---|---|
| 读己之写失效 | 用户刚提交的修改,自己刷新看不到 | 高——用户直接感知,像 bug |
| 判断依据过期 | 先读状态再决定动作,读到旧状态 | 最高——会导致业务逻辑错误,不只是显示问题 |
| 幂等校验穿透 | 靠"查一下有没有"来防重,查的是从库 | 高——可能重复扣减、重复下单 |
| 监控/统计延迟 | 报表数字对不上、对账有偏差 | 中——可容忍,但要知情 |
其中第二类"判断依据过期"是唯一会真正写坏数据的,也是本项目花最大力气防的一类。
举个例子:支付接口的validateOrder要先查订单状态,确认它是"待支付"才允许支付。如果这个查询走了从库,而主从延迟刚好存在,用户重复点击支付时——从库可能还认为订单是WAIT_PAY,于是第二次支付被放行。这不是显示错误,是资损。
四类问题的危险程度可以这样分层:
四、项目的取舍:读走从、写走主、判断走主
针对上面四类问题,本项目的一致性策略压缩成一句话:
读走从、写走主、判断走主。
落到@DS注解上,规则只有一条:纯读、且容忍秒级延迟的方法才加@DS("slave");写方法、以及一切"先读后写"的方法一律不加注解(默认走 primary = master)。
走从库@DS("slave") | 走主库(默认,不加注解) |
|---|---|
OrderService.detail()—— 订单详情展示 | OrderService.create()/pay()/cancel() |
OrderService.myOrders()—— 我的订单列表 | CartService.add()/updateQuantity()/remove() |
CartService.myCart()—— 购物车列表展示 | 两个定时调度器(条件抢占 WAIT→SENDING) |
MessageService.myMessages()—— 消息列表 | MQ 消费者(插入 / 更新) |
判断规则可以写成一句可执行的检查:这个方法读出来的数据,会不会被用来做后续的判断或写入?会,就走主库。
代码形态:
@ServicepublicclassOrderServiceImplimplementsOrderService{/** 纯展示:读出来直接返回给前端,不做任何判断 → 容忍延迟,走从库 */@DS("slave")@OverridepublicOrderDetailVOdetail(LongorderId){...}/** 先读后写:读状态是为了决定能不能改 → 必须走主库 */@Override@Transactional(rollbackFor=Exception.class)publicvoidpay(LongorderId,LonguserId){Orderorder=validateOrder(orderId,userId);// ← 这一步读到的状态将决定后续写入if(!OrderStatus.WAIT_PAY.name().equals(order.getStatus())){thrownewBizException("订单状态不允许支付");}// ... 更新为 PAID}}pay()不加@DS("slave")不是"忘了加",而是刻意的:它的读是"读来改"的,一旦读到延迟的旧数据,逻辑就错了。
万一某个读接口确实需要强一致怎么办
那就显式声明,不要靠默认值靠运气:
@DS("master")// 显式强制读主库:我知道这里不能容忍延迟@OverridepublicOrderVOgetForUpdate(LongorderId){...}关键原则是"显式":容忍延迟走从库,是默认选择;不容忍延迟走主库,必须写出来。让每一处"我要强一致"都在代码里看得见。
项目的核心决策逻辑可以用下面这张图表达:
走从库"] B --> -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'STR'
五、延迟从哪来:五个放大因素
正常情况延迟是毫秒级,会让它突然拉长的通常是这几种:
- 从库 SQL 线程单线程重放:MySQL 5.6 之前从库只有一个 SQL 线程串行重放,主库并发写入的压力会全堆在这里;
- 大事务:一次更新几十万行,从库要一行一行重放,主库早提交完了;
- DDL 操作:加字段、加索引在主库执行完,从库还要重放一遍;
- 从库自身压力大:从库既要重放 binlog 又要扛读流量,读 QPS 一高,重放就慢——这是读写分离方案里最讽刺的一种延迟来源;
- binlog 格式的影响:本项目用
ROW格式(记录每行前后镜像,最安全),代价是一次批量更新产生的 binlog 量远大于STATEMENT,从库要搬运和重放的数据也更多。
第 4 条值得单独强调:读流量本身会加剧延迟,而延迟又会让人更想读主库,主库压力上升——这是一个正反馈,容量规划时不能只看平均值。
五个放大因素中最值得警惕的是第 4 条的正反馈循环:
六、三档解法与项目的选择
业界应对主从延迟有三档手段,从简单到硬核:
| 档位 | 手段 | 代价 | 本项目 |
|---|---|---|---|
| 第一档 | 写后必须读准的,强制读主库(@DS("master")) | 几乎没有,只是少了一个读分流点 | ✅已落地 |
| 第二档 | 半同步复制(semi-sync):主库提交前等从库确认收到 binlog | 牺牲写性能换强一致,延迟敏感型写入变慢 | ⬜ 未落地(README 列为备选策略) |
| 第三档 | 从库并行复制(MySQL 5.7 MTS):从库 SQL 线程并行重放 | 需要配置,且并行度受事务冲突限制 | ⬜ 未落地(README 列为备选策略) |
本项目的选择是只用第一档,并且明确不承诺"零延迟"。理由是:本项目的读方法全部是"列表展示类",容忍秒级延迟;凡是"读来改"的都留在主库了。也就是说——延迟仍然存在,但它不再落在任何会写坏数据的路径上。
这是一个很重要的表达方式:不是"我们消除了主从延迟",而是"我们把延迟隔离在了不会造成错误的地方"。
三档解法的取舍一目了然:
加 @DS(\"master\")"] A --> A1 -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'STR'
七、边界与并发:能承诺什么,不能承诺什么
写这类方案时必须把边界说清楚,否则就是给自己埋雷:
能承诺的:
- 从库上的查询最终会与主库一致(异步复制保证最终一致);
- 任何"读后判断/读后修改"的操作都在主库上执行,不会因延迟产生错误判断(本项目已按此规则划分数据源)。
不能承诺的:
- 从库的读不保证读到最新数据——这是设计取舍,不是缺陷;
- 极端情况下(大事务、DDL、从库过载)延迟可能达到秒级以上,展示类接口会出现短暂的数据偏旧;
@DS("slave")的切换依赖 AOP 代理 + ThreadLocal,同类内部this.xxx()自调用不走代理、@Async/线程池场景 ThreadLocal 不跨线程传递——这两种情况下注解会静默失效(退化为走默认的 master,不会报错)。
最后一条是并发边界里最容易踩的:注解失效的表现不是报错,而是"看起来正常但走了主库"。所以核查数据源路由时,不能只看注解写没写,还要看调用路径是否真的过了代理。
八、面试与简历亮点
- 能一句话说清策略:“读走从、写走主、判断走主”——把读写分离的一致性边界讲成了可执行的规则,而不是"我们有读写分离";
- 知道延迟不是故障而是属性:因此方案的目标是"隔离"而不是"消灭",这个认知层次比背八股高;
- 能指出真正危险的那类问题:读己之写失效只是体验问题,"判断依据过期"才会写坏数据——把风险按后果分级,是工程判断力的体现;
- 显式优先于默认:容忍延迟靠默认、需要强一致必须显式写
@DS("master"),让例外在代码里可见; - 知道自己的注解会失效:自调用、异步场景下
@DS静默失效且不报错。
九、场景题
Q1:用户反馈"删了购物车商品又自己回来了",但你查数据库确认数据是正确的。怎么解释、怎么修?
解释:删除走主库、列表查询走从库,用户"秒刷"时从库还没重放到这次删除,于是读到了删除前的快照。数据是正确的,用户看到的是延迟窗口内的旧快照。
排查要点:先确认两台库分别查这条记录,主库已删、从库还在 → 立刻能定性为延迟而不是删除失败(这一步能省掉大量无效排查)。
修法(按性价比排序):
- 该场景强制读主库(
@DS("master")),或删除成功后由接口直接返回删除结果、前端本地移除该行,不做二次查询; - 更通用的做法:写操作返回后,对同一用户短时间内的读请求强制走主库("写后粘主库"窗口);
- 半同步复制 / 从库并行复制,按下延迟本身(本项目未落地,属备选)。
关键提醒:不要用"加个 sleep 等一下"来解——那是把不确定性换成随机性,线上延迟一波动就会复发。
Q2:同事给pay()加上了@DS("slave")想"减轻主库压力",说"这个方法就是查一下订单,纯读"。你怎么判断这个改动能不能上?
判断标准一句话:这个读的结果,会不会被后面用来做判断或写入?
pay()的validateOrder是典型的"读来改"——它读订单状态是为了决定能不能支付,并且紧接着就要写。走从库意味着:延迟窗口内,一笔已支付的订单可能被从库读成WAIT_PAY,于是重复支付被放行。这是资损,不是体验问题。
所以答案是不能上。正确做法是:要减主库压力,应该从它周围的纯展示读(订单详情、我的订单、购物车列表、消息列表)入手,而不是从带判断的读入手。如果确实要优化pay(),方向是缩短它的主库事务、减少锁持有时间,而不是把它的读挪到从库。
一句话总结:主从延迟是异步复制的固有属性,不可能被"消灭",只能被隔离。订单系统的做法是把延迟全部赶到"读出来只用于展示"的路径上,而把"读出来要用于判断或修改"的操作留在主库——读走从、写走主、判断走主。
推荐标签:MySQL、主从延迟、读写分离、数据一致性、微服务