ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SEATA AT模式实战:分布式事务原理、配置与避坑指南

SEATA AT模式实战:分布式事务原理、配置与避坑指南

在微服务架构遍地走的今天,分布式事务一直是个绕不开的坎。数据库本地事务好办,ACID 四个特性背得滚瓜烂熟就能搞定;一旦拆了服务、拆了库,数据一致性就成了悬在头上的达摩克利斯之剑。我接触 SEATA 的 AT 模式也算比较早,从最初抱着试试看的心态,到后来在好几个项目里把它作为分布式事务的默认方案,中间踩了不少坑,也摸清了它的脾气。这篇文章我想把一个真实的 AT 模式使用心得完整记录下来,包括它的核心原理、落地步骤、配置细节,以及那些只有实际跑过才明白的坑和应对方式。如果你正准备在项目里引入分布式事务方案,或者已经在用 SEATA 但对其中的一些机制似懂非懂,这篇文章应该能帮你省下不少时间。

1. 内容整体设计与思路拆解

1.1 为什么偏偏选 AT 模式

先聊一个很多人都会问的问题:分布式事务方案那么多,消息队列、TCC、SAGA、本地消息表,各有各的拥趸,为什么最后还是选了 SEATA 的 AT 模式?

我的核心考量有三个。第一个是侵入性极低。这是 AT 模式最大的卖点——业务代码里不需要写补偿逻辑、不需要定义幂等接口、不需要手动维护事务状态机。我只需要在业务方法上加上@GlobalTransactional注解,然后把数据源换成 SEATA 代理过的数据源,剩下的本地 SQL 该怎么写还怎么写。对比 TCC 那种需要为每个操作写 Confirm 和 Cancel 两个方法的方案,AT 模式省掉的代码量是实打实的。

第二个是对老系统友好。我接手过好几个跑了四五年的老项目,数据库表结构已经固化,业务逻辑耦合严重,不太可能为了引入分布式事务专门去改表结构、加字段。AT 模式不需要业务表有任何额外字段,它依赖的是独立的undo_log表,相当于在旁边开了一个“草稿本”,完全不碰原始数据。这一点对于存量系统的改造尤其重要。

第三个是性能开销相对可控。TCC 的性能确实好,但开发和维护成本太高;SAGA 适合长流程,但事件溯源那套对团队要求很高。AT 模式的性能损耗主要集中在对业务 SQL 的解析、快照的生成和写入、以及全局锁的获取上。在大部分业务场景下,这些开销是可以接受的。当然,如果你的系统是那种单接口 TPS 要求上万的高并发场景,AT 模式可能不是最优解,这种时候该选 TCC 还是 TCC。

1.2 AT 模式想解决的核心问题

抛开宣传话术,AT 模式解决的本质问题就一个:多个服务各自操作自己的数据库,怎么保证要么全部成功,要么全部回滚。

举个例子,我在某模拟项目里做过一个订单系统,“创建订单”这个操作会同时调用订单服务、库存服务和账户服务。订单服务在自己的库里插入一条订单记录,库存服务扣减库存,账户服务扣减余额。三个操作分散在三个数据库里,任何一个失败,前面的操作都得撤销。

在单体时代这根本不是问题,一个本地事务包住三个 SQL 就行。但服务拆开之后,数据库也拆开了,本地事务管不了别人的库。这时候就需要一个“总导演”来协调三个服务的行为——这就是全局事务,而 AT 模式就是 SEATA 为全局事务提供的一种具体实现方案。

要理解 AT 模式的执行过程,得先理解它把一次全局事务分成了两个阶段:

  • 第一阶段(业务执行+分支事务提交):各个服务执行自己的本地业务 SQL,执行成功后就向全局事务管理器(TC)注册分支事务,并直接提交本地事务。这里的关键是“先提交”,不是等所有服务都执行完再一起提交。
  • 第二阶段(全局提交或全局回滚):全局事务管理器根据所有分支事务的结果做决策。如果都成功了,就异步删除各个分支事务的快照数据,完成全局提交。如果有任何一个分支失败,就通知所有成功的分支事务执行反向 SQL,把数据恢复原状,完成全局回滚。

