看到“谷粒商城”这四个字,很多人都不会陌生。这个项目在Java后端学习圈子里,几乎是微服务架构入门必刷的一个实战项目。而整个谷粒商城里面,含金量最高、面试被问得最多的模块,就是下单这条链路。原因很简单:下单不是简单地往数据库里插一条订单记录,而是牵一发动全身——要锁库存、扣积分、核销优惠券、生成支付单。如果在单体应用里,这些操作可以用一个数据库事务搞定,但放到微服务架构下,订单库、库存库、会员库被拆成了独立的数据源,本地事务彻底失效,这时候怎么保证数据不出乱子,就成了整个项目最核心的技术难点。
这篇博客就围绕谷粒商城的分布式事务与下单展开,把我自己在做这个项目时踩过的坑、捋清楚的原理,以及最终落地的一套方案,全部整理出来。不管你是刚开始学微服务的小白,还是准备面试冲刺的选手,这篇文章都能帮你在“订单与库存分布式事务”这个点上,建立起一套完整且能落地的心智模型。
1. 下单场景下的分布式事务:为什么一个小小点击会引发数据一致性危机
1.1 一次下单操作背后到底调用了多少个服务
谷粒商城是标准的Spring Cloud Alibaba微服务电商项目,拆分的服务非常多。以用户点击“提交订单”这个动作为例,后端真正参与的流程远比表面上复杂得多。
一个典型的谷粒商城下单流程是这样的:用户在购物车界面勾选商品,点击结算,前端会带着选中的商品列表、收货地址、支付方式等信息请求订单服务的创建订单接口。订单服务收到请求后,第一件事是查询商品服务确认商品是否还在售、价格是否变动,接着调用库存服务锁定商品的库存,同时调用会员服务扣减用户的积分或余额,还要调用优惠券服务将用户使用的优惠券标记为已核销。等这些步骤都执行完了,订单服务才在自己的订单库里生成订单记录和订单项,最后再发一条消息给支付服务,让用户可以继续完成支付。
看到没有,一次下单操作,至少要跨订单、库存、会员、优惠券、商品等好几个微服务。而且这些服务的数据存储是完全独立的,订单数据落在订单库,库存数据落在库存库,会员积分又在会员库。这就引出了分布式事务最本质的矛盾:每个服务都有自己独立的本地事务,但业务上又要求这些分散在不同库里的数据变更,要么全部成功,要么全部失败。
1.2 为什么本地事务管不住跨库的账
很多刚接触微服务的同学会有一个疑惑:我在订单服务的方法上加了 @Transactional 注解,为什么库存服务扣库存失败后,订单还能创建成功?原因很简单,@Transactional 的底层是Spring管理的数据源事务,而每个微服务的数据源是独立的。订单服务的 @Transactional 只能保证订单库里的那几条SQL在一个事务里执行,它管不了库存服务那边的事务提交或回滚。
换句话说,当订单服务调用库存服务的Feign接口时,库存服务自己开启了一个JDBC事务,这个事务和订单服务的事务是两回事。哪怕库存服务扣减失败抛了异常,这个异常传递给订单服务之后,订单服务回滚的也只有自己数据库里的事务,库存服务那边如果已经提交了,数据就变不回来了。
我在最初做谷粒商城的时候,就实实在在踩过这个坑。测试的时候先创建订单再锁库存,后来改了逻辑先锁库存再创建订单,想着这样能安全一点,结果发现锁库存成功之后、订单创建报错,库存依然被扣掉了。用户那边显示下单失败,但仓库里少了一件商品,后台库存数据就和实际对不上了。那一刻我才真正理解,分布式事务不是一个技术名词,它是一个随时可能把线上数据搞崩的现实问题。
1.3 分布式事务要解决的一致性问题到底有哪些
分布式环境下,数据不一致主要出现在几个环节。第一种是服务之间调用链路过长,中间某个服务挂了,导致后续服务没有被调用,前面的操作却已经提交。第二种是服务之间调用成功,但返回结果因为网络超时没有送达,调用方以为失败,实际上数据已经变了。第三种是多个服务各自提交了本地事务,但其中某一个服务在最后阶段回滚失败,造成整体数据不完整。
这三种问题在谷粒商城下单场景里都能找到对应的位置。锁库存接口等待超时,但库存服务实际扣减成功了;订单创建成功,但锁库存失败,需要把订单数据撤销;优惠券核销了,但订单最终没支付,优惠券状态错乱。这些情况如果不处理,用户端看起来可能只是一个模糊的“下单失败”,但后台的数据逻辑已经完全乱了套。所以,下单场景必须引入分布式事务来保证跨服务的数据最终一致性,甚至在某些节点要做到强一致性。
2. 分布式事务主流方案横向对比:为什么谷粒商城最终选择了Seata
2.1 2PC和TCC方案的特点与局限
分布式事务不是一个新问题,行业里早有各种解决方案。先说说最经典的2PC(两阶段提交协议),也就是XA事务。2PC的核心思想是引入一个事务协调者,让所有参与者在第一阶段先做预处理并锁住资源,第二阶段再由协调者统一决定提交还是回滚。这个方案的优点是强一致性有保障,但缺点非常致命:第一阶段要锁定资源,整个事务执行期间数据库的吞吐量会大幅下降,一旦某个参与者挂了,协调者要一直等它,整个链路都被阻塞住。互联网电商这种高并发场景,根本承受不住这种性能损耗。
TCC(Try-Confirm-Cancel)方案则是另一种思路,它把每个分布式操作拆分成三个阶段:Try阶段检查并预留资源,Confirm阶段真正执行业务,Cancel阶段回滚释放资源。TCC的优点是不用锁数据库资源,性能比2PC好很多,但缺点也突出:对业务代码侵入性强,每个操作都要手写Try、Confirm、Cancel三段逻辑,而且很多业务场景根本不容易拆成这三个阶段。比如锁库存操作,Try阶段锁定库存,Confirm阶段扣减库存,Cancel阶段回滚库存,听上去简单,但遇到库存表结构复杂、还要记录操作流水的时候,代码量直接翻倍。
2.2 本地消息表和MQ事务消息适合什么场景
再来看另外一类方案,本地消息表和MQ事务消息。这两类方案的核心思路都是牺牲实时一致性,换取最终一致性,很适合异步化的业务场景。
本地消息表的大致做法是,在业务操作所在的数据库里建一张本地消息表,把要发给其他服务的消息和业务操作放在同一个本地事务里,保证业务操作和消息记录要么一起成功,要么一起失败。然后再用一个定时任务轮询消息表,把状态为待发送的消息发给MQ或者直接调用下游接口。这个方案的好处是实现简单、不依赖额外组件,坏处是要自己维护消息表的状态轮询,而且消息的实时性会差一些。
MQ事务消息则把消息发送拆成了两个阶段。以RocketMQ为例,发送方先把半消息发送到MQ,然后执行本地事务,如果本地事务执行成功,再提交半消息,让消费者可以消费。如果本地事务执行失败,就回滚半消息,消费者不会收到消息。这个方案是目前比较推荐的异步解耦方案,但问题在于它解决的问题只是“消息可靠投递”,下游消费之后如果处理失败了,还得额外配合重试和补偿机制。
2.3 Seata AT模式凭什么成为最合适的落地方案
对比了一圈,最后回到谷粒商城这个场景来看。下单链路里,订单服务和库存服务之间的数据一致性,是要相对实时、相对强一致的。用户下单瞬间,库存不能多扣也不能少扣,订单和库存的绑定关系必须准确。如果这中间走异步消息最终一致性,理论上可行,但会带来一个问题:用户下单成功后,如果库存回滚最终失败了,订单已经展示给用户了,处理起来非常麻烦。
Seata AT模式最核心的优势在于,它在不侵入业务代码的前提下,实现了基本接近于强一致的分布式事务效果。开发者只需要在业务方法上加上一个 @GlobalTransactional 注解,Seata框架就会自动接管事务提交和回滚的协调工作,典型的低侵入、易落地。谷粒商城这种以学习重业务逻辑为主的项目,选Seata AT模式既能快速跑通整个下单流程,又能帮助理解分布式事务的底层运行机制。
为了让大家更直观地做选型,我把几种方案放在一个表格里对比:
| 方案 | 一致性级别 | 业务侵入性 | 性能影响 | 典型应用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 高(数据源层) | 大(资源锁定) | 传统金融、跨库强一致的内部系统 |
| TCC | 最终一致/准实时一致 | 高(业务拆三个阶段) | 小 | 高并发且业务能清晰拆分场景 |
| 本地消息表 | 最终一致 | 中(需建表维护状态) | 小 | 异步通知、任务补偿类场景 |
| MQ事务消息 | 最终一致 | 中(配合消息重试) | 小 | 异步解耦、削峰填谷场景 |
| Seata AT | 准强一致 | 极低(注解式) | 中(有全局锁开销) | 中小规模微服务业务强一致场景 |
3. Seata AT模式底层原理精讲:三个角色、两阶段设计、undo_log回滚
3.1 全局事务的三个角色:TC、TM、RM分别干什么
要真正理解Seata AT模式,得先从它的全局事务模型说起。Seata框架定义了三个核心角色:事务协调者(TC,Transaction Coordinator)、事务管理器(TM,Transaction Manager)和资源管理器(RM,Resource Manager)。
TC是独立运行的Seata Server,负责全局事务的注册、分支事务的状态记录、全局锁的协调,以及最终下发全局提交或全局回滚的指令。TM在代码层面,就是发起全局事务的那个入口方法,它负责向TC申请开启一个全局事务,拿到一个全局事务ID(也就是XID),然后在所有分支事务执行完毕之后,再向TC发起全局提交或全局回滚的请求。RM则是每个参与分布式事务的微服务里的资源管理器,它负责把本地事务注册成全局事务的一个分支事务,并配合TC执行分支的提交和回滚。
三个角色协作起来,就是这样一个流程:TM开启全局事务拿到XID,XID通过微服务调用链传递到下游,下游的RM执行本地SQL时,把本地事务注册成分支事务,并上报给TC。所有分支都执行完,TM发起提交或回滚指令,TC统一协调。
3.2 一阶段提交和二阶段提交到底做了什么
Seata AT模式的设计思想,可以概括成“一阶段提交本地事务+二阶段决定全局结果”。
在一阶段,RM拦截到业务SQL之后,并不会直接让这条SQL交给数据库执行了事,而是会做额外的三件事。第一件事是解析SQL语句,生成这条SQL对应的前后镜像数据,也就是查询出SQL执行前那一行的原始数据(beforeImage),然后执行SQL,再查询出执行后那一行的数据(afterImage)。第二件事是把业务SQL放在本地事务里执行,同时把这组前后镜像数据写入一张undo_log表,业务数据和undo_log在同一个本地事务里一起提交。第三件事是向TC注册分支事务,并上报本分支事务涉及的数据行的全局锁信息。
到了二阶段,TC会根据所有分支事务的执行情况,决定是全局提交还是全局回滚。如果所有分支都成功了,TC通知各个RM做全局提交。RM收到提交指令后,只需要异步删除之前写入的undo_log记录即可,因为业务数据在一阶段已经实际提交到数据库里了。这个过程很快,不需要再做其他数据操作。
如果任何一个分支失败,TC就会通知所有RM做全局回滚。RM收到回滚指令后,会读取undo_log中的前后镜像数据,然后根据这些数据生成反向SQL。举个例子,如果一阶段执行的是一条update语句,把库存从100改成了90,那么回滚时Seata会生成一条update语句,把库存从90改回100。这个反向SQL执行完之后,再删除undo_log记录,分支事务的回滚就算完成了。
3.3 全局锁和undo_log是怎么配合防脏写的
这里有一个非常关键的设计细节:Seata在二阶段回滚的时候,并不是无条件执行反向SQL的,它要先做“脏写校验”。具体来说,RM会取出undo_log里的afterImage,与当前数据库里这一行的实际数据做比较。如果两者一致,说明在一阶段提交之后,这行数据没有被其他事务修改过,可以安全地执行反向SQL回滚。如果不一致,说明这行数据已经被其他事务改动过了,如果强制回滚,就会把别人新写入的数据也一并覆盖掉,这时候Seata会抛出异常,转而走人工介入补偿的流程。
全局锁就是用来减少这种脏写冲突概率的。Seata的全局锁由TC统一管理,当一个全局事务的分支RM要更新某行数据时,会先向TC申请这行数据的全局锁。如果这行数据的全局锁已经被其他全局事务占用了,当前事务就需要等待。这样设计的好处是,避免两个分布式事务同时对同一行数据做修改,导致回滚时无法判断数据归属。
我实际做项目时发现,理解undo_log为主、全局锁为辅这个关系很重要。全局锁降低的是多个全局事务之间的冲突概率,但并不能完全避免脏写,尤其是在并发比较高的电商场景下。所以后期如果要优化下单性能,全局锁往往会成为瓶颈,这一点放到后面第五节详细聊。
4. 谷粒商城下单模块的分布式事务落地实操
4.1 Seata Server环境的完整搭建步骤
说了这么多原理,终归要落到代码上。我做的谷粒商城项目里,Seata用的版本是1.4.0,配合Spring Cloud Alibaba 2.2.x版本使用,这两个版本序列的兼容性比较成熟,资料也多,踩坑了容易搜到解决方案。
第一步是启动Seata Server。从Seata官方GitHub把对应版本的server压缩包下载下来,解压之后重点修改两个配置文件。registry.conf里要把注册中心类型配置成nacos,并填上Nacos服务器的地址。file.conf里要修改store模式,我是直接用的默认的file模式做的单机学习,如果做生产配置,建议改成db模式,把事务会话信息存到数据库里,避免Seata Server重启之后事务状态丢失。
第二步是初始化数据库。每个参与分布式事务的业务数据库里,都需要新建一张undo_log表。这张表的SQL脚本Seata官方文档里有提供,核心字段包括branch_id、xid、context、rollback_info、log_status、log_created、log_modified。其中rollback_info字段存放的就是前后镜像数据的JSON序列化结果。这一步经常有人漏掉,因为业务代码能正常启动,Seata也没有报错,等到真正发生回滚的时候,才发现undo_log表不存在,导致回滚直接失败。我在第一次集成的时候就是只给订单库建了表,库存库忘了建,测回滚测到怀疑人生。
第三步是启动Nacos,Seata Server注册进去之后,在Nacos的服务列表里能看到seata-server这个服务,说明注册成功。
4.2 订单服务和库存服务引入Seata的方式
Seata Server启动好之后,接下来就要改造业务服务了。核心工作可以分成三块:引入依赖、配置数据源代理、添加全局事务注解。
先看依赖。订单服务和库存服务的pom.xml文件里,都需要引入spring-cloud-alibaba-seata这个starter依赖,同时引入seata-spring-boot-starter。这里要注意版本对齐,Spring Cloud Alibaba的版本管理里其实已经包含了seata的依赖管理,直接用不带版本号的引入方式更稳妥,避免手动指定版本导致冲突。
然后是数据源配置,这是集成Seata时最容易踩的坑。Seata AT模式之所以能在业务SQL执行前后自动生成镜像数据,核心原理是在数据源层面做了一层代理,它会拦截所有的JDBC连接,在真正的SQL执行前后插入额外的查询逻辑。因此,我们需要把原来的DataSource包装成Seata的DataSourceProxy。
具体的做法是:在启动类上排除DataSourceAutoConfiguration,不让Spring Boot自动创建数据源,然后自己写一个配置类,手动创建一个DataSourceProxy类型的Bean。我在做的时候用的Druid连接池,所以是在DruidDataSource创建完之后,用new DataSourceProxy(druidDataSource)包装一层。如果这一步没有做对,Seata的全局事务拦截器就感知不到数据源,即使方法上加了@GlobalTransactional,也只会把本地事务提交掉,根本不会注册分支事务。
对于使用MyBatis-Plus的项目,还有一个小的注意事项:MyBatis-Plus的分页插件等配置,要确保拿到的是Seata包装后的数据源,否则分页查询和Seata的SQL拦截可能会冲突。我在实际项目中遇到过MyBatis-Plus的分页失效问题,排查了半天,最终发现是因为数据源代理顺序不对导致的。
4.3 在核心下单方法上开启全局事务
环境都准备好了,最后一步就是在业务代码上添加全局事务注解。
在我做的谷粒商城下单模块里,OrderServiceImpl里的submitOrder方法是整个下单链路的入口。这个方法内部逻辑大致是这样的:
@Override @GlobalTransactional(rollbackFor = Exception.class) public SubmitOrderResponseVo submitOrder(OrderSubmitVo orderSubmitVo) { // 1. 校验用户的收货地址、支付方式、商品信息 // 2. 调用库存服务锁定库存 // 3. 调用会员服务扣减积分 // 4. 调用优惠券服务核销优惠券 // 5. 在订单库创建订单记录和订单项 // 6. 返回下单结果 }@GlobalTransactional 注解是这个方法能参与分布式事务的关键,它相当于告诉Seata的TM:这个方法是一个全局事务的入口,请给我生成一个XID,并让通过Feign调用的下游服务都把各自的分支事务归属到这个XID下面。
需要注意的是,Feign调用时要保证XID能在服务之间传递。这一点Spring Cloud Alibaba已经帮我们封装好了,引入依赖后它会自动配置Seata的RequestInterceptor,在Feign请求的Header里自动携带XID。所以一般来说只需要专注业务逻辑即可。但如果自己手动拼接HTTP请求去调其他服务,就必须手动把XID放到Header里传递,否则下游服务那边的RM就不知道当前请求属于哪个全局事务,分支事务就不会注册。
4.4 事务边界的设计:哪些操作该放进全局事务,哪些不该放
事务边界的设计,直接决定了这个分布式事务方案的成败。很多同学在做项目时容易犯一个错误,就是把所有业务操作一股脑都塞进 @GlobalTransactional 方法里,结果导致全局事务的执行时间非常长,数据库连接被长时间占用,全局锁竞争加剧,系统并发能力直线下降。
在谷粒商城中,我最终划定的全局事务范围是这样的:锁定库存、扣减积分、核销优惠券、创建订单记录,这四步操作是必须放进同一个全局事务里的。因为它们的共同特点是对数据一致性的要求非常高,必须在同一时刻全部成功或者全部失败。
但是,像发送短信通知、记录操作日志、清理购物车记录这类操作,就没有必要放进全局事务里了。这些操作对数据一致性的要求没那么严格,延迟几秒甚至几分钟都能接受,完全可以通过MQ异步消费来实现。比如清理购物车,完全可以等订单创建成功之后,发一条消息到RabbitMQ,消费者再异步把用户勾选的购物车条目删除。如果购物车清理失败,最多就是用户下次看到购物车里还有这个商品,不影响订单和库存的正确性。
还有一步非常关键,就是下单成功之后跳转支付页面的动作,绝对不能放在全局事务里。因为支付环节本质上是和第三方支付系统交互,这个调用的响应时间完全不可控,如果放进全局事务里,会让Seata全局锁持有时间无限拉长,严重时会把整个下单接口拖垮。正确的做法是,全局事务只负责生成本地订单并把订单状态置为待支付,事务提交之后,再让前端带着订单号去请求支付服务发起支付。
4.5 集成后的完整时序流程走读
把整个集成做完之后,我习惯用一条业务链路去验证代码是否真正生效。模拟一次正常的下单请求,整套流程走下来是这样的:
用户点击提交订单,请求打到订单服务的submitOrder方法,@GlobalTransactional 生效,TM向TC发起全局事务注册,拿到一个XID。订单服务接着调用库存服务的Feign接口,Feign拦截器自动把XID放进请求Header,库存服务收到请求后,RM发现Header里带着XID,就先把库存扣减这个本地事务注册到一个分支事务,然后向TC申请库存行数据的全局锁,拿到锁之后,在本地事务里执行UPDATE库存SQL和写入undo_log,一起提交。订单服务继续调用会员服务扣积分,会员服务一样的套路,注册分支事务,执行本地更新,提交。三四个服务依次走完,最后订单服务在自己库里插入订单记录,插入完成后,TM向TC发起全局提交请求。TC收到之后,对比所有分支事务的状态都是成功,于是通知各个RM执行二阶段提交,RM们收到指令后,各自删除自己的undo_log记录。整个分布式事务到此干净利落地结束。
如果中间任何一个环节抛了异常,比如库存服务扣库存时报库存不足,订单服务的全局事务拦截器就会感知到异常,TM向TC发起全局回滚请求。TC通知各RM回滚,已被调用的服务会根据undo_log里的镜像数据生成反向SQL,把库存、积分、优惠券状态都恢复到一阶段之前的样子,订单库那边如果已经插入了订单数据,也会被删除掉。用户那边看到的就是一个统一的下单失败提示,后台数据却纹丝不乱。
5. 谷粒商城下单链路常见问题与排查实录
5.1 全局事务注解明明加了,事务却完全没有生效
这是很多人第一次在谷粒商城集成Seata时最容易遇到的坑。代码里确实加了 @GlobalTransactional 注解,方法执行也正常,Feign调用也成功,但去看Seata Server的日志,压根就没有任何全局事务创建的记录。
我排查这个问题时的经验是,第一步先确认引入的依赖版本是否对应。Spring Cloud Alibaba 2.2.x的版本管理里,seata-spring-boot-starter的版本是固定的,如果你手贱在pom里额外指定了一个其他版本的seata,很容易出现starter的自动配置类找不到的情况。第二步,检查启动类上是否把自己定义的数据源排除掉了,如果没排除,Seata的DataSourceProxy可能不会被正确初始化,全局事务拦截器和数据源代理之间就会对不上。第三步,检查 @GlobalTransactional 是否加在了通过外部调用进入的方法上,在同一个类的内部方法调用,Spring AOP代理默认不生效,这个是最容易忽略的。比如有的同学会把下单逻辑抽到一个内部方法上,在controller里直接调this.submitOrder(),注解是不起作用的,必须从外部代理对象调用才行。
5.2 库存扣减成功,但订单创建失败后库存没有回滚
这个问题比上一个要隐蔽一些。从现象上看,整个调用链确实报了异常,下单失败,但库存还是被扣了。最直接的原因是这个异常发生在库存服务的本地事务已经提交之后,而且是超时或者异常丢失导致的。
排查的时候,先看Seata Server的控制台,确认全局事务是否正常创建并且进入了回滚流程。如果控制台显示回滚成功,但库存数据没变,那就要去库存库检查undo_log表里有没有对应的回滚记录。如果undo_log表里没有数据,说明库存服务的本地事务在一阶段就提交掉了,但分支事务根本没注册成功。这种情况大概率是XID传递失败,库存服务流量入口那个Feign调用的Header里没有XID。可以在库存服务的锁库存方法里打日志,输出RootContext.getXID(),如果是null,那就说明请求进来时XID就已经丢了,去检查服务间的Feign是否有自定义拦截器覆盖了Seata默认的拦截器。
还有一种情况,是回滚的SQL执行了,但反向SQL没有正确匹配到数据。比如库存表里除了库存数量之外,还有一个version字段或者modified_time字段,反向SQL是根据beforeImage和afterImage的差异动态生成的,如果beforeImage和afterImage的比对逻辑因为字段类型转换问题出了问题,就可能更新不到正确的行。这个需要看回滚日志,一般来说会根据报错信息定位到具体字段。
5.3 高并发下出现大量的全局锁等待超时
当我做完基础的分布式事务集成,开始做并发压力测试的时候,一个非常头疼的问题暴露出来:一旦同时有多个用户抢购同一个商品,就会出现大量的全局锁等待超时异常。后台日志里最常见的报错是GlobalLockWaitTimeoutException。
原因在于,Seata AT模式的全局锁是粒度比较粗的行锁,而且锁的持有时间和整个全局事务的生命周期绑定。如果两个用户同时下单都锁同一个商品的库存,第二个用户的全局事务就必须等第一个用户全局提交后才能拿到锁。如果全局事务里业务逻辑执行时间又长,比如调用了响应很慢的第三方服务,那等待超时的概率就会进一步放大。
对谷粒商城这类电商项目,我的实际优化思路是这样的:首先严格控制全局事务内的远程调用,确保全局事务里只有必要的数据操作。其次,对热点库存的商品,可以把库存预扣操作从Seata全局事务里剥离出来,改用Redis预扣库存的方案。具体来说,下单前先从Redis里扣减库存,扣减成功才继续后面的订单和数据库库存的同步操作,库存数据库只做异步持久化。最后,如果确实需要依赖Seata,可以通过调大全局锁超时时间来缓解,但这不是治本之策。
5.4 回滚失败:Branch transaction rollback failed, undo_log中镜像数据不一致
这个问题我处理过很多次,基本上可以定位为脏写校验没过。前面讲过Seata回滚前会对比当前数据和afterImage,如果发现当前数据被别的线程改过了,就会抛出这个异常。
我遇到过的一个典型场景是,订单服务在下单过程中调用库存服务锁库存,锁库存成功之后,订单服务又发了一条MQ消息,消费者异步去更新库存表的一个冗余字段。结果这个异步更新操作和Seata的回滚操作撞到了一起,把afterImage对应的行数据改掉了,导致Seata回滚时发现镜像不一致。
解决这个问题的思路有两个方向。一个方向是从业务上规避,比如所有对库存表的写操作,只要可能和下单链路产生竞争,都要加入全局事务的考虑范围,不要把同一行的更新操作散落在不同的事务模型里。另一个方向是,确认这个隔离级别的取舍,如果某些字段确实可以被异步修改,那就不要放在undo_log捕获的更新语句里,或者改用其他方式做数据补偿。实战中我最后选择了把库存异步更新改成同步放在全局事务内执行,彻底避免了这类脏写冲突。
5.5 常见问题速查表
| 现象 | 直接原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| @GlobalTransactional不生效 | 版本不匹配/代理失效/同类内部调用 | 查Seata Server日志、检查pom依赖、确认Spring代理方式 | 对齐依赖版本、排除自建数据源类、从外部代理调用 |
| 库存不回滚 | 分支事务未注册/XID未传递 | 打印RootContext.getXID() | 检查Feign自定义拦截器、确认XID传递的Header |
| 全局锁等待超时 | 全局事务持有锁时间过长 | 看超时日志定位全局事务ID | 缩短事务边界、Redis预扣库存、必要时调大超时时间 |
| 分支回滚失败 | 脏写校验未通过 | 对比当前数据与afterImage | 业务上规避同行的多事务模型并发写 |
| 找不到undo_log表 | 初始化遗漏 | 确认每个业务库都有该表 | 补建undo_log表并核对字段 |
6. 下单场景还能怎么优化:写在最后的几句实操心得
谷粒商城完整做下来,我觉得最有价值的不仅仅是跑通了Seata这套分布式事务方案,而是让我形成了一个认知:分布式事务不能只盯着一套方案,不同的业务阶段应该有不同的处理策略。
在我最终维护的下单版本里,短链路上强一致的数据操作走Seata,长链路、耗时长、不需要实时一致的操作全部走RabbitMQ加定时任务补偿。订单三十分钟未支付自动关单这个场景,Seata就帮不上什么忙了,它就是标准的延迟消息加最终一致性:下单成功发一条延迟消息出去,三十分钟后消息被消费,消费者去查订单状态,发现还是待支付,就调用释放库存接口,把库存补回去。如果释放库存失败,消息队列的重试机制会自动再投递几次,加上定时任务兜底,保证库存最终会释放。
这里面的关键心得就是四个字:缩小边界。能让数据在一个服务里保持一致,就不要拆出去做分布式事务;能用普通消息队列解决的,就不要引入全局锁;必须用分布式事务的,也尽量让每个分支事务短小精悍。很多同学做谷粒商城的时候,喜欢把所有功能都拆成微服务,结果本来一个本地事务能搞定的事,硬生生变成了分布式事务,最后被各种一致性问题折磨得焦头烂额。这个项目的含金量,不在于你用了多少新技术,而在于你通过它理解了什么场景该用、什么场景不该用。
最后再分享一个小细节:做Seata集成测试的时候,不要只在正常流程里验证,一定要手动模拟各种异常,比如让库存服务抛异常、让Feign调用超时、让下游服务宕机,然后观察Seata的回滚日志,亲眼看一次数据恢复的过程。我对Seata的理解,就是在一次一次制造故障又看着数据自动恢复的过程中,慢慢建立起来的。这套流程走通了,面试时再被问到分布式事务,你就不会再怕了。