ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

事务边界与补偿策略:订单库存分布式事务实战解析

2026/10/8 15:09:34 拓冰建站 浏览量
事务边界与补偿策略:订单库存分布式事务实战解析 1. 从一次下单扣库存线上事故说起事务为什么这么难念做了这么多年后端每逢团队里聊起事务我总觉得这个话题像极了背课文——ACID四个字母谁都背得出来可真到线上出问题的时候十有八九不是栽在概念上而是栽在我以为我懂了的细节里。家家有本难念的经——事务知多少这句话放在开发里一点都不夸张每个业务系统在事务上踩的坑都不一样有的是锁竞争把接口拖垮有的是跨服务数据对不上账还有的是注解事务压根没生效、数据悄悄就乱了。先说我最近一次真实经历。我们的订单服务拆出来之后下单流程是先调库存服务扣减库存再在本地创建订单最后发消息通知支付。单体时代这活儿一个Transactional就全包了拆成微服务之后问题立刻暴露——库存扣了订单因为某个字段校验失败没创建成功用户回头一看钱没扣库存却少了。这可能还算温和的最怕的是反过来订单创建成功库存扣减失败那超卖就来了库房盘点的时候怎么都平不了账。这个事故的根子在于事务边界变了。单体应用里订单表和库存表在同一个数据库一条SQL、一个事务就能保证原子性。拆成两个服务之后事务边界被网络和进程切开数据库自己的事务管不到跨服务跨库的写操作。你本地事务提交了另一个服务的本地事务可能还没开始、可能失败了、也可能网络超时了——三方永远无法同时知道大家都成功了。我说这些不是想贩卖焦虑而是想先把一个观点立住**事务的本质是一个业务动作要么全成功要么全失败。**别被技术框架带偏了事务的边界永远是业务行为本身不是一个方法、一个注解、一个begin/commit。理解到这一层后面所有所谓的事务难题其实都只是边界划分和补偿策略的问题。2. 事务隔离级别的实践账本脏读、不可重复读与幻读的真实代价很多人以为事务隔离级别就是面试题背完四个级别就完事了。直到某天线上出现订单金额对不上库存越扣越奇怪这种诡异问题才意识到隔离级别不是概念题是实实在在的算账题。2.1 四个级别的实际含义先看这张对照表重点不是我背了多少而是每一行在真实数据库里意味着什么隔离级别脏读不可重复读幻读InnoDB的加锁实现READ UNCOMMITTED可能可能可能读不加锁READ COMMITTED不可能可能可能行锁读用快照REPEATABLE READ不可能不可能可能当前读下行锁 间隙锁SERIALIZABLE不可能不可能不可能全表锁/范围锁脏读读到别人没提交的数据。人家回滚了你拿着这条数据继续做业务白忙活一场。不可重复读同一个事务里同一行数据读两次结果不一样。原因是别的会话在你两次读之间提交了修改。幻读同一个事务里同一个查询条件查两次多出几行本来不存在的记录。这回不是行变了是行数变了。MySQL的默认隔离级别是REPEATABLE READ注意它默认用MVCC快照解决了一部分幻读问题——普通SELECT是快照读第一次读时生成快照后续读都从快照取所以不会看到新插入的行。但如果你用的是SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE这种当前读每次都是读最新数据MySQL必须靠间隙锁gap lock去锁住行与行之间的空隙才能挡住新插入。2.2 一个扣减场景的复现我们线上出过一个真实案例库存表只有一行记录stock 10。两个请求同时来扣减一条扣8一条扣5。在READ COMMITTED下如果两条事务都先读到10各自扣减再更新最后库存可能是2或5谁能赢就看提交顺序肯定有一方是超卖或数据错乱。换到REPEATABLE READ下因为行锁的存在第二条扣减会阻塞在UPDATE上等第一条提交后它读到的是已提交的2再扣5变成-3。负库存要是没做校验照样出问题。你会发现隔离级别只解决读到什么的问题根本解决不了业务逻辑合不合理的问题——负数校验、乐观锁版本号、唯一索引防重这些才是程序员自己的责任。2.3 隔离级别的选择建议我的经验是线上不要轻易动隔离级别。你的数据库默认是什么就先用什么。互联网高并发场景里READ COMMITTED因为不用间隙锁死锁概率和锁开销通常更低PostgreSQL默认就是RC这也是为什么很多从PG迁移到MySQL的团队会觉得MySQL更容易死锁。但如果你直接改成RC某些依赖可重复读的业务逻辑又会悄悄出问题。更务实的做法是**隔离级别保持默认并发正确性靠唯一索引、分布式锁、乐观锁、业务状态机去兜底。**什么时候考虑升级到可串行化几乎只有资金对账这种极小并发的场景而且要说清楚——为了这点强一致付出的锁等待代价可能让整个接口的RT翻好几倍这笔账一定要算明白。3. 被误解的Transactional注解事务失效的六种经典场景Spring的Transactional大概是Java后端最容易以为有效、实际没效的东西。它本质是AOP代理原理一句话Spring给你生成一个代理对象方法调用先进代理代理里开事务、调真实方法、再决定提交还是回滚。可一旦调用没有经过代理这个事务就只是一层皮里面什么都没有。3.1 场景一方法自调用Service public class OrderService { public void createOrder(OrderDTO dto) { // 这里走的是this调用不是代理对象 this.createOrderTx(dto); } Transactional public void createOrderTx(OrderDTO dto) { orderMapper.insert(dto); stockClient.deduct(dto.getSkuId(), dto.getCount()); } }外部调createOrder时进入的是代理对象但代理转发给真实对象后this.createOrderTx是真实对象直接调自己的方法根本没再走代理那一层。事务开没开没开。我在生产环境排查过一次扣库存成功、订单插入失败数据全乱日志里啥回滚都没有。最后定位就是自调用。3.2 场景二异常被吞掉Transactional public void deductStock(Long skuId, int count) { try { stockMapper.deduct(skuId, count); } catch (Exception e) { log.error(扣库存失败, e); // 异常吞了事务感知不到照样提交 } }Spring的默认回滚策略是只有RuntimeException和Error抛出到代理层才回滚。你catch住了一切再把异常咽进肚子事务只能认为一切正常然后提交。这代码在单条数据行上看似无所谓一旦后续还有其他写操作前面成功的部分也会跟着一起提交——数据就保持在一个半成品状态。3.3 场景三检查型异常默认不回滚Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) throws Exception { orderMapper.insert(dto); if (dto.getAmount() 10000) { throw new BizException(大额订单需要人工审核); } }方法声明了throws Exception抛的是检查型异常默认情况下Spring不会回滚。所以那些我就随便抛个自定义异常为什么没回滚的坑十有八九是没配rollbackFor Exception.class。习惯上我所有事务方法都写rollbackFor Exception.class宁可多写也不赌默认行为。3.4 场景四到六非public方法、多线程、传播行为非public方法Spring的事务代理对非public方法基本不拦截JDK代理做不到CGLIB有限制方法写成private或protected注解直接失效。多线程事务是和线程绑定的你在事务方法里new Thread开一个子线程去写库子线程的写操作完全不在父线程事务里它自己开一个连接自己提交——失败了你也不知道成功了也没法和父事务一起回滚。传播行为误用Propagation.REQUIRES_NEW是开了个独立的新事务内层方法跑完就提交了不跟外层一起回滚。很多人想用嵌套事务做局部回滚结果外层回滚了内层那笔已经提交账就对不上。反过来PROPAGATION_NESTED才是真正的嵌套保存点但也不是所有框架都支持。3.5 正确打开方式我现在写事务代码会先过一遍检查清单调用是否经过代理方法是否public异常能不能传到代理层回滚类型配了没有事务里是不是还混着RPC调用最后一条尤其要命——不要在事务里调远程服务。一个事务方法里串一个库存服务RPC加锁时间就是RPC的RTT并发一高数据库连接池直接被打满。正确的做法是先把本地数据写好把后续要做的事记下来消息表、事件表事务提交后再异步去做。4. 分布式事务的底层困局CAP的取舍与一致性等级的划分聊完单体事务再往大了看。为什么分布式事务没有像单库事务那样一个方案打遍天下核心原因是CAP定理一个分布式系统在发生分区网络故障、节点宕机时只能在一致性和可用性之间选一头。4.1 用生活类比理解CAP把两个数据库节点想象成两个合伙记账的人。强一致性要求他俩任何时候账本都完全一样那每次记账都得先问对方你记好了吗两边都确认了才算完——可用性自然下降对方慢一点或者联系不上你就干等着。要可用性允许大家先记自己的、回头再对账那中间就有一段时间两边账本不一样一致性就打了折扣。单库事务之所以能既要又要,是因为它默认只有一个人记账单节点不存在两个人对不上账的分区问题。一旦拆到多个节点CAP是绕不开的天花板。4.2 一致性等级到底怎么分除了最终一致性一致性其实有一串梯度从强到弱大致是强一致性写完立刻所有节点都能读到任何时刻对外表现像单节点。会话一致性同一个会话同一个用户、同一个连接内能看到自己的更新别人不一定。单调读一致性一个进程读数据不会出现先读到新值、又读到旧值的时光倒流。最终一致性允许中间不一致但在没有新更新的前提下最终所有节点会收敛到同一份数据。做订单与库存这个场景最常犯的错就是所有环节都想要强一致。实际上一个下单流程里用户看到订单已提交到库存扣减真正完成中间隔个几百毫秒甚至几秒绝大多数业务完全能接受。真正需要强一致的只有钱相关的核心账目剩下的业务环节设计成最终一致即可。4.3 分布式事务的本质问题分布式事务难难在三个地方没有全局视角每个服务只有自己库里的数据谁成功谁失败全靠协调。网络不可靠请求可能丢了、重复了、超时了你没法区分对方没收到还是对方收到了但回包丢了。故障不可预测任何一步都可能宕机协调者自己也可能挂恢复以后状态从哪找理解了这三条你就明白为什么市面上有那么多分布式事务方案——没有银弹每个方案都是在一致性强度、性能、实现复杂度三者之间做取舍选型不是选最好的而是选我的业务能接受哪种不一致的代价。5. 订单与库存的分布式事务四种主流方案怎么选下单扣库存是分布式事务最经典的练兵场网上铺天盖地的文章都爱拿它举例。这里我不打算念概念直接把我做选型和落地时的真实考量讲一遍。5.1 方案一2PC/XA强一致但重两阶段提交协调者先问所有参与者能不能提交prepare大家都说能再广播正式提交commit。从原理上看XA很完美数据库层面原生支持对业务代码侵入也小。但落地项目里我几乎不用它做跨服务订单。原因很现实prepare阶段要持有数据库锁直到commit整个分布式事务的时长就是所有参与者锁时长的最大值高并发下单场景下等于给数据库上了个慢速闸门。协调者是单点它挂了所有参与者都在等它的指令事务卡到超时。微服务环境里很多参与者不一定是关系型数据库可能是Redis、MQ、第三方接口XA根本没法管。5.2 方案二TCC业务侵入大但可控TCC把每个操作拆成Try、Confirm、Cancel三步。拿扣库存举例Try冻结库存比如库存表加一个frozen_stock字段扣减前先把目标数量冻结起来。Confirm真正扣减把冻结的库存转成已扣减。Cancel解冻把冻结量还回去。TCC的优势是业务自己控制每个阶段能做到比2PC更细粒度的一致性资金类的强一致性场景经常用它。但代价是每个参与方都要写三个方法还要处理空回滚Try没执行成功就收到Cancel、悬挂Cancel先于Try到达、幂等Confirm和Cancel被重复调用这些问题。我们的库存服务当时评估TCC发现工作量几乎是普通接口的三倍而且要改库存表结构、加状态机团队讨论后放弃了——不是TCC不好是我们的场景没到那份上。5.3 方案三SAGA长事务用补偿SAGA把一个大事务拆成一串本地事务每个正向操作配一个反向补偿操作。比如下单成功后如果后续积分服务加积分失败就执行取消订单的补偿把前面成功的操作都“退回去”。相比TCCSAGA不需要每个操作先冻结资源实现上更贴近业务直觉。但它的坑在补偿逻辑——补偿操作必须能正确执行而且补偿操作本身也可能失败这时就得靠重试人工介入。SAGA适合业务链路特别长、跨了很多系统的场景比如旅游预订订机票订酒店租车。订单与库存这种两步链路用SAGA有点大炮打蚊子但如果你后面还连着支付、发票、积分、物流SAGA倒是可以统一管起来。5.4 方案四可靠消息最终一致性订单场景的实战首选这个方案我落地过也是给订单库存场景最推荐的方向。核心思路把扣库存变成一个可靠消息由库存服务异步消费。具体流程拆开看订单服务本地事务里干两件事插入订单记录状态为待支付同时往local_message表插入一条待发送的扣库存消息。这俩操作在一个事务里要么都成功要么都失败。事务提交后一个定时任务扫描local_message表把待发送的消息投递到MQ。库存服务消费消息执行扣减扣减成功就ACK失败就进入重试。如果重试多次还失败消息进入死信触发报警由对账任务兜底。有人会说这不就是本地消息表吗都2025年了还有人用其实你去看各种所谓的事务消息本质都是这个流程的封装RocketMQ的事务消息把本地消息表和发消息这两个动作合成了一步但对业务系统的要求还是一样的。5.5 选型对照没有最优解只有最合适的解方案一致性强度业务侵入性能开销实现难度适用场景2PC/XA强一致低高中小范围跨库同构极少用TCC强一致业务层高中高资金、核心账务SAGA最终一致中高中高长链路业务流程可靠消息最终一致最终一致中低中订单库存、异步解耦场景对照我们自己的场景下单扣库存真实诉求是别超卖、别漏扣、账号对得上。这里面超卖是绝对红线漏扣可以通过报警和对账发现。可靠消息方案把超卖问题转移到扣减服务自己必须保证不超卖上——这本来就是库存服务单库本地事务能做到的事。所以最终我们选择了可靠消息方案把分布式问题一步步简化成了局部问题。5.6 订单库存的落地细节再分享几个落地时容易忽略的细节一来幂等必须是第一公民。MQ消息在极端情况下会重复投递消费端一定要以业务唯一键比如订单号做唯一索引重复消费直接返回成功。我们当时在扣减流水表上建了(order_id, sku_id)的唯一索引重复消息插入时直接冲突代码捕获后当作成功处理。二来消息投递要带版本或状态。如果用户下单后又取消了订单库存那边可能同时收到扣库存和释放库存两条消息消费者必须能识别业务顺序不能先释放后扣减那就把库存搞成负数了。三来兜底对账必须做。最终一致方案的最终两个字就是靠对账任务下定义的。我们每天凌晨跑一个对账脚本拉取当天所有订单和库存扣减流水做LEFT JOIN把有订单没流水、有流水没订单的异常项全部捞出来人工审核处理。这套机制比任何事务方案都让人安心。6. 实战避坑清单把难念的经变成可念的经最后这部分我不想再堆概念了纯粹分享我这些年跟事务打交道积攒下来的几条硬经验每条都是踩过坑换来的。6.1 事务粒度越小越好一个事务方法里只放这个业务动作必须原子的那几条SQL。别把远程调用、消息发送、文件上传、循环里的多次查询全塞进去。事务时间和数据库连接占用直接相关你少写一条慢SQL数据库锁排队的人就少一批。特别提醒事务里千万别做Thread.sleep或者等待某个回调看起来没什么实际上一出事就是连接池超时的大事故。6.2 能不引入分布式事务就别引入这是我最重要的心得。很多团队一听说跨服务数据一致性第一反应就是上某个分布式事务框架。但我复盘过几个项目发现一半以上的场景都能通过业务设计绕开分布式事务——比如把库存预占放到订单服务本地事务里用冻结库存代替扣减库存或者把账户余额拆成本地字段流水表用状态机驱动异步更新。与其费劲协调多个服务强一致不如把业务边界重新画一画让大部分写操作落在同一个事务能管到的地方。6.3 幂等是分布式场景的底裤网络超时重试、MQ重复投递、用户重复点击提交分布式环境下什么重复都可能发生。你的接口、消费者、补偿任务只要不是纯查询一律要做幂等。最简单的做法就是业务唯一键唯一索引冲突就当成功。这套路比什么分布式锁都轻而且数据库唯一索引本身就是最可靠的分布式协调器。6.4 对账与补偿最后一道防线任何事务方案哪怕99.99%的情况下都正常运行那0.01%的边角料网络抖动、数据漂移、人为误操作还是要靠对账捞回来。别把对账想得多复杂就是每天/每小时跑一次SQL把核心数据的差异对比出来先告警再自动或人工修复。有对账兜底你晚上睡觉都踏实不少。6.5 排查事务问题的三个入口如果线上真的出了事务相关的数据不一致我的排查顺序是看异常事务方法有没有抛出异常、异常的栈到没到代理层、有没有被catch吞掉。看日志链路用traceId把一次请求经过的所有服务串起来看清每段本地事务的起止时间、提交还是回滚。看数据流水对业务主表做前后快照对比找出哪一步写了、哪一步没写再回溯到代码里是哪一行逻辑导致跨服务状态分裂。我个人最深的体会是事务的难念多半不是因为技术高深而是因为我们经常在错误的位置画了一根错误的事务边界线。回到标题那句话——家家有本难念的经念懂了的人会发现经书还是那本经书只是他学会了把每一段该由谁来念分清楚不再指望一个注解、一个框架包打天下。你先把这层想通工具怎么选都只是顺水推舟的事。