拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

主从延迟会造成什么问题?订单系统\“读走从、判断走主\“的一致性策略

主从延迟会造成什么问题?订单系统\“读走从、判断走主\“的一致性策略

主从延迟会造成什么问题?订单系统"读走从、判断走主"的一致性策略

承接上一篇《MySQL 读写分离完整链路》:那篇讲怎么把读写分离搭起来,这篇只讲搭建之后必然要还的那笔债——主从延迟。文中策略与判定全部来自笔者的微服务订单系统order-system-cloud的落地实践;属于备选方案、未落地的部分会显式标注。

一、业务背景:一个"删不掉的购物车"

先说一个真实场景,它比任何原理图都更能说明问题:

用户点"删除购物车商品"→ 请求打到主库,DELETE 成功,接口返回200→ 用户秒刷购物车列表(这个查询走了从库) → 刚才删掉的那一项,又冒出来了

用户看到的现象是"删除失败",但数据库里数据是对的、接口也返回成功了。如果没有意识到主从延迟,这个 bug 会非常难查——因为你在任何一台库上单独查,都是对的。

同样的形态还有很多:

用户完成支付 → 主库把订单改成 PAID → 用户秒进订单详情(走从库)→ 仍显示"待支付"用户下单成功 → 主库写入订单 → 用户秒进"我的订单"(走从库)→ 列表里没有这一单

这三个场景有一个共同点:“刚刚写过的数据,马上就要读”。这就是主从延迟唯一会咬人的地方,也是本文全部策略的出发点。

二、主从延迟的本质:异步复制

主从复制靠三个线程接力:

主库 Dump 线程 ──推 binlog──▶ 从库 IO 线程 ──▶ relay log ──▶ 从库 SQL 线程 逐条重放

关键在于:主库提交事务时,并不等待从库重放完成。主库只要把 binlog 写出去就返回了,从库什么时候追平是它自己的事。这中间的时间差,就是主从延迟(replication lag)。

所以延迟不是一个"故障",而是异步复制的固有属性。它不是"会不会发生"的问题,而是"这一瞬间有多长"的问题——正常情况是毫秒级,但只要有放大因素(下一节会讲),它会突然拉长到秒级甚至分钟级。

这条认知很重要:如果你把延迟当成故障来处理,你会去"修"它;但它是设计的一部分,你只能在业务上决定哪些操作不能容忍它。

下面是主从复制的完整链路与延迟产生的位置:

从库

主库

推送 binlog

写入

逐条重放

网络传输

主库不等待从库,直接返回

放大因素

业务写入

事务提交

Dump 线程

binlog

IO 线程

relay log

SQL 线程

从库数据

延迟窗口 = 主库提交到从库重放完成的时间差

毫秒级 → 秒级甚至分钟级

三、延迟会造成的四类线上问题

把延迟的后果归类,比零散记场景更有用:

类型表现危险程度
读己之写失效用户刚提交的修改,自己刷新看不到高——用户直接感知,像 bug
判断依据过期先读状态再决定动作,读到旧状态最高——会导致业务逻辑错误,不只是显示问题
幂等校验穿透靠"查一下有没有"来防重,查的是从库高——可能重复扣减、重复下单
监控/统计延迟报表数字对不上、对账有偏差中——可容忍,但要知情

其中第二类"判断依据过期"是唯一会真正写坏数据的,也是本项目花最大力气防的一类。

举个例子:支付接口的validateOrder要先查订单状态,确认它是"待支付"才允许支付。如果这个查询走了从库,而主从延迟刚好存在,用户重复点击支付时——从库可能还认为订单是WAIT_PAY,于是第二次支付被放行。这不是显示错误,是资损。

四类问题的危险程度可以这样分层:

主从延迟

四类线上问题

读己之写失效

判断依据过期

幂等校验穿透

监控/统计延迟

用户直接感知,像 bug

危险程度:高

先读状态再决定动作,读到旧状态

危险程度:最高

唯一会真正写坏数据

可能重复扣减、重复下单

危险程度:高

报表数字对不上、对账有偏差

危险程度:中,可容忍但要知情

四、项目的取舍:读走从、写走主、判断走主

针对上面四类问题,本项目的一致性策略压缩成一句话:

读走从、写走主、判断走主。

落到@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){...}

关键原则是"显式":容忍延迟走从库,是默认选择;不容忍延迟走主库,必须写出来。让每一处"我要强一致"都在代码里看得见。

项目的核心决策逻辑可以用下面这张图表达:

