做后端开发这些年,订单扣库存这件事几乎天天见。单体应用里一条数据库事务就能解决,但系统一拆成订单服务、库存服务、账户服务,跨库跨服务的数据一致性就成了绕不过去的坎。两个服务各自提交本地事务,谁先成功、谁后失败,结果就是库存扣了订单没生成,或者订单建了库存超卖。我第一次排查这种问题的时候,在日志里来回翻了一下午,才意识到这不是某个接口的bug,而是微服务架构下典型的分布式事务混乱。后来接入Seata,才算真正把这块理顺。
Seata是开源分布式事务框架,主要解决微服务场景下的全局事务一致性问题。它最让我觉得友好的地方是侵入性比想象中小,业务代码只需要加一个注解,事务的协调、回滚、分支注册都交给框架处理。无论你是刚接触微服务的新手,还是已经在生产环境踩过分布式事务坑的开发,都可以从这篇文章找到可落地的思路。下面我先把问题讲透,再讲Seata的架构和模式,最后用订单和库存这个最常见场景做一次完整实操。
1. 分布式事务到底难在哪
1.1 从订单和库存讲起
先还原一个典型场景。用户在商城下单,系统需要做三件事:订单服务插入订单记录,库存服务扣减商品库存,账户服务扣减用户余额。如果三个动作都在一个数据库里,那很简单,一个事务包裹起来,任何一步报错就整体回滚。可微服务化之后,订单、库存、账户都拆成了独立服务,数据库也跟着拆开。这时候问题来了:订单服务提交了订单记录,库存服务扣库存成功了,账户服务却因为余额不足失败了。由于订单服务的事务早就提交,它并不知道后面出错了。
更麻烦的是,如果先扣库存再创建订单,一旦订单创建失败,库存又没能恢复,商品就莫名其妙少了。无论是先还是后,只要服务间的操作无法形成原子性,就会出现中间状态。这种不是程序bug本身,而是分布式事务没有做好,本质上是多个独立事务之间的协调问题。订单与库存这个场景,是所有分布式事务文章避不开的例子,因为它足够简单,又足够说明问题。
1.2 一致性目标:强一致还是最终一致
分布式系统里,我们常说CAP理论:一致性、可用性、分区容错性三者不能同时完美满足。网络分区一旦出现,要么牺牲可用性保证强一致,要么牺牲一定的一致性保证可用。大多数互联网业务会选择可用性,接受最终一致。分布式事务框架的职责,就是在这个前提下尽可能让数据状态收敛到一致,而不是任由它一直不一致下去。
Seata的思路在于,它并不要求所有数据在每一时刻都强一致。它通过全局事务ID把一组本地事务串起来,让它们作为一个整体提交或回滚。对调用方来说,最终结果像是一个原子操作;对系统内部来说,允许在事务执行过程中存在短暂的不一致窗口,但最终通过补偿或回滚恢复。这样做的好处是,不需要把整个调用链路锁成一团,也不需要在所有服务之间频繁通信,性能损耗相对可控。
1.3 经典二阶段提交的局限
说起分布式事务,绕不开两阶段提交(2PC)。它分准备阶段和提交阶段:协调者先问每个参与者能不能提交,参与者都准备好后,协调者再下发最终提交指令。这个思路看起来直接,但有个致命问题:参与者如果迟迟不响应,协调者只能一直等;协调者自己挂掉,所有人都不知道下一步怎么走。整个流程非常依赖网络可靠性,而网络恰恰是最不可靠的部分。
Seata的AT模式在设计上借鉴了2PC的思想,但做了大量优化。它引入了事务协调者(TC),让TM只负责开启和结束全局事务,RM负责执行分支事务并上报状态,分工更清晰。更重要的是,全局事务的提交和回滚不一定需要所有参与者保持在线,部分分支可以异步处理,降低同步阻塞。理解了这一点,再看Seata的架构就不会觉得难。
2. Seata核心架构与模式选择
2.1 TC/TM/RM三个角色
Seata把一次分布式事务拆成三个角色。TC(Transaction Coordinator)是事务协调者,通常指Seata Server,负责全局事务的注册、提交、回滚指令的下发。TM(Transaction Manager)是事务管理器,内嵌在发起全局事务的服务里,负责向TC申请开启事务,并在业务完成后向TC发起全局提交或回滚。RM(Resource Manager)是资源管理器,内嵌在参与分支事务的服务里,管理每个分支事务,并把执行结果上报给TC。
用交通来类比:TC是整个路网的交管中心,TM是发起出行的调度员,RM是各个路口的信号灯。调度员告诉交管中心“我要走这条路”,每个路口的信号灯执行自己的放行动作,都通过后交管中心通知整条路放行。一旦某个路口出问题,交管中心会广播回滚。这里的“全局事务ID”(XID)就是贯穿整条路线的派车单号,下游服务拿着同一个编号,才知道自己属于哪一次全局事务。
2.2 AT模式:侵入性最小的默认选择
AT模式是Seata最常用的一种,也是新手最容易上手的模式。它的核心思路是:代理数据源拦截业务SQL,在业务执行前后自动记录数据镜像,写入undo_log表。先记录before image(操作前数据),再执行SQL,再记录after image(操作后数据),然后在TC那里注册一个分支事务。如果全局事务要回滚,Seata就根据undo_log里的before image生成补偿SQL,把数据改回去。
由于业务代码里只需要加一个@GlobalTransactional注解,SQL和表结构基本不用动,所以很多团队把AT模式当作默认方案。但注意,AT模式有一个隐藏要求:每个业务数据库里都要有undo_log表,并且主键必须可见。如果SQL更新时带的条件本身无法定位到唯一记录,Seata会锁更多行,影响性能。所以在表设计上尽量保留清晰的主键,避免批量更新大范围的SQL。
2.3 TCC、Saga、XA三种模式怎么选
除了AT,Seata还支持TCC、Saga和XA。TCC模式要求业务提供Try、Confirm、Cancel三个方法,适合事务边界灵活但容忍开发成本的场景,比如账务冻结、积分增减。Saga模式用一串本地事务加补偿操作来编排,适合长事务和最终一致场景,但需要自己设计补偿逻辑。XA模式直接交给数据库XA协议处理,强一致能力强,但对数据库和连接池的要求更高,分布式环境下不太灵活。
模式选择上,我建议新手先掌握AT。它开发成本最低,通用性最高。TCC和Saga通常用于特殊场景,比如AT全局锁容易冲突的时候,或者事务执行时间太长的时候。XA更适合那些对强一致有硬性要求、且业务量不那么大的系统。先学会AT跑通一个完整链路,再扩展其他模式,理解会更快。
3. 环境搭建与快速上手
3.1 五分钟启动一个Seata Server
Seata Server是TC角色,所有全局事务的协调都靠它。启动方式不复杂,我以1.6.1版本为例。从官方Release页面下载seata-server压缩包,解压后进入bin目录。在conf目录下有一个registry.conf文件,里面配置注册中心和配置中心。最简单的方式是先用直连模式跑起来,后面再切Nacos。
修改registry.conf,把type设为file,或者把file配置项中的name改成file.conf。使用docker也可以,命令是:
docker run --name seata-server -p 8091:8091 seataio/seata-server:1.6.1启动后,默认端口是8091,看到Started SpringApp就说明成功了。此时Seata Server相当于一个单独的协调者,没有任何业务库依赖,可以独立运行。
3.2 在Spring Boot项目里绑定事务组
下面以一个标准Spring Boot订单服务为例。加入依赖seata-spring-boot-starter,版本和你下载的Server保持一致,最好别差太多。配置文件里需要声明事务分组:
seata: tx-service-group: my_test_tx_group service: vgroup-mapping: my_test_tx_group: default grouplist: default: 127.0.0.1:8091这里的my_test_tx_group是应用自己的事务分组名,default映射到实际TC地址。如果使用Nacos注册中心,则把registry.type改成nacos,并填写server-addr。这个分组名没有硬性规则,但建议一个应用对应一个分组,方便后面做隔离和权限管理。
3.3 每个业务库都要建undo_log表
前面说过,AT模式依赖undo_log表。官方提供的建表SQL我直接放出来:
CREATE TABLE IF NOT EXISTS `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, `ext` VARCHAR(100) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;这张表不需要在业务代码中手动操作,Seata的RM会自动读写。但没有它就完了,回滚时找不到补偿依据。实操中,我见过很多团队把表建在主库忘了建在从库,或者只建了订单库、没建库存库,结果就是某个分支回滚失败。建议用脚本在所有业务库统一执行一遍。
4. 订单与库存的分布式事务实操
4.1 表结构和初始数据准备
我直接用一个最小可复现的例子。业务需要一张订单表和一张库存表,服务拆成对应的两个Spring Boot应用。订单表如下:
CREATE TABLE `orders` ( `id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `product_id` BIGINT NOT NULL, `amount` INT NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; CREATE TABLE `inventory` ( `product_id` BIGINT NOT NULL, `stock` INT NOT NULL, PRIMARY KEY (`product_id`) ) ENGINE=InnoDB;初始库存:product_id=1, stock=100。订单表为空。再建两张表对应的Mapper和实体类,这部分和普通开发没有区别。
4.2 订单服务调用库存服务
订单服务的核心方法加上@GlobalTransactional,方法内部先插入订单,再调用库存服务。库存服务的Feign接口暴露/inventory/reduce。关键代码:
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private InventoryFeignClient inventoryFeignClient; @GlobalTransactional(name = "order-create", rollbackFor = Exception.class) public void createOrder(OrderDTO dto) { Order order = new Order(); order.setId(System.currentTimeMillis()); order.setUserId(dto.getUserId()); order.setProductId(dto.getProductId()); order.setAmount(dto.getAmount()); orderMapper.insert(order); inventoryFeignClient.reduceStock(dto.getProductId(), dto.getAmount()); } }注意,@GlobalTransactional只要加在发起方服务上,库存服务不需要加。但库存服务的数据源也必须被Seata代理。这一点很多人会漏掉,导致分支事务没有注册到全局事务里,回滚自然无从谈起。
提示:@GlobalTransactional 一定要加在事务发起方,参与方只需要保证数据源被代理。如果参与方没被Seata接管,整个过程就和普通RPC没有区别。
4.3 库存服务扣减逻辑
库存服务的Mapper里写一个扣减SQL,如果扣减影响的行数为0,就说明库存不足,抛出异常。为了验证回滚,可以在正常扣减后故意再抛一个业务异常,模拟库存服务成功后又发现其他条件不满足的情况。
@Service public class InventoryService { @Autowired private InventoryMapper inventoryMapper; public void reduceStock(Long productId, Integer count) { int rows = inventoryMapper.reduceStock(productId, count); if (rows == 0) { throw new RuntimeException("库存不足"); } // 模拟业务校验失败 if (count > 10) { throw new RuntimeException("单次购买数量超限"); } } }为什么扣减顺序用update inventory set stock = stock - #{count} where product_id = #{productId} and stock >= #{count}?因为这条SQL把库存判断和扣减放在一个原子操作里,避免先查再改带来的并发问题。
4.4 验证正常提交和异常回滚
启动Seata Server、订单服务、库存服务,调用订单创建接口。先传一个商品数量为5的请求,接口返回成功,此时订单表有一条记录,库存从100变成95。再传一个数量为50的请求,库存扣减成功,但随后触发了单次购买数量超限的异常,接口返回失败。此时去查订单表,应该没有新增的那条订单,库存还保持在95。
整个过程看Seata Server日志,能看到全局事务成功注册、分支事务回滚的记录。如果回滚没生效,优先检查数据源代理配置和undo_log表是否存在。这是最经典的自检路径,我建议第一次学习时把这个链路完整跑通,比看一百篇原理文章都管用。
5. 常见问题与排查技巧实录
5.1 数据源没有代理,回滚静默失效
最典型的问题是方法加了@GlobalTransactional,异常也抛了,但数据就是没回滚。翻日志看到no active global transaction之类提示,八九不离十是数据源没有被Seata代理。解决办法是启动类或配置类上加上@EnableAutoDataSourceProxy,或者手动用DataSourceProxy包一层数据源。我见过有人检查了半小时,最后发现忘了加这个注解。
5.2 Undo_log表没建,一异常就报错
日志中出现UndoLogManager相关异常,或者table 'undo_log' doesn't exist,属于没建表。每个参与AT模式的服务,对应的业务库都要建undo_log表,不只主库。如果业务库分库了,所有分片都要建。表结构可以直接用官方模板,不要自己加字段或改字符集,否则Seata解析undo_log时可能出现数据错乱。
5.3 全局锁超时与高并发优化
AT模式在更新记录时会使用全局锁,防止不同全局事务并发修改同一行。并发高时,日志会出现Global lock acquire timeout或get global lock fail。这时候不要一上来就关掉全局锁,那是把一致性短板彻底暴露。可以先优化业务:缩短事务执行时间,减少分支数量,或者把热点行的并发量分流。再不行,就考虑对热点场景换TCC模式,手动控制锁粒度和补偿逻辑。
5.4 注解“不起作用”的奇怪坑
@GlobalTransactional没有生效,很多时候是Spring AOP的问题。比如在同一个类里,一个方法调用另一个带注解的方法,就属于内部调用,代理对象拦截不到。可以把获取本类代理再调用,或者把全局事务方法独立到一个Service类中。另外,非public方法也无法被Spring AOP代理,加注解之前先确认方法修饰符。
5.5 版本兼容速查
Seata和Spring Cloud Alibaba版本匹配不好,会出现很多莫名其妙的问题。这里整理一个简化表,以个人经验为准,不代表绝对完整:
| 组件 | 版本参考 |
|---|---|
| Spring Boot | 2.6.x / 2.7.x |
| Spring Cloud Alibaba | 2021.0.5.0 |
| Seata | 1.6.1或1.7.x |
| Nacos | 2.x |
如果项目用的是Spring Boot 3.x,要注意Seata版本必须支持Jakarta命名空间,不能拿老版本硬怼。我建议优先采用和项目JDK版本匹配的官方发行说明,避免猜。
6. 一些使用经验与实战建议
6.1 别把Seata当成万能药
Seata确实好用,但每一次全局事务都有额外开销。AT模式需要写undo_log、注册分支、获取全局锁,这些成本在并发不高的时候看不出来,一旦成为热点就会出现锁竞争。所以能用本地事务解决的,就不要拆成分布式事务;能用消息队列做最终一致的,也不必强求强一致。Seata适合的是“必须让多个服务在一个逻辑事务里要么全成功要么全失败”的场景。
6.2 事务边界越短越好
全局事务里尽量不要嵌套太深的RPC,不要执行太多业务逻辑。事务边界越长,锁持有时间越长,回滚范围也越大,失败的几率自然上升。我在实际项目里会把外部RPC、短信通知这类操作挪到事务之外,只把最核心的写操作放进Seata管理。宁可多做几次补偿查询,也别让一个慢接口拖垮整个事务。
6.3 幂等和重试要提前设计
分布式事务里的每个服务都可能面临重复请求。调用方超时后重试,可能把同一笔订单提交两次。Seata能保证全局事务的一致性,但它不会帮你处理业务幂等。常见做法是:订单号用唯一索引,插入前查重;库存扣减用条件更新;调用下游接口时带上同样的请求ID。这些防线和Seata并不冲突,而是互补。
6.4 把XID打印到日志里
排查分布式事务问题时,最大的困难是没法把一次请求的所有调用串起来。Seata提供了一个很方便的接口:RootContext.getXID()。你可以在Filter或拦截器里把它放进MDC,日志里直接输出。比如这样:
String xid = RootContext.getXID(); MDC.put("xid", xid == null ? "" : xid);之后,任何参与这个全局事务的服务日志都会带上同一个XID,从头看到尾。很多看着诡异的问题,比如回滚成功但业务报错,后续排查就变成了搜索同一个XID,效率会高很多。
6.5 最后分享一个细节
我在生产环境踩过最大的坑,是全局事务的默认超时时间和接口实际执行时间不匹配。Seata默认全局事务超时时间一般是60秒,如果接口里有大事务或者慢SQL,很容易在业务还没结束时先被超时回滚。需要在事务发起方的配置里调整全局事务超时时间,同时也要把RM的锁重试时间配合理。不同版本配置项名称有差异,最好翻一下官方Release Notes,别盲目拷网上的配置。
分布式事务不是个炫技话题,它是在系统拆分之后必须面对的现实。Seata把实现成本降得很低,但底层的锁竞争、超时、幂等问题仍然要靠业务侧去解。先把订单和库存这个小链路跑通,再逐步理解AT模式的补偿机制,后面的路会顺很多。