很多人第一次听到“第一阶段就提交本地事务”会有点慌:万一后面有别的事务失败了怎么办?这时候回滚就需要依靠 AT 模式的核心武器——undo_log和全局锁来兜底。这两个东西的具体工作方式,我在下一节里细讲。

1.3 和其他模式的横向对比

为了说清楚 AT 模式的定位,我列了一个对比表,把主流的几种方案放在一起看:

模式侵入性性能一致性适用场景
AT(自动补偿)低,注解+数据源代理中等,有快照读写开销最终一致,带全局锁隔离中低并发、跨库事务、存量系统改造
TCC(手动补偿)高,每个操作需写 Try/Confirm/Cancel高,无额外数据操作强一致高并发、核心链路
SAGA(事件协调)中,需要定义补偿事件高,异步化最终一致长流程、多步骤业务
本地消息表中,需要维护消息表高,异步化最终一致跨服务最终一致、解耦场景

从这个表能看出来,AT 模式的定位非常明确:它牺牲了一部分性能,换来了极低的开发成本和入侵性。对于大部分业务系统,“开发效率和可维护性”往往比“极致性能”更重要,这也是 AT 模式能成为 SEATA 最流行模式的原因。

2. 核心机制拆解:一眼看穿 AT 模式的底层

2.1 三个角色:TC、TM 和 RM

SEATA 的架构里有三个角色,搞不清这三个角色的职责,后面写代码和排查问题都会很吃力:

  • TC(Transaction Coordinator,事务协调器):一个独立的服务,负责维护全局事务和分支事务的状态,驱动全局提交或回滚。在 SEATA 1.0 之后的版本里,TC 模块已经和 Server 端打包在一起,直接启动 Server 就是在启动一个 TC。它需要独立部署,所有参与者都要向它注册和上报状态。
  • TM(Transaction Manager,事务管理器):负责开启全局事务、提交或回滚全局事务。一般就在业务代码里,由@GlobalTransactional注解触发。
  • RM(Resource Manager,资源管理器):管理分支事务的资源,负责向 TC 注册分支事务、上报分支事务状态,并驱动分支事务的提交和回滚。数据源代理的底层实现就是 RM。

这三个角色之间的交互流程可以这样理解:TM 是“发起人”,在业务代码入口开启一个全局事务,拿到一个全局事务 ID(XID);这个 XID 会通过调用链传递到每个参与的服务。RM 每执行完一个分支操作,就向 TC 注册一个分支事务;TC 收集所有分支的状态,最后根据 TM 的指令决定全局提交还是全局回滚。

我在刚接触 SEATA 的时候,经常把 TC 和 TM 搞混,总觉得“协调器”不就是“管理器”吗。后来想了一个办法区分:TC 是“裁判”,TM 是“领队”。领队决定这场比赛(全局事务)要不要打、打完结果算不算数;裁判负责登记每个队员(分支事务)的表现,最后根据规则判定整场比赛的胜负。

2.2 第一阶段:先提交,但留一手

AT 模式的第一阶段设计,是整个方案的精妙之处。我拆开来看:

假设库存服务要执行一条扣减库存的 SQL:

UPDATE storage SET count = count - 50 WHERE id = 1;

普通执行,这行代码跑完就完事了。但在 AT 模式下,数据源代理会拦截这条 SQL,自动完成三件额外的事情:

  1. 解析 SQL,生成镜像数据:在执行 UPDATE 之前,先查询这条数据在当前状态下的值,生成前置镜像(before image);执行 UPDATE 之后,再查询变更后的值,生成后置镜像(after image)。
  2. 生成 undo_log 记录:把前置镜像和后置镜像一起拼装成一条 JSON,插入到undo_log表中,并记录全局事务 ID(XID)、分支事务 ID、SQL 类型等信息。
  3. 注册分支事务并获取全局锁:把这条分支事务注册到 TC,同时在目标数据行上获取一把全局锁,防止其他全局事务并发修改同一行数据。