渲染错误:Mermaid 渲染失败: Parse error on line 4: ...> D["加 @DS(\"slave\")
走从库"] B --> -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'STR'

五、延迟从哪来:五个放大因素

正常情况延迟是毫秒级,会让它突然拉长的通常是这几种:

  1. 从库 SQL 线程单线程重放:MySQL 5.6 之前从库只有一个 SQL 线程串行重放,主库并发写入的压力会全堆在这里;
  2. 大事务:一次更新几十万行,从库要一行一行重放,主库早提交完了;
  3. DDL 操作:加字段、加索引在主库执行完,从库还要重放一遍;
  4. 从库自身压力大:从库既要重放 binlog 又要扛读流量,读 QPS 一高,重放就慢——这是读写分离方案里最讽刺的一种延迟来源;
  5. binlog 格式的影响:本项目用ROW格式(记录每行前后镜像,最安全),代价是一次批量更新产生的 binlog 量远大于STATEMENT,从库要搬运和重放的数据也更多。

第 4 条值得单独强调:读流量本身会加剧延迟,而延迟又会让人更想读主库,主库压力上升——这是一个正反馈,容量规划时不能只看平均值。

五个放大因素中最值得警惕的是第 4 条的正反馈循环:

从库读 QPS 升高

从库既要重放 binlog
又要扛读流量

重放变慢,延迟拉长

用户读到旧数据

更想读主库

主库压力上升

主库写入变慢

六、三档解法与项目的选择

业界应对主从延迟有三档手段,从简单到硬核:

档位手段代价本项目
第一档写后必须读准的,强制读主库(@DS("master"))几乎没有,只是少了一个读分流点✅已落地
第二档半同步复制(semi-sync):主库提交前等从库确认收到 binlog牺牲写性能换强一致,延迟敏感型写入变慢⬜ 未落地(README 列为备选策略)
第三档从库并行复制(MySQL 5.7 MTS):从库 SQL 线程并行重放需要配置,且并行度受事务冲突限制⬜ 未落地(README 列为备选策略)

本项目的选择是只用第一档,并且明确不承诺"零延迟"。理由是:本项目的读方法全部是"列表展示类",容忍秒级延迟;凡是"读来改"的都留在主库了。也就是说——延迟仍然存在,但它不再落在任何会写坏数据的路径上。

这是一个很重要的表达方式:不是"我们消除了主从延迟",而是"我们把延迟隔离在了不会造成错误的地方"。

三档解法的取舍一目了然:

渲染错误:Mermaid 渲染失败: Parse error on line 3: ...
加 @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,不会报错)。

最后一条是并发边界里最容易踩的:注解失效的表现不是报错,而是"看起来正常但走了主库"。所以核查数据源路由时,不能只看注解写没写,还要看调用路径是否真的过了代理。

八、面试与简历亮点

  1. 能一句话说清策略:“读走从、写走主、判断走主”——把读写分离的一致性边界讲成了可执行的规则,而不是"我们有读写分离";
  2. 知道延迟不是故障而是属性:因此方案的目标是"隔离"而不是"消灭",这个认知层次比背八股高;
  3. 能指出真正危险的那类问题:读己之写失效只是体验问题,"判断依据过期"才会写坏数据——把风险按后果分级,是工程判断力的体现;
  4. 显式优先于默认:容忍延迟靠默认、需要强一致必须显式写@DS("master"),让例外在代码里可见;
  5. 知道自己的注解会失效:自调用、异步场景下@DS静默失效且不报错。

九、场景题

Q1:用户反馈"删了购物车商品又自己回来了",但你查数据库确认数据是正确的。怎么解释、怎么修?

解释:删除走主库、列表查询走从库,用户"秒刷"时从库还没重放到这次删除,于是读到了删除前的快照。数据是正确的,用户看到的是延迟窗口内的旧快照。

排查要点:先确认两台库分别查这条记录,主库已删、从库还在 → 立刻能定性为延迟而不是删除失败(这一步能省掉大量无效排查)。

修法(按性价比排序):

  1. 该场景强制读主库(@DS("master")),或删除成功后由接口直接返回删除结果、前端本地移除该行,不做二次查询;
  2. 更通用的做法:写操作返回后,对同一用户短时间内的读请求强制走主库("写后粘主库"窗口);
  3. 半同步复制 / 从库并行复制,按下延迟本身(本项目未落地,属备选)。

关键提醒:不要用"加个 sleep 等一下"来解——那是把不确定性换成随机性,线上延迟一波动就会复发。

Q2:同事给pay()加上了@DS("slave")想"减轻主库压力",说"这个方法就是查一下订单,纯读"。你怎么判断这个改动能不能上?

判断标准一句话:这个读的结果,会不会被后面用来做判断或写入?

pay()的validateOrder是典型的"读来改"——它读订单状态是为了决定能不能支付,并且紧接着就要写。走从库意味着:延迟窗口内,一笔已支付的订单可能被从库读成WAIT_PAY,于是重复支付被放行。这是资损,不是体验问题。

所以答案是不能上。正确做法是:要减主库压力,应该从它周围的纯展示读(订单详情、我的订单、购物车列表、消息列表)入手,而不是从带判断的读入手。如果确实要优化pay(),方向是缩短它的主库事务、减少锁持有时间,而不是把它的读挪到从库。


一句话总结:主从延迟是异步复制的固有属性,不可能被"消灭",只能被隔离。订单系统的做法是把延迟全部赶到"读出来只用于展示"的路径上,而把"读出来要用于判断或修改"的操作留在主库——读走从、写走主、判断走主。

推荐标签:MySQL、主从延迟、读写分离、数据一致性、微服务

返回列表