分布式事务实战:订单与库存扣减的最终一致性方案解析

发布时间:2026/10/7 11:01:37
分布式事务实战:订单与库存扣减的最终一致性方案解析
下单扣库存这个事做后端的人早晚要遇上。你辛辛苦苦把订单服务拆出来、库存服务独立部署结果线上突然来一次“订单创建成功但库存没扣”或者更尴尬的“库存扣了但订单根本没生成”两边一比对全是脏数据。分布式事务、分布式事务一致性这些词听起来像高深的中间件理论但落到订单与库存分布式事务这种具体场景里本质就是一句话多个服务各自拿着自己的数据库事务怎么合起来还能像同一个事务一样不出错。这篇文章我尽量用实际能落地的思路把这层窗户纸捅破适合正在做微服务改造、被数据一致性折磨的后端同学也适合想选型但被各种方案绕晕的架构师。1. 先搞明白分布式事务到底卡在哪1.1 订单与库存的一个小例子把问题说透先看一个最简单的下单流程用户在商城里点“提交订单”订单服务在订单库里插入一条订单记录库存服务在库存库里扣减一个商品库存。单体时代这根本不是问题订单表和库存表放同一个数据库一个本地事务包住两条SQL就行BEGIN; INSERT INTO orders(...) VALUES(...); UPDATE stock SET stock stock - 1 WHERE product_id ...; COMMIT;任何一步失败整个事务回滚数据不会出现中间状态。一旦服务拆开订单表和库存表落在两个独立的数据库里事务边界也被拆开了。下单接口变成先调订单服务再调库存服务或者通过消息异步触发库存扣减。这时候最怕的就是“只做了一半”订单写进去了库存没扣成或者库存扣了订单创建失败。两个事务之间没有原子性这就是分布式事务要解决的第一个核心问题——跨服务的原子提交。很多初学者以为“分布式事务”是个框往里套个什么中间件就完事了。但你先得承认一个现实网络会超时、服务会宕机、消息会丢失强一致在分布式环境下是非常昂贵的。后面所有方案本质上都是在“一致性、可用性、性能”之间做取舍。1.2 CAP定理一致性、可用性、分区容错性三选二分布式系统绕不开CAP。C是一致性A是可用性P是分区容错性。网络分区Partition在真实环境里一定会发生所以P是必须选的剩下的C和A你只能二选一作为优先保障。拿订单和库存来说如果优先保证一致性那当订单服务和库存服务之间网络抖动时下单接口必须停下来等两边确认期间整个功能不可用这就是牺牲了可用性。如果优先保证可用性那网络抖动时订单照常创建库存先放一放用户看到下单成功但后台数据可能短暂不一致等网络恢复后再慢慢补齐。这个取舍没有对错只有场景适不适合。电商下单这种场景用户点了一下按钮你让他等十秒才出结果他大概率直接卸载App。所以大多数互联网业务宁可选择短暂不一致也要保证核心链路可用然后靠补偿机制把数据追平。1.3 BASE理论最终一致性才是分布式场景的主流BASE理论是CAP里选择可用性之后衍生出来的一套实践思路基本可用Basically Available、软状态Soft State、最终一致Eventually Consistent。翻译成人话就是我不保证你查到的数据永远是刚写完的最新值但我保证经过一小段时间之后数据一定能收敛到一致状态。整个过程允许有中间状态这中间状态就是“软状态”。说个我自己的感受很多同学一听到“最终一致”就皱眉觉得这是和稀泥。但实际上从用户视角看下单后库存扣减延迟两三秒是完全无感知的。真正要命的不是短暂不一致而是扣库存和订单状态永远对不上还没有任何机制去修复。“最终”不是“放任不管”而是用补偿和对账去兜底最终收敛。2. 主流方案横评少走弯路2.1 2PC/3PC教科书里的方案生产环境慎用两阶段提交2PC是分布式事务最经典的理论模型分准备阶段和提交阶段。协调者问所有参与方“能不能提交”大家都说“能”才统一发提交指令有一个人说“不能”就统一回滚。理论上很完美但落地就暴雷。最大的问题是同步阻塞准备阶段事务资源一直被锁住参与者必须等协调者的最终指令这个等待期间数据库连接、行锁全被占着并发稍微一高系统直接瘫。另一个硬伤是协调者单点协调者一旦宕机所有参与者卡在中间状态不知道到底是提交还是回滚。3PC多加了一个预提交阶段减少了阻塞窗口但同样没法根治。所以我的建议很直接除非你的系统内部多个数据库还在同一个机房、流量很小、并且已经用了XA数据源否则别在生产环境硬上2PC。它更适合用来理解分布式事务的原理而不是拿来解决问题。2.2 TCCTry/Confirm/Cancel业务侵入高但最灵活TCC把每个事务操作拆成三个阶段Try预留业务资源Confirm确认执行业务Cancel释放预留资源。还是库存这个例子。Try阶段不是直接扣库存而是把库存“冻结”一部分比如商品有100件用户要买3件Try就把可用库存改成97、冻结库存加3。Confirm阶段确认订单成立把冻结的3件真正扣掉。Cancel阶段关单失败把冻结的3件释放回可用库存。这种做法的好处是控制力最强状态机由业务自己定义不受底层资源锁的限制性能比2PC好太多。坏处也明显每个参与方都得实现三个接口而且Try、Confirm、Cancel都得满足幂等开发成本非常高。TCC适合那种事务链路短、对一致性要求高、业务状态容易拆分的场景。你要是整个系统几十个接口全部套TCC光是幂等和补偿代码就能写到你怀疑人生。2.3 Saga长流程业务的务实之选Saga的核心思想是把一个大事务拆成一组本地事务每个本地事务都有对应的补偿事务。比如订单流程创建订单 - 扣库存 - 扣余额 - 发积分。如果扣余额失败就反向执行补偿扣库存、补偿创建订单。Saga分两种事件编排Choreography和集中编排Orchestration。事件编排是各个服务通过监听对方的完成事件来触发下一步服务之间完全解耦但流程隐晦出了问题很难排查。集中编排是有一个协调服务专门负责调度每一步流程清晰补偿逻辑集中管理我推荐团队用这种。Saga的适用场景是长事务、跨很多服务、业务流程本身就有明确的先后顺序。它不要求实时强一致只要最后业务能走完失败能退回去就行。缺点是编码量大补偿逻辑多了以后调试比较难受。2.4 本地消息表不引中间件也能做最终一致性在说RocketMQ事务消息之前必须先聊聊本地消息表因为事务消息本质上就是它的工业级升级版。方案很简单在业务数据库里建一张消息表业务操作和消息写入放在同一个本地事务里。比如创建订单时同时往消息表插入一条“待发送”的扣库存消息。本地事务保证要么订单和消息都成功要么都失败。然后由一个定时任务扫描消息表把状态为“待发送”的消息投递到MQ消息发出后更新状态。下游消费成功后再回调更新状态。这个方案的优点是不需要额外引入分布式事务中间件利用的是“同一个数据库本地事务”的天然原子性。缺点是消息表会随着业务量增长变得很大定时任务扫描有延迟消息状态维护也得自己做。作为兜底方案它反而很实用。2.5 事务消息把“本地消息表”下沉到MQRocketMQ的事务消息把本地消息表的逻辑收敛到了MQ内部。发送方先发送一条“半消息”half message这条消息对消费者不可见然后发送方执行业务本地事务事务成功就提交消息事务失败就回滚消息。如果MQ长时间没收到提交或回滚指令会反向询问发送方事务状态这就是事务回查。对比本地消息表事务消息的好处是消息生命周期由MQ托管回查机制更成熟不用自己写定时任务扫表。但它的前提是你得接受引入RocketMQ这类支持事务消息的中间件并且对延迟有一定容忍度。我直接给一张表总结五种方案的取舍方案一致性强度业务侵入可用性风险复杂度适合场景2PC强一致低协调者阻塞风险高中极少用理论为主TCC强一致可控高三个接口无全局锁较好高短链路、高一致性控制Saga最终一致中高补偿事务好中高长流程、跨多服务本地消息表最终一致低好依赖定时任务低无MQ事务能力时的兜底事务消息最终一致低好依赖MQ中异步化核心链路首选3. 订单扣库存实战选型与落地细节3.1 一致性目标拆解先判断能不能接受最终一致动手写代码前我习惯先问三个问题用户操作完成后多久之内必须看到完整结果不一致的状态会不会导致资损业务上能不能接受补偿和冲正下单减库存这个链路用户点击下单后订单状态变“待支付”库存扣减完全可以在几秒内完成甚至用户不查看库存数字根本感知不到。这种场景适合最终一致性用事务消息或本地消息表就够了。但如果是充值扣余额用户立刻要看到余额变化你就得考虑TCC或者更实时的事务方案。一致性目标一旦定错后面全盘皆输。3.2 路线一RocketMQ事务消息实现订单与库存最终一致我做一个简化的订单服务伪代码用RocketMQ事务消息。第一步准备一个事务消息监听器public class OrderTransactionListener implements TransactionListener { Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { // 半消息发送成功后执行本地订单创建 try { Order order (Order) arg; orderMapper.insert(order); return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; } } Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // 回查半消息长时间没有提交/回滚时根据订单是否存在决定 Long orderId (Long) msg.getUserProperty(orderId); return orderMapper.selectById(orderId) ! null ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } }第二步发送事务消息TransactionMQProducer producer new TransactionMQProducer(order_producer_group); producer.setTransactionListener(new OrderTransactionListener()); producer.start(); Message msg new Message(ORDER_STOCK_TOPIC, body); msg.putUserProperty(orderId, String.valueOf(orderId)); TransactionSendResult result producer.sendMessageInTransaction(msg, order);第三步库存服务作为消费者收到消息后扣减库存关键逻辑用条件更新防止超卖UPDATE stock SET stock stock - #{count} WHERE product_id #{productId} AND stock #{count};如果你用Spring可以把事务消息发送封装成注解但核心思想不变半消息和本地事务绑定下游消费保证幂等。这套方案产线上我实测过高峰期只要不出现长时间的MQ集群故障基本不会漏消息。3.3 路线二TCC手动实现库存冻结TCC的落地比事务消息要重但控制力完全在业务手里。我以“冻结库存”为例给你一个接口设计参考。库存表增加两个字段total_stock总库存、frozen_stock冻结库存可用库存 total_stock - frozen_stock。Try阶段UPDATE stock SET frozen_stock frozen_stock #{count} WHERE product_id #{productId} AND (total_stock - frozen_stock) #{count};Confirm阶段UPDATE stock SET total_stock total_stock - #{count}, frozen_stock frozen_stock - #{count} WHERE product_id #{productId};Cancel阶段UPDATE stock SET frozen_stock frozen_stock - #{count} WHERE product_id #{productId};Try成功锁定了3件库存Confirm真正扣库存Cancel释放冻结库存。注意每个阶段都必须幂等因为网络重试可能导致同一个Try、Confirm请求被发送多次。我处理幂等的做法是在库存流水表里加唯一键比如用全局事务ID做唯一约束重复执行直接返回成功。3.4 消息不丢不重不乱序生产环境必备的可靠性措施方案选完之后真正考验功力的是可靠性细节。我用三个词总结不丢、不重、不乱序。不丢。事务消息模式里半消息提交后MQ保证消息不会丢失但消费者这边可能处理失败。所以消费端一定要开手动ACK逻辑处理成功才提交offset。如果消费失败允许MQ重试重试多次还失败就转入死信队列配合告警人工处理。不重。MQ的重试机制会导致消息重复投递库存扣减不能扣两次。最简单的方法是用唯一键去重给每条消息带上orderId库存扣减前先查询流水表有没有这条orderId的扣减记录有就直接忽略。不乱序。如果同一个订单的多个操作消息都发到同一个Topic要保证按顺序执行就得让同一个订单的消息进同一个消费队列。RocketMQ支持消息队列选择器用orderId做取模保证相同订单进入同一队列消费端串行处理。3.5 幂等设计最终一致性方案的命门我在前几节反复提到幂等因为它太重要了。分布式事务任何一个环节都可能重试没有幂等补偿逻辑本身就是制造脏数据。库存扣减的幂等建议用三步全局唯一操作流水号条件更新SQL流水表记录。伪代码思路如下public boolean deductStock(StockDeductRequest request) { // 1. 流水号唯一键防重 if (stockFlowMapper.selectByRequestId(request.getRequestId()) ! null) { return true; } // 2. 条件更新防止超卖 int rows stockMapper.deductWithCondition( request.getProductId(), request.getCount()); if (rows 0) { throw new BizException(库存不足); } // 3. 记录流水 stockFlowMapper.insert(request); return true; }流水表的作用不只是防止重复它还是后面对账补偿的数据源。很多团队忽略流水表只靠条件更新防超卖等出问题想查“到底哪些订单扣了库存”时手里没有任何可追溯的记录这才是真正的灾难。4. 真刀真枪踩过的坑问题排查与调优4.1 消息发送成功但一直没消费有一次线上反馈订单创建成功库存迟迟不扣。排查半天发现是事务消息的回查机制出了问题。executeLocalTransaction里插入订单数据库耗时较长提交半消息后本地事务还没返回MQ等不到提交状态就开始回查。回查接口查的是订单号但订单此时还没提交于是返回ROLLBACK消息被回滚了。这个坑的关键在于回查接口不能简单查“主业务表里有没有数据”而是要查本地事务的最终结果。更稳妥的做法是在本地事务里先写一张transaction_log表记录事务状态回查时以这张表的状态为准。另外我还习惯把回查超时时间调大一点给本地事务足够的执行窗口。4.2 库存扣成负数缓存扣减与数据库不一致另一个高频事故是Redis库存和DB库存不一致。很多团队为了扛高并发先扣Redis缓存库存再异步同步到DB。Redis扣成功了但异步同步失败或顺序颠倒DB库存就一直不对超卖问题看起来像是“Redis和DB数据不一致”。我的排查思路是先确定哪个是最终数据源。库存的最终数据源必须是数据库Redis只是加速层。任何Redis扣减都必须记录操作流水并通过MQ异步把流水同步到DB执行条件扣减。出现不一致时用流水表重放一遍即可不要直接改Redis数值“临时修一下”临时修改往往引发更大的乱子。4.3 对账补偿最终一致性最后一道防线不管方案设计得多完善我都建议加一道对账补偿的任务。每天凌晨跑一次比对订单表和库存流水表订单状态是已创建但库存流水缺失的触发补扣库存流水存在但订单状态异常的触发冲正。对账任务不需要实时但对业务兜底价值极大。我见过一个团队平时一致性问题很少全靠消息重试和事务回查兜底结果有一次MQ集群版本升级导致消息大面积积压订单数据乱了几天最后就是靠对账脚本一条条修复的。对账不是重复劳动它是分布式一致性方案的保险丝。4.4 性能与可用性调优引入分布式事务后性能瓶颈往往不在事务方案本身而在锁和日志。TCC里Try阶段如果长时间不释放冻结资源会造成大量库存被“锁死”所以一定要给冻结记录加超时自动释放。事务消息方案里消费端如果串行消费吞吐会卡在消费速度上建议根据订单号分片并行消费同一订单串行、不同订单并行。可用性方面我强烈建议做降级开关。正常情况下用RocketMQ事务消息MQ一旦不可用立刻切到本地消息表方案用定时任务投递。这个降级逻辑平时没人注意大促前必须压测验证。5. 用开源框架能省多少事Seata 接入提醒5.1 AT模式自动生成反向SQL如果不追求手写TCC可以了解下Seata的AT模式。它的核心思路是在全局事务里记录每个分支事务的undo_log业务SQL执行完后自动生成反向SQL。全局事务提交时删除undo_log回滚时用反向SQL恢复数据。AT模式对业务侵入很小你只需要在业务方法上加GlobalTransactional注解数据源换成Seata代理的数据源。但它有局限只适合简单的INSERT、UPDATE、DELETE不适合复杂SQL、存储过程、批量操作。反向SQL在某些场景下生成不准确比如SQL里带函数、子查询回滚数据可能对不上。5.2 Seata接入注意点如果你决定用Seata注意几件事。第一事务分组名和配置中心要统一不同环境别混。第二AT模式靠全局锁实现写隔离并发高时锁等待时间会变长别把它当无锁方案用。第三undo_log表必须和业务表在同一个数据库否则本地事务和undo_log不在同一事务边界回滚就失效。另外Seata更适合内部短链路事务跨公司跨系统的调用链路上AT模式不现实因为对方不见得愿意接你的全局事务。跨组织边界还是事务消息或者Saga更靠谱。5.3 我的选型经验先别急着上框架最后说点实际经验。很多团队一听说分布式事务稳定马上引入Seata或事务消息结果业务没那么复杂反而引入一大堆运维成本和性能损耗。我自己的选型顺序是能不用分布式事务就不用能异步最终一致就不做强一致如果必须在核心链路上保证数据不错优先事务消息如果业务状态复杂、需要实时控制再考虑TCC只有极少数场景才需要Seata的AT模式。踩过几次坑之后我的体会是分布式事务里“一致性”和“可用性”注定要博弈技术方案只是把你选择的天平变成可执行的机制。真正让订单与库存分布式事务“轻松搞定”的不是某个万能中间件而是把业务拆清楚、把幂等做扎实、把对账补起来。这套基本功在无论未来换什么框架心里都不慌。