ARTICLE DETAIL

资讯详情

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

订单与库存分布式事务:从强一致到最终一致的方案选型

订单与库存分布式事务:从强一致到最终一致的方案选型 你见过最诡异的线上事故是什么我印象最深的是订单表里突然出现了一批“幽灵订单”用户明明下单成功库存扣减却在几毫秒后失败了等仓库发货时才发现超卖。更隐蔽的是另一类库存先扣了订单却因为超时被系统关掉冻结的额度一直没还回去。这两类问题的背后其实是同一个根因——跨服务、跨库的操作没有在分布式事务一致性层面约束住。这篇文章我不想写教科书式的理论。我想用一个最典型的“订单与库存分布式事务”场景把分布式事务这件事从头到尾讲透它到底在解决什么、那些常见的方案各自是什么原理、真到项目里怎么选、落地时会踩哪些坑。“轻松搞定”当然有标题党的成分但如果你理解了底层逻辑它确实没有想象中那么可怕。适合正在做微服务拆分、被数据一致性搞得睡不着觉的后端同学参考。1. 一次下单扣库存订单和库存怎么就变成了两个世界1.1 那个让我凌晨三点被叫醒的“幽灵订单”事故先聊聊那次事故。当时系统还比较简单订单表、库存表在同一个MySQL库扣减库存就是一条UPDATE加一个事务老老实实用了很多年。后来业务量上来订单服务、库存服务拆开了数据库也独立部署。拆完不到一个月线上就出了一单超卖凌晨三点我被电话叫醒查了一整天才定位到问题创建订单和扣减库存变成了两个独立的数据库操作中间任何一步失败两边数据就永久不一致。订单创建成功了库存没扣成——超卖库存扣了订单创建失败——库存凭空蒸发。这两个方向的问题就是典型的分布式事务一致性缺失导致的。最痛苦的不是故障本身而是这类问题在少量流量下几乎测不出来等到大促才集中爆发一爆发就是资损。1.2 为什么“一个事务”在分布式环境里就不灵了在单体时代我们把订单表和库存表放在同一个数据库里本地事务天然保证ACID要么都成功要么都回滚。但服务拆了、库也分了以后一次下单操作里先写订单库再写库存库这两个独立的数据库各有各的事务谁也不知道对方是成功还是失败。CAP理论里最核心的矛盾就在这当网络出现分区时你必须在一致性和可用性之间做取舍。分布式事务里所有参与者不在同一个时钟、同一个内存视图下想让它们像同一个数据库那样强一致就必须付出协调成本而这个成本在跨网络环境下非常高。所以业内才有了两条路线强一致派和最终一致派。没有哪条绝对正确只有合不合适自己的业务。这个选择逻辑我会在后面的章节展开。1.3 先分清需求你要的是“不能错”还是“最终不能错”动手设计之前最重要的一件事是明确一致性需求。我见过太多团队一上来就上Seata结果库存流水被全局锁卡成串行大促直接打满CPU。也有团队用纯消息异步结果账目对不上天天靠人工补单。我的判断方法很简单问业务这笔操作发生异常后你能接受多少延迟才看到正确数据如果能接受秒级甚至分钟级收敛就用最终一致如果一秒都不能错比如账户扣款才值得考虑强一致方案。订单库存这个组合比较有意思它恰好卡在中间用户下单时对超卖是零容忍的但库存数据的最终修正又可以被异步流程兜住。所以这种场景往往是混合方案而不是一个方案走到黑。2. 强一致路线从XA到Seata AT以及必须付出的锁代价2.1 两阶段提交的老规矩先问一圈再动手分布式事务最早的主流方案就是两阶段提交2PCXA是它的数据库实现标准。核心思路很好理解先请一个协调者让所有参与者进入准备状态确认每个库都准备好了再统一提交只要有一个没准备好全部回滚。听起来完美。但你真去用XA的时候就会发现几个问题。第一prepare阶段资源就被锁住了从prepare到commit的时间取决于最慢的参与者而网络延迟是不可控的长锁几乎是必然第二协调者是单点它一挂所有参与者都卡在prepare状态谁也不敢提交第三XA对数据库版本、连接池、通信层都有要求为了一个下单功能把基础设施折腾一遍很多团队吃不消。所以在互联网高并发场景里纯XA的应用越来越少它更合适金融机构那种低并发、强管控的内部系统。2.2 Seata AT模式的聪明与局限Seata的AT模式可以理解为对XA的一种改良它没有在业务全链路持有数据库锁而是靠“全局锁undo_log”实现回滚。具体来说事务发起方生成一个全局事务ID各个分支事务在执行SQL之前先记录数据变更前镜像和变更后镜像写入undo_log本地事务照常提交提交后释放数据库行锁但Seata在TC事务协调器上还管理着一把全局锁用来防止其他全局事务在回滚前读到脏数据。AT模式最大的价值是开发成本低。你基本不需要改动业务代码Seata通过DataSourceProxy拦截SQL自动打理镜像和回滚。我见过不少项目从本地事务切到AT模式只加了几个注解和配置就跑了。但代价也很实在全局锁在高并发下会成为吞吐瓶颈尤其像库存表这种热点数据两个全局事务同时想改同一行后到者必须自旋等待RT直接拉高。另外AT模式默认的隔离级别实际上没有完全做到读已提交极端情况下会读到中间状态的脏数据业务上必须心里有数。2.3 为什么订单库存这种高频场景不能全用强一致我们曾经做过压测对比同样是一万笔下单请求本地事务每笔平均3毫秒切到Seata AT之后最坏情况每笔涨到150毫秒以上热点库存行还出现过一批全局锁超时异常。原因就是库存行被多个事务争用而后到的每一个都要等待、重试、再撞锁。所以我的结论是强一致方案适合低频、数据敏感、错了就出大事的操作但对订单库存这种高频热点场景把每笔扣减都拖进全局事务里等于把分布式事务的锁放大到了业务链路上。业务量小无所谓业务量一涨第一个崩的就是它。3. 最终一致路线本地消息表、事务消息、TCC、Saga怎么选3.1 本地消息表最朴素但从来不过时本地消息表的核心思想是把“跨库事务”改造成“本地事务异步投递”。以订单和库存为例创建订单时在同一数据库事务里既插入订单又插入一条状态为“待投递”的消息这个本地事务提交了消息一定在后台定时任务扫这张消息表把消息发给MQ或直接调用库存接口成功后就改成“已投递”。这个方案最容易被轻视因为它太“土”了。但它在很多场景下比任何中间件都稳定因为它不依赖额外的分布式组件唯一的消息状态是写在数据库里的你甚至可以手动查、手动改、手动救。缺点是消息表和业务表绑定跨系统之间没有通用的投递语义而且定时扫描有延迟。不过对于秒级最终一致来说大部分业务完全可以接受。3.2 RocketMQ事务消息半消息与回查机制如果不想维护本地消息表RocketMQ的事务消息是更加工程化的替代品。它的流程是生产者先发送一条“半消息”到BrokerBroker先不收落然后执行本地事务本地事务成功就Commit消息变为可见本地事务失败就Rollback。如果本地事务执行了半天没有回话Broker会主动回查生产者问这次本地事务到底成没成。这个机制看着漂亮落地时却有一个很容易忽略的坑回查接口拿到的数据往往来自一个还没提交的事务。数据库默认隔离级别是读已提交你在本地事务里写的数据回查线程不一定读得到。解决方式是在业务表里单独记一个事务状态字段提交之前先置为“处理中”提交后再置为“成功”回查接口只查这个状态宁可查不到也别误判。RocketMQ事务消息我实际用下来确实能覆盖大量的“先写业务、再通知下游”场景但前提是本地事务要有明确的状态记录可回查。3.3 TCC把补偿逻辑写在业务层灵活但费心TCC这个名字对应三个阶段Try、Confirm、Cancel。Try阶段做资源的检查和预留Confirm阶段做真正的业务提交Cancel阶段做补偿释放。以库存为例Try就是把可售库存扣减到冻结库存Confirm把冻结库存转成已出库Cancel把冻结库存回补到可售库存。相比AT模式TCC是一种侵入式方案你要为每个业务接口手写三个方法。好处是业务可以完全控制锁的范围和粒度性能上限更高坏处是“空回滚”和“悬挂”两个问题必须自己处理。所谓空回滚就是下游收到了Cancel但这个事务的Try根本没执行成功过Cancel在执行回补时发现没有可补的数据悬挂则是Cancel先到了Try后到后到的Try必须被拒掉否则资源会被释放后再被占用一次。TCC适合像库存预占、账户预扣这种“资源预留型”业务但每一组Confirm/Cancel方法都要配防重和状态判断开发量并不小。3.4 Saga与选型结论没有银弹只有取舍Saga的思路是把一个长事务拆成多个本地事务当前一步成功就执行下一步失败就执行前面所有步骤的补偿操作。它适合订单流程里跨了多个服务的场景比如下单、赠品、发票、积分一个失败逐个补偿。事务中间状态对外可见所以需要用状态机把每一步的中间态显式管理起来。我把常用方案拉了一张表方便你对比方案一致性方向锁粒度开发成本数据可见性适用场景XA/2PC强一致数据库资源锁大低隔离低频、强管控Seata AT强一致/准强全局锁较大低基本隔离中小并发、短事务本地消息表最终一致无中中间态可见异步可靠通知事务消息最终一致无中半消息不可见跨服务投递通知TCC最终一致业务预留锁小高中间态可见高频、资源预留Saga最终一致无高中间态可见长流程编排订单与库存场景的选型我的实际建议是分两层实时路径用“库存服务内部扣减Redis预占或数据库乐观锁”保证不超卖业务路径用事务消息或本地消息表把订单生成和库存确认之间的账异步对平。如果一定要严格不发超卖且扣减实时可见那考虑TCC但要有心理准备后面的坑比方案多。4. 订单与库存的一致性问题一个可落地的方案拆解4.1 订单状态机与库存流水先把模型定下来无论是哪种方案表结构和状态机都得先定清楚。我常用的设计是订单表加一个状态字段库存表拆成“可售库存”和“冻结库存”两个概念另外再加一张库存流水表每一笔扣减、回补都有唯一流水号。订单状态我控制在四到五个待支付、已支付、已发货、已完成、已取消。库存状态跟着订单流转冻结、扣减、回补。状态机的核心价值在于所有对账、补偿、幂等判断都基于状态而不是基于某个中间件的消息。消息可能丢失网络可能抖动但数据库状态不会自己变。订单状态和库存流水的配对相当于给分布式操作上了“账本”这是整个方案的定海神针。4.2 完整时序从下单到扣库存每一步都写到数据库我按本地消息表的思路给你拆解一次下单流程顺序非常重要。第一步订单服务在自己的库里开启本地事务插入订单状态待支付同时插入一张订单事件消息状态待投递。第二步本地事务提交后由消息投递线程把订单事件发送到MQ。第三步库存服务消费消息在自己的库里开启本地事务插入库存流水扣减可售库存或增加冻结库存并根据处理结果更新消息消费记录里的状态。第四步消息消费成功后回执订单服务把事件消息状态改为已投递。第五步如果中间任何一步失败定时任务重试超过重试次数则进入对账人工干预。这套流程里没有一个强一致全局事务但在任何时刻订单和库存都能通过流水数据和消息状态推导出正确的行动方向。哪怕消息丢了定时任务还会补偿。代价只是秒级或分钟级的一致性延迟但对大多数电商业务来说完全够用。4.3 核心代码骨架一张消息表和一段幂等消费逻辑我给你看一个最小可用的消息表结构这是整套方案的底线CREATE TABLE biz_event_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL COMMENT 业务单号如订单号, event_type VARCHAR(32) NOT NULL COMMENT 事件类型如ORDER_CREATED, payload TEXT NOT NULL COMMENT 消息内容JSON格式, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待投递 1已投递 2失败, retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_event (biz_id, event_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这个唯一索引uk_biz_event它就是幂等的第一道防线。库存服务消费消息时先按biz_id查流水不存在才执行扣减存在就直接返回成功。这样MQ重复投递、定时任务重复扫描都不会造成重复扣减。库存扣减的核心代码骨架大概是这样的Transactional public void deductStock(StockDeductRequest req) { // 幂等检查流水表已存在则直接返回 if (stockFlowRepo.findByBizId(req.getBizId()) ! null) { return; } // 乐观锁扣减可售库存 冻结数量时才扣 int rows stockMapper.deductAvailableStock( req.getSkuId(), req.getQuantity()); if (rows 0) { throw new InsufficientStockException(req.getSkuId()); } // 插入流水记录本次扣减的唯一性 stockFlowRepo.insert(StockFlow.createDeductFlow(req)); }几个关键点再提醒一下第一deductAvailableStock的SQL里必须带上available_stock quantity条件这是防超卖的最后一道锁第二流水表必须先查后插但判断和插入之间要接受极小概率的并发重复更好的做法是用数据库唯一键把重复直接挡住第三业务异常要让本地事务回滚消息消费失败后由MQ或定时任务重试。4.4 幂等、空回滚与悬挂这三只拦路虎必须处理这三个词是分布式事务的专用暗号凡是调过TCC的人听到都会心一笑。幂等指同一个操作重复执行多次结果必须一致。实现幂等的核心是唯一业务ID加上流水表或者在状态机上判断“当前状态是否允许这个动作”。空回滚指的是TCC里Cancel先于Try到达或者Try根本没有成功。这时候Cancel不能直接做回补否则会把别人的数据扣回去。处理方式是在Cancel里先查资源预留记录没有记录就标记成“已空回滚”后面再来的Try必须拒绝执行。悬挂指的是Try的执行晚于CancelCancel已经回补了Try却把资源再预占一次。防悬挂的办法是在事务表里保留本次事务的状态Try执行前先查Cancel是否已经发生过发生过就不要再执行。这三个问题在实际代码里就是一堆状态判断和分支但漏掉任何一个线上都会出现账不平。我自己的习惯是把所有状态流转画成一张表把所有非法分支用代码显式封死宁可报错也不能让状态跳到不该跳的地方。5. 我在生产环境踩过的坑以及“轻松搞定”的真相5.1 Seata全局锁冲突库存表被锁成串行的原因有一段时间我们把库存扣减切到了Seata AT模式压测数据很好看上线后白天流量一来库存服务就开始报全局锁冲突。日志里清一色是Lock wait timeout库存行的更新被TC全局锁串行化了。高并发下所有订单都抢同一件商品的库存行后到的全局事务必须等前面的提交或回滚等太久就直接超时。那次之后我把AT模式从高频扣减路径上撤了下来只保留在低并发且严格要求一致的场景。不是说AT模式不能用而是要注意热点全局事务越多锁冲突概率越高必须压测出真实阈值并且给全局锁配置合理的等待时间和重试次数别用默认值硬扛。5.2 事务消息的半消息状态为什么发成功却永远等不到Commit还有一次线上发现一批订单事件消息一直停在半消息状态既不Commit也不Rollback。查了半天原因是本地事务里更新业务表和发送消息是在同一个事务方法里但提交前回查接口来了回查查的恰好是事务里还没提交的业务数据于是回查线程认为本地事务没成功给Broker回了一个未知状态。最终Broker一直等消息就悬挂在那。后来我把方案改成本地事务先写业务状态再显式更新一张事务状态表这张表里明确记录“本地事务执行中”和“本地事务已完成”。回查接口只查事务状态表不查业务数据。这就把“数据是否可见”和“事务是否成功”两个概念分开了问题再没出现过。5.3 兜底永远要有对账、告警与人工补偿说实话我经历过的分布式事务事故有一半不是方案不好而是缺少兜底监控。方案的逻辑写得再对网络抖一下、机器重启一下总有犄角旮旯的数据对不上。所以每个接入分布式事务的服务都应该有一张对账定时任务每天跑一遍比对订单状态、库存流水、消息表状态发现不一致就告警。我现在的习惯是给每笔扣减加一个“期望库存快照”的字段对账任务直接比对当前库存和历史流水累加值是否一致。再配合每小时的未成功消息积压监控基本能把分布式事务的故障发现时间控制在分钟级而不是业务方投诉到你脸上。5.4 什么时候不要引入分布式事务这个可能有些反直觉但很重要分布式事务不是越高级越好很多“问题”根本不需要分布式事务。如果你的订单和库存还在同一个库里那就用本地事务别为了架构好看而拆开如果你只有低并发的内部系统XA或AT模式也就够了没必要上TCC把代码复杂度拉满如果你只是想保证通知不丢先评估一下最简单的定时对账能不能解决再决定是否引入MQ事务消息。我见过太多团队为了“技术先进性”硬上分布式事务结果真正的成本都在后续维护上。务实一点把方案的收益和成本列清楚再动手这才是“轻松搞定”的真正含义。最后再分享一个小技巧无论你选了哪种方案先把核心的订单状态机画出来把每一步失败时的处理动作写清楚再去碰消息中间件和分布式事务框架。状态机稳了方案就稳了一大半。分布式事务之所以让很多人头疼不是因为原理多复杂而是因为大家在动手前没有把“这笔业务到底怎么做才算成功”这件事先想明白。想明白了剩下的就是选一个趁手的工具把账记清楚然后安安心心等对账结果。
返回列表