ARTICLE DETAIL

资讯详情

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

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

主从延迟会造成什么问题?订单系统\“读走从、判断走主\“的一致性策略 主从延迟会造成什么问题订单系统读走从、判断走主的一致性策略承接上一篇《MySQL 读写分离完整链路》那篇讲怎么把读写分离搭起来这篇只讲搭建之后必然要还的那笔债——主从延迟。文中策略与判定全部来自笔者的微服务订单系统order-system-cloud的落地实践属于备选方案、未落地的部分会显式标注。一、业务背景一个删不掉的购物车先说一个真实场景它比任何原理图都更能说明问题用户点删除购物车商品→ 请求打到主库DELETE 成功接口返回200→ 用户秒刷购物车列表这个查询走了从库 → 刚才删掉的那一项又冒出来了用户看到的现象是删除失败但数据库里数据是对的、接口也返回成功了。如果没有意识到主从延迟这个 bug 会非常难查——因为你在任何一台库上单独查都是对的。同样的形态还有很多用户完成支付 → 主库把订单改成 PAID → 用户秒进订单详情走从库→ 仍显示待支付用户下单成功 → 主库写入订单 → 用户秒进我的订单走从库→ 列表里没有这一单这三个场景有一个共同点“刚刚写过的数据马上就要读”。这就是主从延迟唯一会咬人的地方也是本文全部策略的出发点。二、主从延迟的本质异步复制主从复制靠三个线程接力主库 Dump 线程 ──推 binlog──▶ 从库 IO 线程 ──▶ relay log ──▶ 从库 SQL 线程 逐条重放关键在于主库提交事务时并不等待从库重放完成。主库只要把 binlog 写出去就返回了从库什么时候追平是它自己的事。这中间的时间差就是主从延迟replication lag。所以延迟不是一个故障而是异步复制的固有属性。它不是会不会发生的问题而是这一瞬间有多长的问题——正常情况是毫秒级但只要有放大因素下一节会讲它会突然拉长到秒级甚至分钟级。这条认知很重要如果你把延迟当成故障来处理你会去修它但它是设计的一部分你只能在业务上决定哪些操作不能容忍它。下面是主从复制的完整链路与延迟产生的位置从库主库推送 binlog写入逐条重放网络传输主库不等待从库直接返回放大因素业务写入事务提交Dump 线程binlogIO 线程relay logSQL 线程从库数据延迟窗口 主库提交到从库重放完成的时间差毫秒级 → 秒级甚至分钟级三、延迟会造成的四类线上问题把延迟的后果归类比零散记场景更有用类型表现危险程度读己之写失效用户刚提交的修改自己刷新看不到高——用户直接感知像 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→SENDINGMessageService.myMessages()—— 消息列表MQ 消费者插入 / 更新判断规则可以写成一句可执行的检查这个方法读出来的数据会不会被用来做后续的判断或写入会就走主库。代码形态ServicepublicclassOrderServiceImplimplementsOrderService{/** 纯展示读出来直接返回给前端不做任何判断 → 容忍延迟走从库 */DS(slave)OverridepublicOrderDetailVOdetail(LongorderId){...}/** 先读后写读状态是为了决定能不能改 → 必须走主库 */OverrideTransactional(rollbackForException.class)publicvoidpay(LongorderId,LonguserId){OrderordervalidateOrder(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五、延迟从哪来五个放大因素正常情况延迟是毫秒级会让它突然拉长的通常是这几种从库 SQL 线程单线程重放MySQL 5.6 之前从库只有一个 SQL 线程串行重放主库并发写入的压力会全堆在这里大事务一次更新几十万行从库要一行一行重放主库早提交完了DDL 操作加字段、加索引在主库执行完从库还要重放一遍从库自身压力大从库既要重放 binlog 又要扛读流量读 QPS 一高重放就慢——这是读写分离方案里最讽刺的一种延迟来源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不会报错。最后一条是并发边界里最容易踩的注解失效的表现不是报错而是看起来正常但走了主库。所以核查数据源路由时不能只看注解写没写还要看调用路径是否真的过了代理。八、面试与简历亮点能一句话说清策略“读走从、写走主、判断走主”——把读写分离的一致性边界讲成了可执行的规则而不是我们有读写分离知道延迟不是故障而是属性因此方案的目标是隔离而不是消灭这个认知层次比背八股高能指出真正危险的那类问题读己之写失效只是体验问题判断依据过期才会写坏数据——把风险按后果分级是工程判断力的体现显式优先于默认容忍延迟靠默认、需要强一致必须显式写DS(master)让例外在代码里可见知道自己的注解会失效自调用、异步场景下DS静默失效且不报错。九、场景题Q1用户反馈删了购物车商品又自己回来了但你查数据库确认数据是正确的。怎么解释、怎么修解释删除走主库、列表查询走从库用户秒刷时从库还没重放到这次删除于是读到了删除前的快照。数据是正确的用户看到的是延迟窗口内的旧快照。排查要点先确认两台库分别查这条记录主库已删、从库还在 → 立刻能定性为延迟而不是删除失败这一步能省掉大量无效排查。修法按性价比排序该场景强制读主库DS(master)或删除成功后由接口直接返回删除结果、前端本地移除该行不做二次查询更通用的做法写操作返回后对同一用户短时间内的读请求强制走主库写后粘主库窗口半同步复制 / 从库并行复制按下延迟本身本项目未落地属备选。关键提醒不要用加个 sleep 等一下来解——那是把不确定性换成随机性线上延迟一波动就会复发。Q2同事给pay()加上了DS(slave)想减轻主库压力说这个方法就是查一下订单纯读。你怎么判断这个改动能不能上判断标准一句话这个读的结果会不会被后面用来做判断或写入pay()的validateOrder是典型的读来改——它读订单状态是为了决定能不能支付并且紧接着就要写。走从库意味着延迟窗口内一笔已支付的订单可能被从库读成WAIT_PAY于是重复支付被放行。这是资损不是体验问题。所以答案是不能上。正确做法是要减主库压力应该从它周围的纯展示读订单详情、我的订单、购物车列表、消息列表入手而不是从带判断的读入手。如果确实要优化pay()方向是缩短它的主库事务、减少锁持有时间而不是把它的读挪到从库。一句话总结主从延迟是异步复制的固有属性不可能被消灭只能被隔离。订单系统的做法是把延迟全部赶到读出来只用于展示的路径上而把读出来要用于判断或修改的操作留在主库——读走从、写走主、判断走主。推荐标签MySQL、主从延迟、读写分离、数据一致性、微服务
返回列表