一个再常见不过的订单场景:用户下单,订单服务先落库,然后远程调库存服务扣库存,还要调账户服务减余额。单机时代一个本地事务就能锁死这三件事,到了微服务架构下,每个服务都有自己的数据库,本地事务只能保证本库一致,订单写进去了、库存没扣成功,最后对不上账——这就是分布式事务要解决的问题。所谓"订单与库存分布式事务一致性",本质就是在多个数据库、多个服务之间,找回单库事务的原子性。Seata这个阿里开源框架之所以火,就是因为它把这个老难题的解法压到了三行代码的体量。不过,这三行代码背后藏着的机制,远没有宣传里那么轻描淡写。
我实际用Seata做了几个真实项目的落地,从踩坑到调优都走过一遍。这篇文章不打算只贴个注解然后说"搞定",我想把"三行代码"是怎么把分布式事务串起来的、订单扣库存这种场景具体怎么配、以及真实生产环境里最容易出事的几个坑,一次性讲透。
1. 订单扣库存翻车现场:分布式事务到底难在哪
1.1 一个本地事务管不住三个数据库
先还原一下翻车的现场。假设你有订单服务、库存服务、账户服务三个微服务,各自独立的数据库。用户下单时,订单服务要做的操作是:
- 订单表插入一条订单记录
- 调用库存服务的接口扣减库存
- 调用账户服务的接口扣减余额
在单体应用时代,这三步都在同一个数据库事务里,BEGIN到COMMIT之间任何一步失败,ROLLBACK就全没了。可在微服务架构下,订单服务只能保证自己库里的订单插入是原子的,库存服务和账户服务那边的操作,它根本管不着。
最常见的翻车结果是:订单表和账户表都成功,库存扣减却因为超时、库存不足、兜底逻辑抛异常而失败。用户看到订单已经生成了,仓库那边却少扣了库存,资产和实物对不上。更麻烦的是,这种不一致不会立刻暴露,往往等对账才发现,那时候数据已经被后续流程污染,修复成本成倍增长。
要解决这个问题,本质上就是让跨服务、跨数据库的一组操作,重新拥有"要么全成功、要么全失败"的原子性。这跟单库事务的语义一样,区别只是参与者从一张表变成多个服务。
1.2 强一致与最终一致:先别急着上Seata
在聊Seata之前,我想先泼一盆冷水:不是所有跨服务写操作都需要强一致的分布式事务。分布式事务是有成本的,这个成本包括额外的锁开销、全局协调的延迟、引入TC组件后的运维负担。如果业务能接受"几分钟后再对上账",用消息队列做最终一致性往往是更经济、更稳定的方案。
但有一类场景必须上强一致或近乎强一致的方案:你没法接受哪怕一小段时间的数据错位。比如下单扣库存,如果允许先超卖后补单,用户体验会很差;比如资金类操作,余额明明没扣却显示扣了,用户再下一单就透支了。Seata解决的就是这类"必须一致、且链路不长"的场景。这也是为什么我的判断标准很简单:如果业务链路超过五六个服务、耗时可能超过几十秒,我会优先考虑最终一致性;如果链路短、需要即时一致,Seata的AT模式是第一个值得考虑的工具。
2. 三行代码的真相:一次注解背后的TC/TM/RM协作
2.1 "三行代码"具体是哪三行
Seata官方宣传里常提"三行代码搞定分布式事务",这确实不是夸张,前提是你已经搭好了TC(事务协调器)和相关基础设施。站在业务开发者的视角,你要做的改动确实非常少:
第一行,加依赖。以Spring Boot项目为例,引入seata-spring-boot-starter,版本要跟你部署的seata-server保持一致:
<dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>2.1.0</version> </dependency>第二行,写配置。在application.yml里配置事务组、注册中心和seata-server地址,这块稍微有点绕,后面实操章节我会详细说明,但本质上就是告诉客户端"TC在哪儿、我这个服务属于哪个分组"。
第三行,也是唯一的业务代码改动,在需要全局事务的方法上加一个注解:
@GlobalTransactional public void createOrder(OrderDTO orderDTO) { // 业务代码 }如果你用惯了Spring的@Transactional,这个注解的用法几乎不用学习成本。但这里有个必须提前说清楚的误区:@GlobalTransactional不是本地事务的替代品,它是"全局事务的入口"。方法内部那些操作各自数据库的代码,仍然依赖每个服务自己的本地事务,Seata负责的是把各个本地事务组织成一个全局事务,让它们要么一起提交,要么一起回滚。
2.2 TC/TM/RM三个角色和一次全局事务的完整生命周期
要真正理解那一个注解做了什么,你必须认识Seata模型里的三个角色:
TC(Transaction Coordinator),事务协调者。这是独立部署的seata-server进程,负责维护全局事务的状态、分支事务的注册、全局锁的管理。它是整个分布式事务的"大脑"。
TM(Transaction Manager),事务管理器。它在发起全局事务的那个服务里,由@GlobalTransactional注解触发,负责向TC发起全局事务的开启、提交、回滚请求。
RM(Resource Manager),资源管理器。它在每个参与分支事务的服务里,负责本服务本地事务的注册、状态上报,以及接收TC的提交或回滚指令去执行本地的提交或回滚。
一次完整的AT模式全局事务,生命周期是这样的:
- TM向TC发起全局事务开启请求,TC生成一个全局事务ID(XID)并返回。
- XID通过远程调用链路往下传。Seata通过Dubbo、Spring Cloud OpenFeign等RPC框架的过滤器,自动把XID放进调用上下文里传到下一个服务。
- 每个参与服务在执行本地SQL前后,RM会记录"前镜像"和"后镜像"到本库的
undo_log表,然后向TC注册分支事务并申请全局锁。 - 业务正常执行完毕,TM向TC提交全局事务。TC通知所有RM提交分支事务,RM只需要删除对应的
undo_log记录并释放全局锁。 - 如果任何一个环节抛异常,TM向TC发起回滚。TC通知所有RM回滚,RM根据
undo_log里的前镜像数据,生成反向SQL,把数据恢复成执行前的样子。
这里最核心的机制是"镜像+逆向回滚"。Seata并没有像XA那样锁住整条SQL的执行,而是把业务SQL先正常执行,同时记录修改前后的数据快照。回滚时不是重新跑业务逻辑,而是依据快照把数据"倒回去"。这个设计规避了2PC协议中资源长时间被锁的问题,也是AT模式相比XA的一个关键优势。
3. 订单与库存场景的AT模式落地实操
3.1 环境准备与TC部署
先讲TC,也就是seata-server的部署。对本地调试来说,从GitHub release页下载对应版本的压缩包,解压后直接启动就行:
# 端口默认8091,file模式存储事务数据到本地文件,适合开发环境 sh seata-server.sh -p 8091 -m file如果你的项目用了Nacos做注册中心,客户端要能自动发现TC,seata-server也是注册到Nacos的,启动时带上注册中心配置即可。生产环境建议把存储模式从file切换成db,让TC的事务状态落库,避免服务重启导致正在进行中的全局事务状态丢失。db模式需要先建TC自己的三张表:global_table、branch_table、lock_table,官方源码的script/server/db/mysql.sql里都有,直接执行就行。
我见过不少新手在这步栽跟头:版本没对齐。客户端starter的版本、seata-server的版本、Nacos上的配置格式,这三者必须匹配。版本差太远会出现序列化错误、接口不兼容这类莫名其妙的问题。先统一到同一个小版本,比如都用2.1.x,能省掉大量排查时间。
3.2 业务库表准备与数据源代理
AT模式对业务库表有两个硬性要求:一条是每张参与事务的业务表必须有主键,另一条是必须有undo_log表。第一点很好理解,没有主键,Seata就没法定位到具体行去记录前后镜像和生成逆向SQL。第二点是整个回滚机制的依赖:
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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表每个业务库都要建,不是建在TC那边。回滚时RM就是在本库读这张表来恢复数据的。
然后是数据源代理。这是很多教程没讲透、却是AT模式真正起作用的开关。Seata要拦截并解析业务SQL,前提是数据源被包装成DataSourceProxy。在老版本里需要手动配置:
@Configuration public class SeataDataSourceConfig { @Bean @Primary public DataSource dataSource(DataSource dataSource) { return new DataSourceProxy(dataSource); } }新版本的seata-spring-boot-starter在多数场景下会自动完成数据源代理,但如果你用的是公司封装过的多数据源、连接池代理,还是要确认一下Seata是否拿到了代理控制权。判断方法很简单:启动日志里搜DataSourceProxy,或者看事务执行时有没有生成undo_log记录。如果没有,说明SQL没被Seata接管,全局事务形同虚设。
3.3 业务代码改造与回滚验证
搭建完成之后,业务代码改起来确实就是那一个注解。订单服务里这样写:
@Service public class OrderService { @Resource private OrderMapper orderMapper; @Resource private InventoryFeignClient inventoryClient; @GlobalTransactional public void createOrder(OrderDTO orderDTO) { orderMapper.insert(buildOrder(orderDTO)); inventoryClient.deduct(orderDTO.getProductId(), orderDTO.getCount()); } }库存服务那边不需要加@GlobalTransactional,它只需要在扣减库存的方法上标记本地事务@Transactional,由Seata的RM自动把本地事务纳入全局事务。注意这里有个隐性的协作关系:全局事务的回滚能力依赖每个RM正确上报分支状态。如果库存服务是普通Feign调用且方法内异常被吞掉了,RM可能会上报成功,导致全局事务错误地提交。所以跨服务的接口要约定清楚,业务异常必须让RM感知到异常状态。
落地之后一定要做两类验证。第一类是正常链路:下单后,订单表、库存表、账户表数据同步变更,undo_log表会短暂出现记录,全局事务提交后这些记录被删除。第二类是异常回滚:我在库存服务里人为抛一个运行时异常,再调用下单接口,观察订单表是否回滚。用Postman打一次请求,你会发现订单表的插入也被回滚了,这正是分布式事务要的效果。我还会顺手查TC日志里的分支状态,确保每个RM都正确返回了Rollbacked。
4. 全局锁冲突、超时回滚与脏读:实测中常见的坑
4.1 全局锁冲突:最容易被误判为死锁的问题
AT模式为了防脏写,引入了一把"全局锁"。简单说,在分支事务执行UPDATE时,RM会向TC申请对目标行数据的全局锁。如果另一个全局事务也想改同一行,就申请不到锁,SQL会被阻塞等待,直到超时。
实际生产里最典型的冲突场景是:用户A的全局事务还在进行中,修改了某条库存记录但没提交;用户B的全局事务也想改这条库存,B的SQL就会一直卡住。你会发现数据库本身没有死锁,DBA看锁信息一切正常,但业务就是超时。这时候要去查TC的lock_table,看哪条row_key被哪个XID占着。
解决方向有两个。业务侧:减少全局事务的保持时间,把远程调用、外部API等耗时的动作挪到事务外面做,锁越短,冲突面越小。架构侧:如果发现同一行数据的并发修改极其频繁,那说明这个业务本来就不适合用全局锁来保护,可能需要考虑把该操作从分布式事务链路里拆出去,用队列串行化处理。
4.2 事务超时、AOP自调用与undo_log的隐性依赖
@GlobalTransactional有个默认超时时间,默认60秒。全局事务内的所有分支加在一起超过这个时间,TC会主动发起回滚。这在分布式环境下很容易被触发:某个下游服务响应慢,RPC超时重试,整个全局事务运行时间超过60秒,结果就是业务还在跑,TC已经判了死刑开始回滚,两边状态彻底乱掉。我建议根据链路实际耗时显式设置timeoutMills,不要默认值裸奔。
AOP自调用也是一个非常经典的坑。@GlobalTransactional依赖Spring AOP代理,如果同一个类内部,方法A调用方法B,B上虽然标注了@GlobalTransactional,但这个调用发生在代理对象内部,不会经过代理,注解完全不生效。我在订单服务里就踩过一次:把异步任务和事务方法写在同一个类里,结果事务边界完全没建立。解决办法是把需要事务的方法拆到单独的Bean里,或者用@Resource注入自己。
还有undo_log的隐性依赖。回滚时如果undo_log表数据被人工清理,或者表结构不对,回滚会失败。更隐蔽的是,回滚执行需要DML权限,如果服务账号只有SELECT和INSERT权限,会导致SQL执行成功但回滚语句被数据库拒绝。这类问题不常见,一旦踩到你会在夜深人静时接听到业务方咆哮的电话。
4.3 回滚失败后的现场排查链路
我给你的排查路径是这样的:先从TM的日志看全局事务是否进入了回滚流程,确认XID和时间戳;然后去TC查global_table里这个XID的状态和分支情况;再去每个参与服务的undo_log表,看分支提交时是否成功删除记录,回滚时是否成功插入记录;最后对比业务表当前数据和undo_log里的前镜像,定位是哪个分支没有按指令恢复。
这套链路我完整走过一次。当时现象是回滚后库存对了,订单却还留着。排查发现订单服务那个分支的RM,因为本地事务配置错误,在全局事务回滚指令到达时本地事务还没提交,导致回滚时锁冲突,SQL一直重试。最终是把订单服务里的事务传播行为做了修正,问题才消失。这也说明,Seata的AT模式对参与服务的本地事务基础能力要求极高,本地事务本身不规范,全局事务一定会出幺蛾子。
5. AT不是万能钥匙:TCC、SAGA、XA怎么选
5.1 四种模式的取舍对比
Seata不是只有AT这一种模式,它实际上是一个分布式事务框架,提供了AT、TCC、SAGA、XA四种模式。我的经验是对不同场景必须用不同模式,只盯AT会吃亏。
| 模式 | 侵入性 | 一致性类型 | 性能 | 适合场景 |
|---|---|---|---|---|
| AT | 配置侵入低,业务代码几乎不改 | 全局锁保证提交期强一致,回滚靠补偿 | 中等,有全局锁开销 | 普通CRUD、短链路、表结构规范 |
| TCC | 高,业务需要写Try/Confirm/Cancel | 强一致,但靠的是业务补偿 | 高,无全局锁 | 高频交易、资金操作、复杂业务语义 |
| SAGA | 中,需要编排和补偿逻辑 | 最终一致 | 高,无全局锁 | 长事务、多步骤、不允许长时间锁资源 |
| XA | 配置侵入低 | 强一致,数据库原生支持 | 偏低,资源锁持续到提交 | 单库多分支、依赖数据库强一致能力 |
先说AT。它的最大优点是业务改造量极小,但为了支持自动回滚,它要求你的业务表尽量是简单的"可逆向"结构。如果表聚合了复杂计算,或者UPDATE后没办法精确还原,前镜像后镜像的机制就会遇到麻烦。
TCC则是把Try、Confirm、Cancel三个阶段全部交给业务实现。比如扣库存,Try阶段冻结库存,Confirm阶段真正扣减,Cancel阶段解冻。这个模式控制力最强,但代码量明显上去,每个参与方都要独立写一套逻辑。适合账户余额、订单状态这种可以明确拆成"预操作、确认、撤销"的业务。
XA模式本质是标准的两阶段提交协议,数据库层面锁资源。它在Seata里的适用面其实比较窄,因为微服务下跨数据库的XA实现依赖每个数据库都支持XA协议,而且全局锁等待时间会让系统吞吐量明显下降。
5.2 我的选型经验:能用消息队列就别硬上强一致
最后聊点个人体会。我在做用户积分系统的时候,积分发放和订单完成其实是两个不同的业务域,强行用分布式事务把两边绑在一起,虽然能在技术上做到强一致,但每次发积分都要等订单那边提交,链路被拉长,失败重试也更复杂。后来改成订单完成发MQ消息、积分服务异步消费,配合幂等表和重试机制,反而更稳定、更省资源。
Seata适合的是那种"必须立刻一致、且可以把相关操作收拢到同一个业务域"的场景,比如下单扣库存,这两者的关联紧密到无法拆分,用户点击下单那一刻,库存必须真实扣减,否则订单状态和库存数量会产生严重冲突。
所以我的选型策略可以总结成三句话:链路短、要即时一致,用Seata AT;链路长、要强一致且有明确状态流转,考虑TCC;能接受少量延迟、跨业务域解耦,优先MQ最终一致性。分布式事务不是越强越好,而是越合适越好。这也算是我踩了几年坑之后,最想告诉后来人的一句话。