分布式事务这个话题,我在实际项目里踩过的坑比写过的代码还多。早些年做单体应用的时候,一个本地事务注解就能搞定的事情,拆成微服务之后突然变成了一门玄学——订单扣了库存没扣、库存扣了余额没扣、余额扣了积分没加,数据对不上账的时候排查起来能把人逼疯。后来团队引入了Seata,才算把这块硬骨头啃下来。这篇内容我想把Seata这套分布式事务的实现原理掰开揉碎讲清楚,从它为什么这么设计、AT模式的核心机制、全局锁与回滚日志的配合方式,到实际落地时的配置细节和踩坑记录,都会涉及。适合正在做微服务拆分、被跨服务数据一致性折磨的后端同学,也适合对分布式事务原理好奇、想搞明白“两阶段提交到底怎么落地”的开发者。看完你至少能搞清楚:Seata的AT模式凭什么能做到对业务代码几乎零侵入,以及它在哪些场景下会翻车。
1. 先搞清楚分布式事务到底难在哪
1.1 从一次真实的对账事故说起
我印象特别深的一次事故,是某电商系统大促期间的对账异常。用户下单后,订单服务写入了订单记录,紧接着调用库存服务扣减库存,库存服务返回成功,然后调用账户服务扣款——结果账户服务因为数据库连接池被打满,超时了。订单服务拿到超时异常,按照本地事务的逻辑回滚了自己的订单记录,但库存服务那边已经扣掉的库存却没人管了。最后的结果是:订单没了,库存少了,钱没扣。这种“部分成功”的状态,就是分布式事务要解决的核心问题。
单体应用时代,一个数据库连接、一个本地事务就能保证ACID。但服务拆开之后,每个服务有自己的数据库,本地事务只能管住自己那一亩三分地,跨服务的操作失去了统一的协调者。你可以把分布式事务想象成几个人合伙凑钱买东西:每个人都从自己钱包里掏钱,但没有人统一记账,万一有人掏了钱有人没掏,这笔账就乱了。分布式事务框架要做的,就是找一个“记账人”,确保要么所有人都掏钱、要么所有人都不掏。
1.2 分布式事务的几种主流思路对比
在讲Seata之前,有必要把几种主流方案摆在一起对比一下,这样你才能理解Seata的设计取舍。业界常见的方案大致有这么几类:
| 方案类型 | 代表实现 | 一致性强度 | 业务侵入性 | 性能开销 | 适用场景 |
|---|---|---|---|---|---|
| 两阶段提交(2PC) | XA规范 | 强一致 | 低 | 高 | 传统关系型数据库跨库事务 |
| 补偿事务(TCC) | 自研或框架支持 | 最终一致 | 高 | 中 | 金融支付等对一致性要求高的场景 |
| 消息最终一致 | 消息队列 | 最终一致 | 中 | 低 | 异步解耦、允许延迟一致的场景 |
| 本地消息表 | 自研 | 最终一致 | 中 | 低 | 对实时性要求不高的业务 |
| AT模式 | Seata | 最终一致(默认) | 极低 | 中 | 大部分微服务业务场景 |
XA方案是最“正统”的2PC实现,数据库层面就支持,但它的问题在于资源锁定时间长,性能在高并发下很难看。TCC需要业务方自己写Try、Confirm、Cancel三个方法,侵入性太强,开发成本高。消息最终一致适合异步场景,但要做到可靠消息投递本身又是一堆工程问题。Seata的AT模式走的是另一条路:它在底层自动生成反向SQL来做补偿,业务方只需要加一个注解,几乎感知不到分布式事务的存在。这个“低侵入”的特性,是AT模式在业务系统里大受欢迎的根本原因。
1.3 Seata的整体架构:三个角色各司其职
Seata的架构里有三个核心角色,理解这三个角色是理解整个框架的前提。我用一个生活化的类比来说明:把分布式事务想象成一场需要多方签字的合同签署流程。
- TC(Transaction Coordinator,事务协调者):相当于“公证处”,独立部署的Server,负责维护全局事务和分支事务的状态,驱动全局提交或回滚。它是整个流程的中枢。
- TM(Transaction Manager,事务管理器):相当于“发起人”,通常就是业务代码里标注了
@GlobalTransactional的那个服务,负责定义全局事务的边界,向TC发起开启、提交、回滚全局事务的请求。 - RM(Resource Manager,资源管理器):相当于“各方签字人”,管理各个分支事务的资源(通常是数据库),负责向TC注册分支事务、汇报状态、执行提交或回滚。
一次完整的全局事务流程是这样的:TM向TC申请开启全局事务,拿到一个全局唯一的XID;这个XID会通过服务调用链路一路传递下去;每个参与的服务作为RM,拿着XID向TC注册自己的分支事务;所有分支执行完毕后,TM根据结果向TC发起全局提交或回滚;TC再逐个通知RM完成分支的提交或回滚。XID的传递是隐式的,Seata通过拦截器把它塞进调用上下文里,业务代码不需要手动传递。
2. AT模式的核心机制拆解
2.1 AT模式凭什么能做到低侵入
AT模式全称是Automatic Transaction,自动事务。它最吸引人的地方在于:业务方写代码的时候,跟写本地事务几乎没区别,只需要在发起方的方法上加一个@GlobalTransactional注解,参与方什么都不用改。那它是怎么做到的?答案藏在“数据源代理”这个设计里。
Seata会对你配置的数据源做一层代理(DataSourceProxy),所有经过这个数据源的SQL操作都会被拦截。当你在一个全局事务里执行一条UPDATE语句时,Seata在背后悄悄做了三件事:第一,解析这条SQL,找到它要操作的表和主键;第二,在更新之前,把这条记录更新前的样子查出来存一份(这叫before image,前置镜像);第三,在更新之后,再把记录更新后的样子查出来存一份(after image,后置镜像)。这两份镜像加上SQL的元信息,一起被写进一张叫undo_log的表里,和业务SQL在同一个本地事务中提交。
这个设计精妙的地方在于:业务SQL和undo_log的写入是原子性的,要么都成功要么都失败,这就保证了只要业务数据改了,回滚所需的信息一定在。等到全局事务需要回滚的时候,Seata读取undo_log里的before image,生成一条反向的SQL把数据改回去,同时校验after image和当前数据是否一致,防止覆盖掉别人的修改。整个过程业务代码完全无感,这就是“低侵入”的底层逻辑。
2.2 两阶段提交在AT模式里的具体形态
AT模式本质上还是两阶段提交,但它的阶段划分和传统XA不太一样。传统XA的第一阶段只是“准备”,资源一直锁着不释放;AT模式的第一阶段直接把业务SQL提交了,同时记下undo_log,资源锁在本地事务提交后就释放了。这个差异是AT模式性能优于XA的关键。
第一阶段(执行阶段):TM开启全局事务,各RM执行分支业务SQL,同时写undo_log,然后向TC注册分支并汇报执行成功。此时本地事务已经提交,数据对其他事务可见。
第二阶段(提交或回滚阶段):如果所有分支都成功,TM通知TC全局提交,TC异步通知各RM删除对应的undo_log即可,因为数据已经落库了,不需要再做什么。如果某个分支失败,TM通知TC全局回滚,TC通知各RM根据undo_log里的before image生成反向SQL执行回滚,回滚完成后再删除undo_log。
这里有个关键点:全局提交时,第二阶段只需要删日志,非常轻量,而且是异步的;全局回滚时,才需要真正执行补偿SQL。由于绝大多数事务都是成功的,这个设计让AT模式在正常路径上的额外开销很小。这也是为什么AT模式在互联网业务里能扛住较高并发的原因。
2.3 全局锁:解决脏写问题的关键设计
第一阶段本地事务提交后,数据就可见了,这带来一个隐患:在全局事务还没最终提交之前,如果有另一个事务修改了同一条记录,回滚的时候就会出问题。举个例子:全局事务A把账户余额从100改成80,本地提交了;这时候另一个本地事务B把余额从80改成50;然后全局事务A要回滚,它拿着before image(余额100)去恢复,结果把B的修改给覆盖了,B的50变成了100,数据就错了。
Seata用全局锁来解决这个问题。在执行阶段,RM在本地事务提交之前,需要先向TC申请这条记录(准确说是表名+主键)的全局锁。拿到锁之后才能提交本地事务。如果另一个全局事务想改同一条记录,申请全局锁时会被阻塞,直到前一个全局事务完成提交或回滚释放锁。这样就保证了在全局事务期间,同一条记录不会被其他全局事务修改。
但要注意,全局锁只能挡住其他全局事务,挡不住纯本地事务(不走Seata的普通SQL)。所以如果你的系统里有绕过Seata直接改库的操作,脏写风险依然存在。这一点在实际落地时经常被忽略,后面讲避坑的时候我会再展开。
2.4 undo_log表的结构与回滚校验逻辑
undo_log是AT模式的基石,它的表结构设计直接决定了回滚的可靠性。标准建表语句大致是这样的(以MySQL为例):
CREATE TABLE `undo_log` ( `branch_id` bigint NOT NULL COMMENT '分支事务ID', `xid` varchar(128) NOT NULL COMMENT '全局事务ID', `context` varchar(128) NOT NULL COMMENT '上下文,用于序列化校验等', `rollback_info` longblob NOT NULL COMMENT '回滚信息,含before/after image', `log_status` int NOT NULL COMMENT '状态:0正常,1全局已完成', `log_created` datetime(6) NOT NULL, `log_modified` datetime(6) NOT NULL, UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;rollback_info字段里存的是序列化后的镜像数据,包含表名、before image、after image以及SQL类型。回滚的时候,Seata会做一次校验:拿after image和数据库当前的数据比对,如果一致,说明这条记录在全局事务提交后没被别人改过,可以安全回滚;如果不一致,说明有并发修改,回滚就会失败并抛出异常。这个校验机制是防止脏写覆盖的最后一道防线。
注意:undo_log表必须在每个参与全局事务的业务库里都建一份,而且表名不能改。很多人第一次接入时只在自己以为的“主库”建了表,结果参与方回滚时报找不到undo_log,排查半天。
3. 从零搭建一套可运行的Seata环境
3.1 TC Server的部署与存储模式选择
Seata的TC Server(也就是seata-server)需要独立部署。部署方式有几种:直接下载发行包跑、用Docker跑、或者嵌入到应用里(不推荐生产用)。我一般用Docker,干净利落。但部署之前有个关键决策要做:TC的会话和全局事务状态存在哪里?
TC支持三种存储模式:file、db、redis。file模式最简单,状态存在本地文件里,重启就丢,只适合本地开发调试。db模式把状态存到数据库,支持集群部署,是生产环境的主流选择。redis模式性能好,但需要额外的可靠性保障。我的建议是:开发和测试环境用file模式图省事,生产环境老老实实用db模式,配合数据库的高可用。
db模式下需要先在数据库里建好Seata的元数据表,官方脚本里包含global_table、branch_table、lock_table、distributed_lock这几张表。global_table存全局事务,branch_table存分支事务,lock_table就是全局锁的落地表。建完表之后,修改seata-server的配置文件,把store.mode改成db,配好数据库连接信息,启动即可。
# file.conf 或 application.yml 中的关键配置 store.mode=db store.db.datasource=druid store.db.dbType=mysql store.db.driverClassName=com.mysql.cj.jdbc.Driver store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true store.db.user=seata store.db.password=seata1233.2 业务应用侧的接入配置
应用侧接入Seata,核心是配好两样东西:TC Server的地址(registry配置)和当前应用作为RM的身份信息(config配置)。现在主流是用注册中心(Nacos、Eureka等)来发现TC,这样TC集群扩容时应用不用改配置。如果图简单,也可以直接配直连地址。
应用侧还需要配置seata.tx-service-group,这个值要和TC端配置的service.vgroupMapping对应上。很多接入失败的问题都出在这里:应用配了一个group叫my_tx_group,TC端没配对应的映射,结果应用启动时找不到TC,报no available service。这个映射关系的作用是让应用知道该去哪个TC集群。
数据源方面,必须用Seata提供的DataSourceProxy包一层。如果你用Spring Boot的自动配置,引入seata-spring-boot-starter之后,它会自动帮你把数据源代理好,前提是你的数据源是通过标准方式配置的。如果你自己手动new了数据源,那就得手动包代理,否则SQL拦截不生效,undo_log也不会写,回滚时直接抓瞎。
seata: enabled: true application-id: order-service tx-service-group: my_tx_group service: vgroup-mapping: my_tx_group: default registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: seata3.3 一个最小可复现的跨服务事务示例
光说不练假把式,我搭一个最小的示例来演示。假设有两个服务:订单服务(order-service)和库存服务(stock-service),下单时需要同时写订单表和扣库存表。订单服务作为发起方,方法上加@GlobalTransactional。
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StockClient stockClient; @GlobalTransactional(name = "create-order", rollbackFor = Exception.class) public void createOrder(Order order) { // 本地分支:写订单 orderMapper.insert(order); // 远程分支:扣库存 stockClient.deduct(order.getProductId(), order.getQuantity()); // 模拟异常触发回滚 if (order.getQuantity() > 100) { throw new RuntimeException("库存扣减超限,触发回滚"); } } }库存服务的扣减方法不需要加任何分布式事务注解,它作为RM会自动被纳入全局事务,因为XID通过Feign调用传过来了。这里的关键是Feign拦截器要能传递XID,Seata提供了SeataFeignClient相关的自动配置,引入starter后基本开箱即用。跑起来之后,你可以故意让库存服务抛异常,观察订单表的数据是否被回滚、undo_log是否被正确清理,这样能直观感受到AT模式的工作过程。
4. 实际落地中的坑与排查手册
4.1 全局锁冲突导致的高并发性能问题
AT模式在低并发下很美好,但一旦热点数据集中,全局锁就会成为瓶颈。我遇到过一个典型场景:秒杀活动里,所有请求都在扣同一个商品的库存,全局锁串行化之后,吞吐量断崖式下跌,接口响应时间从几十毫秒飙到几秒。这不是Seata的bug,而是AT模式的固有特性——它为了保证可回滚,必须对热点行加锁。
应对思路有几种。第一,把热点数据拆分,比如库存分桶,把一行库存拆成多行,请求分散到不同行上,降低锁冲突概率。第二,对于这类强竞争场景,考虑改用TCC或者基于消息的最终一致方案,绕开全局锁。第三,如果业务上能接受,把库存扣减做成异步,用消息队列削峰,事务边界缩小。我的经验是:AT模式适合“写冲突不激烈”的常规业务,秒杀、抢购这类极端场景要单独设计,别指望一个注解解决所有问题。
4.2 回滚失败与脏写校验不通过的排查
回滚失败最常见的原因是after image校验不通过,报错信息通常是Branch rollback failed. reason: Dirty data detected之类。这说明在全局事务执行期间,有别的操作改了这条数据。排查方向有这么几个:一是检查有没有绕过Seata数据源代理的SQL,比如用了原生JDBC、或者某个ORM框架没走代理;二是检查有没有定时任务、数据修复脚本在直接改库;三是确认全局锁是否真的生效了,有时候配置问题会导致锁没加上。
还有一种情况是undo_log被提前删了。比如有人手动清理了undo_log表,或者某个分支的本地事务和undo_log写入不在同一个事务里(数据源代理没配对),导致回滚时找不到日志。这类问题的排查技巧是:先在出问题的库上查undo_log表里有没有对应xid的记录,再看TC的branch_table里分支状态是否正常,两头一对基本能定位。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
启动报no available service 'default' found | tx-service-group与TC端vgroupMapping不匹配 | 核对应用配置和TC配置的group名称是否一致 |
| 业务SQL执行了但undo_log没写 | 数据源未被Seata代理 | 检查是否引入starter、数据源是否被DataSourceProxy包装 |
| 回滚报脏数据检测失败 | 全局事务期间数据被其他事务修改 | 排查绕过代理的SQL、定时任务、手动改库操作 |
| 全局锁等待超时 | 热点行竞争激烈或存在长事务 | 优化热点拆分、缩短事务时长、检查是否有事务未正常结束 |
| TC重启后事务状态丢失 | 使用了file存储模式 | 生产环境切换为db存储模式 |
| 分支注册失败 | TC与RM网络不通或注册中心配置错误 | 检查网络连通性、注册中心地址和命名空间 |
4.4 几个容易被忽视的实操细节
第一个细节:@GlobalTransactional注解的rollbackFor一定要显式指定Exception.class。默认只回滚RuntimeException和Error,如果你的业务抛的是受检异常,全局事务不会回滚,这个坑我见过不止一次。
第二个细节:全局事务的粒度要控制。有些人图省事,把整个下单流程(包括发短信、写日志这些非核心操作)都包在一个全局事务里,导致事务链路特别长,锁持有时间久,性能差还容易超时。正确的做法是只把真正需要原子性的数据库操作纳入全局事务,非核心的旁路操作放到事务外或者异步处理。
第三个细节:TC Server的server.recovery相关配置要关注。TC会定期扫描超时的全局事务做补偿,如果这个扫描间隔配得太大,异常事务的清理会延迟;配得太小又增加数据库压力。默认值一般够用,但高并发场景下值得根据实际负载调一调。
提示:接入Seata之前,先想清楚你的业务到底需不需要强一致。很多场景用最终一致加对账补偿就够了,硬上分布式事务反而增加复杂度和故障面。技术选型要服务于业务,别为了用而用。
5. 和其他方案的配合与边界认知
5.1 AT模式不是万能药:明确它的适用边界
用了几年Seata,我最大的体会是:AT模式解决的是“常规业务里的跨库原子性”,它不是银弹。它适合那些写冲突不频繁、对性能要求不是极致、业务逻辑相对简单的场景。一旦遇到热点数据、长事务、或者需要和外部系统(比如第三方支付)交互,AT模式就会力不从心。
外部系统调用是AT模式的盲区。比如你在全局事务里调了第三方支付接口,支付成功了但后续本地事务回滚了,Seata没法帮你把支付撤回来,因为第三方系统不归它管。这种场景必须用TCC或者补偿机制,自己写正向和逆向操作。所以实际项目里,往往是AT、TCC、消息最终一致混着用,按业务特点选型,而不是一刀切。
5.2 与消息最终一致方案的组合使用
在很多实际架构里,Seata和消息队列是配合使用的。核心的、需要强原子性的数据库操作走Seata的AT模式;而那些可以异步、允许延迟一致的环节(比如发通知、更新统计、写日志),走消息队列。这样既保证了核心数据的一致性,又避免了把所有操作都塞进全局事务导致的性能问题。
组合的时候要注意事务消息的可靠性。如果用RocketMQ的事务消息,它能保证本地事务和消息发送的原子性;如果用普通消息,就得靠本地消息表来兜底。这块的工程细节比较多,核心思路是:消息一定要在本地事务提交成功之后才发,且要有重试和幂等机制,防止消息丢失或重复消费。
5.3 监控与运维:让分布式事务可观测
分布式事务一旦出问题,排查难度比本地事务大得多,所以可观测性建设必须跟上。至少要监控这几个指标:全局事务的成功率、平均耗时、回滚率;全局锁的等待时间和冲突次数;undo_log表的增长情况(如果只增不减,说明有事务没正常结束)。Seata本身提供了一些metrics,可以对接Prometheus和Grafana做看板。
另外,TC Server的日志要重点盯。全局事务的开启、提交、回滚都会在TC日志里留下痕迹,出问题时按xid去搜日志,能快速还原整个事务的生命周期。我习惯在应用日志里也把xid打出来(Seata的上下文里有RootContext.getXID()),这样应用日志和TC日志能通过xid关联起来,排查效率高很多。
我在实际项目里最大的感受是,分布式事务的复杂度不在于框架本身,而在于你对业务一致性边界的理解。Seata把技术细节封装得很好,但“哪些操作该纳入全局事务、哪些该异步、哪些该补偿”这些判断,框架替你做不了。踩过几次回滚失败的坑之后,我现在接入任何分布式事务方案前,都会先把业务的失败场景和补偿逻辑画清楚,再决定用哪种模式。这个习惯比记住多少配置项都管用。