这三件事完成之后,本地事务提交,UPDATE语句的变更就生效了。从外部看,这个分支事务的执行就是一个普通本地事务,只是“顺手”多写了一点日志。

这里有一个非常容易忽略的细节:前置镜像和后置镜像的生成顺序。SEATA 实际执行的顺序是,先把原始数据查出来作为前置镜像,然后执行业务 SQL,再查一次数据作为后置镜像。这两次查询之间,数据已经被业务 SQL 修改了,所以前置和后置才有差异。后续如果需要回滚,就用前置镜像数据反向 UPDATE 回去,同时校验后置镜像是否和当前数据一致,防止回滚时覆盖掉别人的修改。

2.3 第二阶段:回滚的魔法,全靠 undo_log

再来看回滚阶段发生了些什么。

当 TC 判定全局事务需要回滚时,会通知所有注册过的分支事务执行逆操作。分支事务拿到自己那一条undo_log记录,执行回滚逻辑的时候,主要做三件事:

  1. 数据对比:读取当前数据库中的实际数据和undo_log中的后置镜像做对比。如果一致,说明这条数据从分支事务提交后没有被其他人改过,可以放心回滚;如果不一致,说明出现了脏写,这种情况会触发异常,需要人工介入或者交给配置的重试机制处理。
  2. 执行反向 SQL:用前置镜像里的数据,生成反向 UPDATE 或 DELETE 语句,把数据恢复成分支事务开始之前的样子。
  3. 删除 undo_log 记录:回滚完成后,删除这条undo_log记录,避免日志表无限膨胀。

整个过程是不是很像我们平时手动写“回滚脚本”的做法?只是 SEATA 帮我们自动做了。这也是 AT 模式的本质——自动生成快照,自动执行补偿,把原本需要人工编码的补偿逻辑全部收编成框架能力。

我自己在研究这段源码的时候发现,回滚时的反向 SQL 生成规则并不复杂:如果是 UPDATE,就生成一条 UPDATE,把数据改成前置镜像里的值;如果是 DELETE,就生成一条 INSERT,把数据插回去;如果是 INSERT,就生成一条 DELETE,按主键删掉。规则简单但也有个前提——业务表必须要有主键,否则无法定位数据行,回滚也无从谈起。这一点我后面还会强调。

2.4 全局锁与隔离级别:AT 模式的“软肋”所在

全局锁是 AT 模式里最容易引发性能问题的点。我单独拿出来说明。

前面提到,分支事务提交前要获取全局锁。这把锁是 SEATA 在 TC 侧维护的,锁对象是“资源ID + 行数据主键”,也就是一把分布式行锁。它的作用是防止两个不同的全局事务同时修改同一行数据,导致脏写。

但这把锁有个特点——它不是数据库原生的行锁,而是 SEATA 自己实现的“应用层锁”。也就是说,一个本地事务在数据库层面已经拿到了行锁,但如果没有获取到全局锁,它仍然会阻塞等待。常见的表现就是:高并发场景下,多个全局事务同时尝试修改同一行数据,其中一个抢到了全局锁,其他的在等待;如果持有全局锁的事务迟迟不释放,后面的事务就会一直阻塞,直到超时。

全局锁的默认超时时间是在 Server 端配置的,单位是毫秒。超时之后,获取锁的请求会抛异常,全局事务会被标记为失败,触发回滚。在业务设计中,如果同一个热点数据被高频更新,AT 模式的全局锁可能会成为瓶颈。这时候我一般会做两件事:一是尽量缩短事务的持续时间,减少锁的持有时间;二是把热点数据的更新操作改为异步化或者串行化,避免多个事务同时竞争。

