
老读者都知道学微服务绕不开谷粒商城这个项目而谷粒商城整个体系中含金量最高、面试被问得最频繁的一块就是“分布式事务”。尤其是下单这个场景订单、库存、会员积分、支付回调分散在好几个微服务里任何一个环节失败都可能造成超卖、少扣库存、订单状态错乱这些线上事故。今天我就把谷粒商城下单链路里的分布式事务完整拆一遍聊清楚为什么要上SeataAT模式底层是怎么运作的以及我在实际落地时踩过的坑和排查思路。这篇文章适合正在学谷粒商城、准备微服务项目面试或者工作中要接分布式事务需求的同学看完能直接套用到自己的项目里。很多初学者第一次接触分布式事务往往被各种名词绕晕两阶段提交、三阶段提交、TCC、本地消息表、Seata AT、XA……其实不用慌脱离了业务场景去背概念学完就忘。最有效的学习路径是先搞清楚一个真实的下单场景到底难在哪里再去看这些方案分别解决了什么问题最后回到代码里亲手调试一遍。谷粒商城恰恰给了我们这样一个完整、真实、可跑的电商业务闭环拿它当载体去理解分布式事务事半功倍。1. 谷粒商城下单流程里分布式事务到底卡在哪一步1.1 一次下单请求背后要跨多少服务先看下单这个动作在整个谷粒商城里涉及的服务。用户在购物车点击“提交订单”前端请求打到订单服务但订单服务不能自己完成所有事情它需要同步调用库存服务锁定库存需要调用会员服务累加积分确认订单信息时还可能查商品服务的价格、优惠券服务的满减。传统的单一数据库事务能保证ACID是因为所有操作都在同一个数据库连接里要么全部提交要么全部回滚。但微服务架构下订单库、库存库、会员库是三个独立的数据库甚至可能部署在三台不同的机器上。订单服务开启本地事务插订单表调用库存服务的Feign接口扣减库存这是一个独立的数据库事务两个库之间没有任何关联。这时候如果库存扣减成功、订单入库失败订单服务回滚了自己的数据库存服务已经提交的事务却回不去了用户的库存被白白扣掉这就是典型的分布式事务问题。谷粒商城的课程里下单链路最经典的就是“订单服务调用库存服务锁定库存”这个环节。代码上看起来只是一个FeignClient接口调用但背后牵涉到两个不同的数据源和两个独立的事务边界。实际生产环境里这条链路还会更长——下单要写订单主表、订单项表、支付流水表要调库存做个锁定量要调会员服务发积分可能还要写一条消息到MQ触发后续的物流、发票异步流程。我见过不少刚学完Spring Cloud的开发者在这里容易产生一个错误理解认为OpenFeign调用失败了订单服务catch住异常抛出两边数据就都回滚了。实际上库存服务的方法如果自己提交了事务订单服务这边再怎么抛异常也管不到库存服务里已经提交的SQL。要真正做到“要么都成功要么都失败”必须引入跨服务的全局事务协调机制。1.2 本地事务在跨库场景下的局限性为了把这个问题看得更清楚我们本地验证一下。假设订单服务里有两个数据源订单库和库存库写在同一个业务方法里用Spring的Transactional只能指定一个事务管理器默认情况下管的是主数据源。哪怕你配置了多个DataSourceTransactionManagerSpring的事务抽象也是绑定在单一线程、单数据源上的做不到跨库提交和回滚。数据库自己的分布式方案也有比如MySQL的XA事务它是数据库原生支持的两阶段提交。但XA在互联网公司用得很少原因很现实它的二阶段提交过程中数据库资源需要被全局锁住事务时间一长数据库的并发能力直线下降而且实现XA协议对数据库版本、连接池、中间件都有要求出了问题很难排查。所以业界的解决思路从不用单库手段去硬扛跨库问题而是往上层走要么用消息做最终一致性要么引入独立的分布式事务中间件。谷粒商城选择的是Seata这是目前Java生态里最主流、对业务侵入最小的方案之一。理解Seata不用急着看源码先记住它的核心目标——让多个服务的本地事务在一个全局事务的协调下达到“看起来像同一个事务”的效果。2. 为什么谷粒商城选Seata而不是TCC或本地消息表2.1 常见分布式事务方案横向对比在动手写Seata之前值得花点时间对比一下主流的分布式事务方案这样你面试被问到“为什么选Seata”时才能讲出层次感。第一种是前面提到的XA两阶段提交属于数据库层面的强一致性方案优点是数据一致性最强缺点是阻塞资源、性能差、实现和后端存储强绑定业务接入成本高。第二种是TCCTry、Confirm、Cancel三段式需要业务方自己把每个操作拆成预留、确认、撤销三个接口代码侵入非常大适合银行转账这类对一致性要求极高、且愿意付出高昂开发成本的场景。第三种是本地消息表定时任务或MQ事务消息优点是性能好、最终一致缺点是实时性不够而且业务逻辑要改成“先写本地消息表再异步消费”对代码结构影响很大。再看Seata的AT模式它的核心优势是“业务代码无侵入”。你在订单服务的方法上标注GlobalTransactional方法内部仍然写本地SQL、仍然用本地事务Seata在底层帮你拦截SQL、生成回滚日志、协调各个分支事务。这种使用体验非常接近“在一个数据库里写一个普通事务”学习成本极低正好符合谷粒商城这种教学型电商项目的定位。2.2 AT模式在算法层面的取舍很多人把Seata AT模式理解成“升级版2PC”严格来说不太准确。传统2PC在一阶段会锁定资源直到全局事务结束性能差。Seata AT模式的巧妙之处在于一阶段就把本地事务提交掉业务数据立刻释放对数据库资源的占用时间极短二阶段如果全局事务成功只需要异步清理刚才记录的undo_log只有需要全局回滚时才根据undo_log反向补偿。这一点大大提升了高并发场景下的吞吐量代价是牺牲了隔离性。AT模式默认的隔离级别是读未提交因为它一阶段提交后其他事务立刻就能读到这个未提交的全局事务修改过的数据。虽然写到数据库的数据一定会在二阶段成功时保留但在二阶段完成前这些数据对旁路读取者来说是“不稳定的”。不过实际电商场景里订单、库存这类数据很少存在必须等待全局事务完全结束才能读的强隔离需求所以这个取舍可以接受。谷粒商城选择Seata AT模式也有教学层面上的考虑。课程要让学生理解分布式事务的核心思想而AT模式把最复杂的分布式一致性算法封装在中间件内部学生可以先用起来再看原理循序渐进。TCC那种模式学生光理解Try、Confirm、Cancel的分工就要费不少劲容易把注意力从“分布式事务本身”转移到“如何写TCC代码”上去。2.3 Seata也能兼容高一致性业务补充一个容易忽略的细节Seata不是只有AT模式它还提供TCC模式、Saga模式和XA模式。也就是说如果某个业务场景确实需要更高的一致性保障或者业务方已经有一套TCC接口实现可以在同一个Seata框架里切换模式不必换中间件。这就让Seata成为很多团队落地分布式事务时的首选。我在实际项目中见过这样的架构核心交易链路用Seata AT模式因为并发量高且业务模型适合资金类操作单独用TCC模式因为每笔金额都不能错。两种模式跑在同一个Seata Server上全局事务ID的生成、分支事务的注册、全局锁的管理逻辑都是统一的。这也是Seata的生命力所在——它不是一个只能解决一种问题的玩具而是一个可扩展的分布式事务基础设施。3. Seata AT模式下一个全局事务是如何“骗”过所有服务的3.1 一阶段提交先干活留凭证我们还是拿下单锁库存来举例走一遍AT模式的完整流程。用户点击提交订单订单服务的下单接口被GlobalTransactional标注Seata的全局事务管理器会向Seata ServerTC事务协调器申请一个全局事务IDXID。XID生成后会被绑定到当前线程的上下文里并通过Dubbo或Feign的拦截器自动传递到下游服务。库存服务接收到XID后本地事务管理器会向TC注册一个分支事务把XID和本地事务关联起来。接下来订单服务执行本地SQL。比如往订单表里插了一条记录Seata的RM资源管理器会通过数据源代理解析这条SQL的执行前后快照。它先查一次当前记录的数据得到前镜像再等SQL执行完毕再查一次得到后镜像。前后镜像和SQL本身会被组织成一条undo_log记录和业务数据在同一个本地事务里一起提交到订单库。这样做的用意很清晰如果全局事务需要回滚就用这条undo_log里记录的前后镜像来逆操作把数据恢复原样。库存服务那边做的事情一模一样。扣减库存的SQL执行前记录前镜像扣完记录后镜像写入库存库的undo_log表然后本地事务提交。到这一步从数据库视角来看订单数据和库存数据都已经实实在在发生了变化但他们各自的本地事务都已经结束了连接释放数据库的锁也释放了不会阻塞其他事务。3.2 二阶段提交成功清理失败回滚所有分支事务都注册完毕后TC会汇总分支事务的结果。如果订单服务和库存服务都返回“本地事务提交成功”TC就通知所有参与者进入二阶段提交也就是全局提交。在AT模式里这个阶段的核心动作特别轻——各服务的RM只需要异步删掉刚才那批undo_log即可业务数据不需要做任何修改因为一阶段已经提交了。如果任何一个分支事务失败比如库存服务扣减库存时发现库存不足抛了异常库存服务的本地事务会回滚它不会提交undo_log而是向TC上报“分支事务失败”。TC收到失败信息后会通知其他已经成功的分支参与者执行全局回滚。订单服务这边RM读到自己的undo_log根据前镜像和后镜像生成反向SQL如果原操作是INSERT就生成DELETE如果原操作是UPDATE就把数据更新回前镜像的值。执行完反向SQL后同一事务里删掉对应的undo_log。整个过程对业务代码是完全透明的。你在订单服务里写一个普通方法加上GlobalTransactional方法里调Feign接口catch住所有异常抛出去Seata就会自动帮你完成“要么全成功要么全回滚”的协调。从开发体验上看Seata AT模式真的很像“把本地事务搬到了分布式环境里”这也是它在教学项目里备受欢迎的核心原因。3.3 全局锁机制如何防止“脏写”AT模式有一个老生常谈的问题一阶段就提交了本地事务万一两个全局事务同时修改同一条记录怎么办比如一个全局事务正在扣库存数据已经提交了但整个全局事务还没结束另一个全局事务这时候也来扣这条库存记录它读到的库存数量是第一个事务扣减后的值如果第二个事务随后回滚它根据undo_log恢复的数据就可能是错的。Seata为这个问题设计了全局锁。RM在执行业务SQL之前需要先拿到这条记录上的全局锁拿不到就自旋等待。全局锁是Seata Server专门维护的一个分布式锁和数据库的行锁是两套独立机制。拿到了全局锁才能去执行本地事务里的更新SQL如果等其他分支都成功了TC会根据全局锁的重入计数在二阶段释放全局锁。这套设计和数据库行锁结合能有效防止AT模式下最常见的“悬挂”和“脏写”问题。不过全局锁也带来一个副作用如果全局事务执行时间过长后面等待全局锁的事务会排队导致吞吐量下降。这也是为什么说Seata AT模式在高冲突场景下需要谨慎评估的原因。实际下单场景中库存行是热点数据如果单靠Seata的全局锁硬扛秒杀这类极端流量会很难看。遇到这种情况可以结合Redis预扣库存、MQ异步落库等方式削峰这也是谷粒商城课程里涉及商品秒杀时需要额外思考的点。4. 谷粒商城下单库存扣减Seata落地的完整实操4.1 部署Seata Server并初始化配置纸上谈兵没用直接动手把谷粒商城跑起来。第一步是部署Seata Server。我用的是和谷粒商城课程一致的版本Seata Server 1.4.2搭配Nacos作为注册中心和配置中心存储模式用数据库模式也就是把全局事务会话、全局锁这类信息持久化到MySQL里。下载Seata Server安装包后修改conf目录下的registry.conf把registry和config都指向Nacosregistry { type nacos nacos { application seata-server serverAddr 127.0.0.1:8848 group SEATA_GROUP namespace cluster default } } config { type nacos nacos { serverAddr 127.0.0.1:8848 group SEATA_GROUP namespace dataId seataServer.properties } }存储模式默认是file生产环境必须改成db。在conf/file.conf里调整store.mode为db并配置数据库连接。同时要在数据库里创建global_table、branch_table、lock_table这三张表Seata官方脚本里有现成的建表SQL直接执行即可。4.2 订单服务和库存服务的接入配置Seata Server就绪后接下来在所有参与分布式事务的服务里引入Seata客户端依赖。谷粒商城用的是Spring Cloud Alibaba版本2.2.x对应Seata客户端的版本一般是1.3.0或1.4.2这个版本对应关系一定要查清楚版本不匹配会出现各种莫名问题最常见的就是和IoC容器初始化相关的报错。在订单服务、库存服务、会员服务的pom.xml里加入dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId /dependency dependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId version1.4.2/version /dependency接着在application.yml里为每个服务配置事务分组和注册中心seata: enabled: true application-id: order-service tx-service-group: my_test_tx_group registry: type: nacos nacos: server-addr: 127.0.0.1:8848 application: seata-server group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUPtx-service-group这个参数最关键它决定了客户端向哪个TC集群发起全局事务请求。Nacos配置中心里需要有对应的service.vgroupMapping配置把“my_test_tx_group”映射到具体的Seata集群名上。这一步是坑最多的地方很多人复制网上的配置结果事务分组和Seata Server的cluster对不上导致GlobalTransactional注解完全没有生效。还有一个必须处理的细节Seata要接管数据源才能解析SQL、生成undo_log。使用seata-spring-boot-starter时它默认会自动配置数据源代理但如果项目里手动定义过DataSource、DynamicDataSource或Druid相关的Bean就需要自己创建DataSourceProxy并交给Seata管理。谷粒商城里订单模块用的是Druid连接池正常按默认配置走问题不大但如果你在项目里自己new了DataSource一定要记得注入一个DataSourceProxy代理。4.3 最关键一步给每个服务数据库添加undo_log表AT模式依赖undo_log表来记录前后镜像每个参与分布式事务的业务数据库都需要建这张表。谷粒商城的sql脚本里就有核心字段包括branch_id、xid、context、rollback_info、log_status、log_created、log_modified。其中rollback_info字段存的是序列化后的前后镜像JSONlog_status标识这条日志是否已被处理过。CREATE TABLE undo_log ( id bigint(20) NOT NULL AUTO_INCREMENT, branch_id bigint(20) NOT NULL, xid varchar(100) NOT NULL, context varchar(128) NOT NULL, rollback_info longblob NOT NULL, log_status int(11) NOT NULL, log_created datetime NOT NULL, log_modified datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid,branch_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8;这个表有没有建对直接决定了全局事务回滚时能不能生成反向SQL。我见过有同学只给订单库建了undo_log漏了库存库的结果测试回滚时订单回滚成功、库存纹丝不动整个全局事务处于半成功半失败的状态数据全乱。排查了半天最后发现库存服务报错“Table stock_db.undo_log doesnt exist”才恍然大悟。4.4 下单接口如何添加分布式事务注解完成基础配置后业务代码的改动量其实很小。在谷粒商城的下单逻辑里最关键的就是在OrderServiceImpl的提交订单方法上加上GlobalTransactionalGlobalTransactional(name create-order, rollbackFor Exception.class) Override public SubmitOrderResponseVo submitOrder(SubmitOrderVo vo) { // 1. 校验库存远程调用库存服务 // 2. 创建订单写入订单表和订单项表 // 3. 远程调用库存服务锁定库存 // 4. 远程调用会员服务增加积分 // 5. 删除购物车已选商品 }注意几点rollbackFor一定要设为Exception.class因为Seata默认只回滚RuntimeException如果业务方法里抛出的是自定义的CheckedException不加这个参数全局事务不会触发回滚。事务传播行为建议保持默认的REQUIRED不需要额外修改。远程调用库存服务的Feign接口内部方法用的是普通Transactional不需要也不应该加GlobalTransactional全局事务入口只在最外层业务方法上标注。我在代码审查时经常看到有人给每个远程调用的方法都加上GlobalTransactional这是错误用法会造成嵌套全局事务导致XID混乱、分支事务重复注册。4.5 验证全局回滚是否真的生效硬编码一个场景来验证先生成订单然后在远程调用库存服务的方法里手动抛异常模拟库存扣减失败。跑一次单元测试观察现象。异常抛出后下单接口直接报错订单表里没有任何新增记录库存表里的库存量维持原值undo_log表里也没有残留的回滚日志。如果在MySQL的general log里开启日志还能看到Seata自动生成的反向SQL。比如订单表插入了一条order_id为10086的记录回滚时会执行DELETE FROM order_main WHERE order_id 10086库存表里执行了UPDATE sku_stock SET stock stock - 1 WHERE sku_id 1回滚时则会执行UPDATE sku_stock SET stock stock 1 WHERE sku_id 1把数据恢复成前镜像。在Nacos控制台的Seata服务列表里也能看到分支事务的注册记录和全局事务的状态变化。调试时我习惯把Seata Server的日志级别调到DEBUG这样每一步的全局锁获取、分支注册、提交回滚都能看得清清楚楚排查问题会快很多。5. 下单锁库存场景的经典问题与排查实录5.1 Feign调用超时导致回滚失效怎么定位分布式事务里最典型的故障之一是远程调用超时后全局事务回滚失败。下单接口调用库存服务正常情况下两秒内能返回但库存服务如果因为慢SQL卡了十秒Feign默认的读超时时间是六秒这时候订单服务会抛超时异常触发GlobalTransactional回滚。问题在于库存服务那边可能还没执行完等它执行完提交了本地事务全局事务已经处于回滚完成状态库存服务这次提交就变成了“悬空分支”。排查时先用异常堆栈确认是超时还是业务异常再看库存服务的日志里有没有出现“branch transaction rollback”或“xid is not active”这类信息。从预防角度讲下单这种核心链路里的Feign超时时间不要用默认值一定要根据业务执行耗时评估设置足够的读超时和连接超时。我一般设置在五到八秒避免因为网络抖动就全链路回滚产生大量无效请求。5.2 undo_log反复生成失败或找不到回滚记录undo_log生成失败的原因很大概率是Seata没代理到正确数据源。你需要在启动日志里确认“DataSourceProxy”是否被创建并且确认Service Bean注入的数据源确实是代理后的实例。另外一个常见的坑是多数据源场景下Seata只能代理其中一个主数据源如果某个分支操作走的是另一个非代理数据源就不会生成undo_log自然也就无法参与全局回滚。回滚时找不到undo_log通常是xid传递链路断了。比如订单服务通过线程池异步调用了库存服务ThreadLocal里的XID没有被传递到新线程库存服务自己启动了一个不参与全局事务的本地事务。Seata官方提供了RpcContext和Hystrix隔离策略但最稳妥的方案是避免在全局事务方法里使用异步线程去执行关键分支操作。5.3 库存行热点冲突全局锁等待导致下单变慢Seata AT模式的全局锁在高并发库存场景下会变成一个瓶颈。当两个全局事务同时操作同一个SKU的库存时后一个事务会在获取全局锁阶段自旋等待等待时间取决于前一个全局事务的执行时长。下单链路如果整体执行三秒那并发上来后同一SKU的下单请求就会排队接口RT飙升。解决思路分两个方向一是缩短全局事务的执行时长把耗时的非核心操作移出事务边界比如会员积分累加可以用MQ异步消费不必和订单、库存放在同一个全局事务里二是从业务上规避热点比如在秒杀场景用Redis预扣库存把数据库层的并发写降下来。谷粒商城的普通下单场景并发量没那么高直接靠Seata AT模式是可以扛住的但你要心里有数知道这个瓶颈点在什么位置。5.4 常见问题速查表问题现象可能原因排查/解决思路GlobalTransactional不生效Seata客户端注册中心配置错误、事务分组映射不对检查registry和config的Nacos配置确认service.vgroupMapping存在回滚时提示找不到undo_log数据库没建undo_log表、数据源未代理初始化undo_log表检查DataSourceProxy是否生效全局事务回滚了但库存没恢复库存服务本地事务已提交且未注册分支检查Feign调用链XID传递是否被拦截器丢弃下单接口RT暴涨全局锁等待、或Seata Server性能瓶颈缩短全局事务、把非核心分支移出事务、升级Seata Server启动报Seata相关Bean冲突Seata版本与Spring Cloud Alibaba版本不匹配严格对照版本对应关系统一升级或降级库存扣减成功但订单入库失败分支事务虽然注册但未纳入同一全局事务确认全局事务入口在最外层服务其他分支调用不加GlobalTransactional6. 把谷粒商城下单事务写成项目亮点很多人学完谷粒商城简历上写的就是很空的“实现分布式事务”六个字面试官一听就知道没深入。要把它变成真正的项目亮点得从三个层面去讲。第一层业务层面要讲清楚“为什么必须分布式事务”下单跨订单、库存、会员多个服务本地事务管不住远程调用的数据一致性一旦订单入库成功而库存扣减失败就会造成超卖风险。这里要能讲出业务痛点而不是一上来就说我用了Seata。第二层技术层面要讲清楚AT模式的原理细节一阶段提交和undo_log生成、二阶段异步清理或反向补偿、全局锁防止脏写、读隔离和写隔离的边界。面试官顺着原理问下去你能把执行流程串起来这个含金量就出来了。第三层工程层面要讲清楚你踩过的坑和做的优化比如Feign超时时间怎么设置、线程池为何会切断XID、热点库存如何用Redis预扣解决、非核心链路如何用MQ异步解耦。这些是网上教程不会详细讲、但真实项目里一定会遇到的问题也是最能体现项目深度的部分。我自己的体会是分布式事务这种中间件光看原理死记硬背是真的记不牢的。你得亲手把一个正常的全局事务跑坏看看它到底是怎么回滚的日志里输出了什么undo_log表里留下了什么才能对整套机制建立起肌肉记忆。谷粒商城作为教学项目最大的价值就在于它给了你一张完整的电商业务地图你可以在上面折腾、埋雷、排雷。这个折腾的过程比背一百道面试题都管用。最后再分享一个小技巧本地调试分布式事务时别把Seata Server打到线上环境就在本地起一个跑把订单服务、库存服务、Nacos、MySQL全部本地化。测试回滚最简单的办法是在库存服务里写一个可配置的开关比如从请求参数里读一个flag为true就强制抛异常不用改代码就能模拟各种异常场景。这个调试方式帮我节省了大量时间也是我每次带新人入门分布式事务时必推的实操手段。