这里还要提一个 AT 模式的经典问题——隔离级别不足。AT 模式默认的隔离级别是“读已提交”级别,因为它允许分支事务先提交本地事务,其他事务在全局事务未完成之前就能读到这个中间状态的提交数据,这就产生了脏读的可能。官方文档里也承认这一点。要提升隔离级别,就得引入SELECT FOR UPDATE加全局锁,但这样性能开销会成倍增加。在实际项目中,我一般会从业务层面尽量避免读到其他全局事务还没提交完的数据,而不是依赖框架去兜底。

3. 实操过程与核心环节实现

3.1 环境准备:Server 部署与参数选择

要在项目里跑通 AT 模式,第一步是部署 SEATA Server。我的做法是这样的:

从官方仓库拉取对应版本的可执行包 解压后进入 conf 目录,打开 application.yml 配置事务分组、存储模式、Server 端口

存储模式我强烈建议使用数据库模式(db 模式)。SEATA Server 默认支持 file 模式(文件存储)和 db 模式(数据库存储)。文件模式配置简单,适合本地开发和测试;到了生产环境,如果 Server 挂了,事务状态数据全丢,全局事务就没法恢复了。所以生产环境必须用 db 模式,把全局事务的状态、会话信息持久化到数据库里。

db 模式需要准备三张表:global_table(全局事务表)、branch_table(分支事务表)、lock_table(全局锁表)。这三张表的建表 SQL,SEATA 官方仓库里有现成的脚本,直接执行就行。我第一次部署的时候犯过一个低级错误:只建了global_table和branch_table,漏了lock_table,结果一启动,注册分支事务的时候直接报表不存在,排查了半天才反应过来。

事务分组的配置是另一个关键点。在application.yml里,SEATA Server 要配置一个tx-service-group的映射关系,把这个分组名对应到具体的 TC 集群地址。客户端的application.yml或bootstrap.yml里也要配置相同的事务分组名,这样客户端才能找到 TC。这个分组名的配置,两端必须完全一致,否则客户端会报“无法找到服务”。我见过不少人在本地环境跑通了,一上测试环境就连不上 Server,最后发现是分组名大小写或者前后缀不一致导致的。

3.2 客户端改造:数据源代理与注解

客户端接 AT 模式的改动,核心就两块。

第一块是引入依赖。使用 Spring Boot 的场景,只需要在pom.xml或build.gradle里加上 SEATA 的 Spring Boot Starter,版本号和 Server 端保持一致。目前稳定版我推荐用 1.6.x 或更新的 1.7.x,太老的版本在 API 兼容性上可能有些小出入。

第二块是配置数据源代理。这一步是最容易踩坑的地方。AT 模式要求业务的数据源必须是 SEATA 的DataSourceProxy代理对象,否则 SQL 不会被拦截,前置后置镜像也不会自动生成。Spring Boot 场景下有两种做法:

第一种是在配置类里手动创建一个代理数据源:

@Bean public DataSource dataSource(DataSource hikariDataSource) { return new DataSourceProxy(hikariDataSource); }

第二种是使用 SEATA 提供的自动配置,只要引入依赖,然后在配置中心或application.yml里声明数据源相关配置,框架会自动完成代理。我个人的建议是:手动创建代理数据源,别偷懒。自动配置省事,但如果你用了多数据源、动态数据源,或者像ShardingSphere、MyBatis-Plus这种本身就对数据源做了一层包装的组件,自动代理的优先级和顺序很难控制,容易出问题。

第三块是加注解。在全局事务的入口方法上加上@GlobalTransactional:

@GlobalTransactional(name = "create-order-tx", timeoutMills = 300000) public void createOrder(OrderRequest request) { orderService.insertOrder(request); storageService.decrease(request.getProductId(), request.getCount()); accountService.decreaseBalance(request.getUserId(), request.getAmount()); }

这里要注意,@GlobalTransactional加在哪个方法上,决定了全局事务的边界。它通常加在“最外层”的调用方法上,也就是整个跨服务事务链路的入口。如果你把它加在一个内部方法上,而外部调用链已经有一个全局事务在跑,那这个内部方法就会成为一个子事务,嵌套在已有全局事务里。SEATA 默认会传播全局事务 ID(XID),所以这种嵌套在多数情况下是能正常工作的,但会增加不必要的分支事务注册和数据快照开销,我一般建议只在最外层加。

3.3 业务代码中的特殊处理:这些 SQL 要留意

AT 模式虽然对业务代码侵入小,但也不是所有 SQL 都能直接适配。这里列几个我实际工作中碰到过的特殊场景。

场景一:非主键条件更新的回滚问题。

假设有一条 SQL 是UPDATE account SET balance = balance - 100 WHERE user_level = 'VIP',这个操作可能一次更新了多行数据。AT 模式在生成前置镜像和后置镜像的时候,会把“SQL 影响的所有行”都记录下来,回滚时逐行恢复。这个机制本身没问题,但如果这个条件非常宽泛,一次更新了几千行数据,那镜像数据会非常庞大,undo_log表也会迅速膨胀,性能会明显下降。我的建议是,尽量让 SQL 走主键或者唯一索引,把影响行数控制在个位数。

场景二:批量操作。

批量插入或批量更新在 AT 模式下的性能开销也是翻倍的。每行数据都要记录镜像,虽然 SEATA 对批量操作做了优化,参数解析的效率已经提升了不少,但在业务允许的情况下,我还是会把大批量操作拆成小批次执行。比如一次插 1000 行,我会拆成 5 次、每次 200 行,既让事务粒度更细,也避免单次镜像数据量过大。

场景三:SELECT FOR UPDATE。

前面提到,为了提升隔离级别,可以在读取关键数据的时候用SELECT FOR UPDATE。但要知道,SELECT FOR UPDATE本身是数据库行锁,在 AT 模式下,如果当前用于只做了查询操作,没有后续的业务修改,那这个锁是纯数据库层面的,不受全局锁影响。如果你需要在全局事务里锁定一个资源供后续分支操作使用,可以在读取时加全局锁,具体做法是手动调用GlobalLockTemplate或者使用@GlobalLock注解。这两个工具用法不复杂,但我建议非必要不使用,因为它们会让本可以并发的操作变成串行执行。

3.4 多服务之间的 XID 传递

全局事务 ID 的传递是链路成功的关键。AT 模式跨服务时,XID 一般通过调用方传给被调方。SEATA 做了很多集成,像RestTemplate、OpenFeign、Dubbo、Spring Cloud Gateway,都有自动传递 XID 的适配器。理论上只要引入了对应模块,XID 就会自动通过 HTTP 头或 RPC 上下文传递。

但这里有一个非常隐蔽的坑:异步调用和消息队列会丢失 XID。如果你在全局事务里用@Async异步线程去调用别的服务,或者在事务里发了 MQ 消息给消费方,默认情况下 XID 不会自动传递过去。消费者那边如果也参与了事务,就会开启一个全新的全局事务,而不是加入原来的事务。这个问题我排查过好几次,现象往往是“单独调用服务A正常,串成一条链路就报事务找不到”,最后定位到就是 XID 没传。

解决方式有两种:一种是手动把 XID 传递到子线程或 MQ 消息头里,消费端再手动恢复上下文;另一种是尽量避免在全局事务中发起异步调用,让事务链路保持同步。如果业务上必须异步,那建议把这些操作移出全局事务,单独用其他一致性方案去兜底。

3.5 完整跑通一个全局事务:代码演示

为了让你能直观感受到 AT 模式的用法,我贴一段模拟订单系统的核心代码。假设有三个服务:订单服务(OrderService)、库存服务(StorageService)、账户服务(AccountService),分别操作三个库。

订单服务入口:

@Service public class OrderServiceImpl { @Autowired private StorageClient storageClient; @Autowired private AccountClient accountClient; @Autowired private OrderMapper orderMapper; @Override @GlobalTransactional(name = "create-order-global-tx", timeoutMills = 30000) public void createOrder(OrderEntity entity) { orderMapper.insert(entity); storageClient.decrease(entity.getProductId(), entity.getCount()); accountClient.decrease(entity.getUserId(), entity.getAmount()); } }

库存服务接口实现:

@Service public class StorageServiceImpl implements StorageService { @Autowired private StorageMapper storageMapper; @Override @Transactional public void decrease(int productId, int count) { storageMapper.decrease(productId, count); } }

账户服务接口实现:

@Service public class AccountServiceImpl implements AccountService { @Autowired private AccountMapper accountMapper; @Override @Transactional(rollbackFor = Exception.class) public void decrease(int userId, BigDecimal amount) { accountMapper.decrease(userId, amount); } }

三个服务的本地事务都是普通的@Transactional,只有订单服务入口加了@GlobalTransactional。这就是 AT 模式的典型落地方式——一个全局注解 + 三个普通本地事务,事务的协调和补偿全部交给框架。

这里有一个很容易被忽略的地方:分支服务里的本地事务,默认只回滚 RuntimeException,如果你的事务方法里抛的是受检异常(如Exception),需要显式设置rollbackFor = Exception.class。上面代码里我写上了这个参数,实际项目中见过多次因为这个参数没配,分支事务提交了但业务方法实际是失败的,导致全局数据不一致。

4. 常见问题与排查技巧实录

这一节是重头戏,我把实际跑 AT 模式过程中高频踩到的问题都整理出来,方便你直接对号入座。

4.1 全局锁等待超时和死锁

AT 模式在并发更新同一数据行的时候,容易出现全局锁等待超时。报错信息一般是这样的:“Global lock wait timeout”。

我遇到过一次印象特别深的情况:某个促销活动,用户大量购买同一个商品,库存服务的同一行数据被并发扣减,冲突十分激烈。结果就是大量请求报全局锁超时,订单创建失败率飙升。

排查思路大致是这样的:

  • 先看 TC 日志,确定是哪个分支事务获取不到锁;
  • 再查该资源对应的业务表,确认数据行 ID;
  • 最后查看是否有长时间未结束的全局事务,把锁占住了。

解决办法,我总结成三层:

  1. 缩短全局事务时间:把业务中耗时比较长的操作(比如远程调用、第三方接口等)移到事务之外,尽可能让事务在毫秒级完成。
  2. 控制并发粒度:对热点数据做更新时,用分布式锁或本地队列串行化操作,避免多个事务竞争同一行。例如扣库存可以改成预扣 + 异步确认,而不是每次实时扣减。
  3. 优化全局锁参数:调整全局锁等待时间,但这是个兜底方案,不建议上来就调大超时时间,否则大量请求堆积在锁等待上,系统吞吐反而下降。

4.2 回滚失败之“数据已被修改”

AT 模式的一个重要保障,是回滚前校验数据是否与后置镜像一致。但在某些场景下,这个校验会失败,导致回滚不成功。

最常见的场景是:分支事务提交后,有一个没有纳入全局事务的普通事务修改了同一行数据。因为普通事务不经过数据源代理,不识别全局锁,所以它可以直接修改。等到全局事务需要回滚时,发现当前数据已经不等于后置镜像,触发“脏写保护”,回滚失败。

这个问题想要根治,唯一的办法是确保所有访问这些表的数据源都经过 SEATA 代理。如果一个库被多个服务或任务访问,必须确保每一个入口都代理了数据源。如果做不到,那就得从业务层面避免非代理入口对这些表的修改。我在一个项目里就因为有个定时任务直连数据库批量更新数据,没有走代理,导致订单回滚频繁报错,排查了很久才发现这个定时任务在“背刺”。

4.3 undo_log 表膨胀问题

undo_log表的清理是很多人不够重视的。事务正常提交时,分支事务的undo_log记录会被异步删除;事务回滚时,回滚完也会删除。但如果 Server 异常重启、分支事务状态丢失,或者回滚失败,undo_log里的记录可能残留,表就会越来越大。

我的做法是加一个定时任务,定期清理status = 6(全局事务已完成)的残留日志,或者清理创建时间超过 N 天且状态不是“回滚中”的记录。具体 SQL 结合你的 SEATA 版本调整,思路大差不差。

4.4 事务不生效的排查清单

这是新手问题重灾区,我从经验里总结了一个速查表:

症状可能原因排查方向
全局事务注解没效果,异常不触发回滚入口方法没有被 Spring 代理,例如无 @Service 注解或同类内部方法自调用检查 Bean 是否由 Spring 管理;自调用需拆分或使用代理对象
报“no available service”客户端事务分组名和 Server 端不一致两端核对 tx-service-group 配置
数据源代理不生效,回滚无效没有把数据源包装成 DataSourceProxy,或者被其他组件覆盖检查数据源初始化顺序,手动创建 DataSourceProxy
分支事务注册失败Server 端数据库表未初始化检查 global_table、branch_table、lock_table 是否存在
本地事务回滚了但全局事务没回滚分支服务本地事务 rollbackFor 未指定,受检异常未触发回滚本地 @Transactional 显式设置 rollbackFor = Exception.class

4.5 性能排查:AT 模式到底慢在哪

如果把一个全局事务跑完,拆开看它的时间消耗,基本上大头在三块:

  1. SQL 解析与镜像生成,占比 30% 左右;
  2. undo_log 插入与全局锁注册,占比 40% 左右;
  3. 业务 SQL 本身的执行时间,占比 30% 左右,具体取决于数据库负载。

所以,AT 模式确实比单纯本地事务慢,这是它的固有开销,没必要回避。但只要业务 SQL 本身不是特别复杂,整体延迟一般能控制在可接受范围内。我测过一些普通的插入和更新操作,单分支事务的额外耗时大约在 5-15ms 之间,具体取决于机器性能和数据量。如果是批量更新上千行,额外耗时可能到几十毫秒甚至更多。

想优化性能,可以从这几个方向入手:

  • 减少分支事务数量:不要把粒度拆得太细,能一个服务内完成的本地事务就不要拆成多个分支。
  • 使用批量操作时,拆分批次:如前面所说,降低单次镜像数据量。
  • 关闭分支事务中不必要的 undo_log:SEATA 支持配置某些操作不记录快照(比如纯查询),但要注意这样做会让这些操作失去回滚能力。

5. 设计避坑与经验心得

5.1 设计阶段就要想清楚的四个问题

不要等代码写完再来考虑分布式事务,那样只能打补丁。我在项目设计阶段就会想清楚下面几个问题:

  • 一个全局事务里到底要包含几个服务?理想的粒度为 3 到 5 个以内。超过这个数,链路过长,事务等待时间成倍增加,失败率也上升,而且任一环节出问题,整个事务都要回滚,业务影响面会非常大。
  • 每一步业务操作是否具备回滚语义?如果某个接口本身是不可逆的(例如发送邮件、调用第三方支付接口),用 AT 模式去包裹它没有意义,反而会因为“回滚尴尬”引入新问题。这种操作更适合放到最后执行,或者用 SAGA 的补偿模式单独管理。
  • 热点数据是否会被高频更新?如果是,AT 模式要慎重。库存扣减、账户余额扣减这类高频写操作,使用 AT 模式在全局面锁上极易出现争抢,可能不如 TCC 模式或者限流方案。
  • 业务能接受最终一致还是必须强一致?AT 模式本身偏向最终一致,虽然在全局锁的辅助下能避免脏写,但中间状态的可见性还是存在。如果业务要求严格的隔离级别,AT 模式需要额外的全局读锁,这会让性能进一步下降。

5.2 我踩过的三个经典坑

第一个坑是异步线程里的事务失真。前面提过,异步线程不会自动传递 XID,导致全局事务实际上被切开了。我在某模拟项目中,订单创建之后需要通过一个异步任务去发送邮件,我为了省事直接在@GlobalTransactional方法里调用了异步方法,第二天就收到了线上告警:部分订单的账户余额扣减没有生效。排查了一晚上,发现就是因为异步线程丢失了全局事务上下文,用户余额那条数据在全局事务之外被另一个子事务操作了,触发了脏写校验失败,整个全局事务回滚,而订单那个分支竟然提交成功了。从那以后我给自己立了一条规矩:全局事务链路里不搞异步,除非你能保证 XID 正确传递且事务语义一致。

第二个坑是库存扣减的无脑套用。项目初期,我看库存扣减这么典型的写操作,顺手就全部纳入了全局事务。后来某个大促,QPS 冲到两千,TC 哪儿都慢,全局锁等待超时的告警刷屏。后来我看了下,扣减库存这种操作本质上是高频写,TCC 模式更适合。但当时代码已经写了不少,临时改造工作量也大。最终方案是给库存服务加了一个 Redis 预扣 + 异步扣库的缓冲层,把高频写挡在全局事务之外。踏过了这次之后,我对 AT 模式的适用边界有了更清醒的认知——它不是万能药,只在合适的场景下才有最优解。

第三个坑是TC 单点问题。SEATA Server 本身支持集群,但配置起来比单机稍微麻烦一些。我在早期部署的时候图省事,直接起了一个单机 Server。结果有一次 Server 磁盘满了,TC 直接不可用,所有依赖全局事务的接口全部报错,整个链路瘫痪了十几分钟。从那以后我强制要求:生产环境 SEATA Server 至少双节点,而且前置一个负载均衡,同时配置 db 模式存储,让事务状态可恢复。

5.3 监控和报警:这些事情要提前做

引入 SEATA 之后,监控是必须跟上的。我建议至少覆盖以下几个维度:

  • TC 所在机器的 CPU、内存、磁盘、网络,以及 JVM 的 GC 情况;
  • SEATA Server 的活跃全局事务数、分支事务数、全局锁等待时长、锁超时次数;
  • 每个业务表undo_log的数据量和残留记录数;
  • 业务接口依赖全局事务的调用成功率、事务平均耗时、回滚次数。

这些指标在腾讯云、阿里云等平台都有自带的监控工具可以采集,自己搭也行。我比较推荐在“事务耗时、锁等待时长、回滚数量”这三个指标上设置告警阈值,一旦突破就及时介入,不要等问题放大了才反应过来。

5.4 未来的演进思路:AT 模式和 SEATA 的其他模式组合使用

很多人觉得选了一种模式就得一条道走到黑。实际上,在一个复杂的系统里,不同业务场景完全可以同时使用不同的分布式事务方案。

我的建议是:核心链路、高频写入的场景,用 TCC;中低频、跨库操作、能接受最终一致的,用 AT 模式;长流程、事件驱动的,用 SAGA;能异步解耦的,优先上消息队列。SEATA 本身支持多种模式共存,你可以在同一个项目里引入多个模块,比如 TCC 模式的接口用@LocalTCC,AT 模式的方法用@GlobalTransactional,两者不冲突。我在现在的项目里就是这种混合模式,实际运行下来很稳,没有互相干扰的问题。

从长远看,分布式事务领域也在演进。比如 SEATA 在高版本中已经支持了更多的扩展点,让用户可以自定义分支事务的处理逻辑;云原生的环境中,Server 的部署、注册、发现也在逐渐向 K8s 和 Service Mesh 靠拢。这些变化不会撼动 AT 模式的地位,因为它的核心价值——极低侵入、自动补偿、场景普适——在很长一段时间内,依然是大多数业务系统的刚需。

我个人在实际使用中的体会是,AT 模式最舒服的地方不在于它有多炫酷,而在于“把复杂留给了框架,把简单留给了业务”。你只需要在关键入口加一个注解,剩下的交给它,不用整天提心吊胆写补偿代码。但同时,它也要求使用的人必须理解它的边界和机制,否则它也会以各种隐性的方式给你挖坑。如果你正打算在项目里引入分布式事务,我的建议是:先把这篇文章里的原理和坑过一遍,再结合自己的业务场景设计好事务边界和监控指标,然后再动手编码。会少走很多弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进