资讯动态

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

发布时间:2026/10/27 22:16:24 来源:尧图企业网站定制
分布式事务这个话题我在实际项目里踩过的坑比写过的代码还多。早些年做单体应用的时候一个本地事务注解就能搞定的事情拆成微服务之后突然变成了一门玄学——订单扣了库存没扣、库存扣了余额没扣、余额扣了积分没加数据对不上账的时候排查起来能把人逼疯。后来团队引入了Seata才算把这块硬骨头啃下来。这篇内容我想把Seata这套分布式事务的实现原理掰开揉碎讲清楚从它为什么这么设计、AT模式的核心机制、全局锁与回滚日志的配合方式到实际落地时的配置细节和踩坑记录都会涉及。适合正在做微服务拆分、被跨服务数据一致性折磨的后端同学也适合对分布式事务原理好奇、想搞明白“两阶段提交到底怎么落地”的开发者。看完你至少能搞清楚Seata的AT模式凭什么能做到对业务代码几乎零侵入以及它在哪些场景下会翻车。1. 先搞清楚分布式事务到底难在哪1.1 从一次真实的对账事故说起我印象特别深的一次事故是某电商系统大促期间的对账异常。用户下单后订单服务写入了订单记录紧接着调用库存服务扣减库存库存服务返回成功然后调用账户服务扣款——结果账户服务因为数据库连接池被打满超时了。订单服务拿到超时异常按照本地事务的逻辑回滚了自己的订单记录但库存服务那边已经扣掉的库存却没人管了。最后的结果是订单没了库存少了钱没扣。这种“部分成功”的状态就是分布式事务要解决的核心问题。单体应用时代一个数据库连接、一个本地事务就能保证ACID。但服务拆开之后每个服务有自己的数据库本地事务只能管住自己那一亩三分地跨服务的操作失去了统一的协调者。你可以把分布式事务想象成几个人合伙凑钱买东西每个人都从自己钱包里掏钱但没有人统一记账万一有人掏了钱有人没掏这笔账就乱了。分布式事务框架要做的就是找一个“记账人”确保要么所有人都掏钱、要么所有人都不掏。1.2 分布式事务的几种主流思路对比在讲Seata之前有必要把几种主流方案摆在一起对比一下这样你才能理解Seata的设计取舍。业界常见的方案大致有这么几类方案类型代表实现一致性强度业务侵入性性能开销适用场景两阶段提交2PCXA规范强一致低高传统关系型数据库跨库事务补偿事务TCC自研或框架支持最终一致高中金融支付等对一致性要求高的场景消息最终一致消息队列最终一致中低异步解耦、允许延迟一致的场景本地消息表自研最终一致中低对实时性要求不高的业务AT模式Seata最终一致默认极低中大部分微服务业务场景XA方案是最“正统”的2PC实现数据库层面就支持但它的问题在于资源锁定时间长性能在高并发下很难看。TCC需要业务方自己写Try、Confirm、Cancel三个方法侵入性太强开发成本高。消息最终一致适合异步场景但要做到可靠消息投递本身又是一堆工程问题。Seata的AT模式走的是另一条路它在底层自动生成反向SQL来做补偿业务方只需要加一个注解几乎感知不到分布式事务的存在。这个“低侵入”的特性是AT模式在业务系统里大受欢迎的根本原因。1.3 Seata的整体架构三个角色各司其职Seata的架构里有三个核心角色理解这三个角色是理解整个框架的前提。我用一个生活化的类比来说明把分布式事务想象成一场需要多方签字的合同签署流程。TCTransaction Coordinator事务协调者相当于“公证处”独立部署的Server负责维护全局事务和分支事务的状态驱动全局提交或回滚。它是整个流程的中枢。TMTransaction Manager事务管理器相当于“发起人”通常就是业务代码里标注了GlobalTransactional的那个服务负责定义全局事务的边界向TC发起开启、提交、回滚全局事务的请求。RMResource 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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.modedb store.db.datasourcedruid store.db.dbTypemysql store.db.driverClassNamecom.mysql.cj.jdbc.Driver store.db.urljdbc:mysql://127.0.0.1:3306/seata?useUnicodetruerewriteBatchedStatementstrue store.db.userseata store.db.passwordseata1233.2 业务应用侧的接入配置应用侧接入Seata核心是配好两样东西TC Server的地址registry配置和当前应用作为RM的身份信息config配置。现在主流是用注册中心Nacos、Eureka等来发现TC这样TC集群扩容时应用不用改配置。如果图简单也可以直接配直连地址。应用侧还需要配置seata.tx-service-group这个值要和TC端配置的service.vgroupMapping对应上。很多接入失败的问题都出在这里应用配了一个group叫my_tx_groupTC端没配对应的映射结果应用启动时找不到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拦截器要能传递XIDSeata提供了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 foundtx-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把技术细节封装得很好但“哪些操作该纳入全局事务、哪些该异步、哪些该补偿”这些判断框架替你做不了。踩过几次回滚失败的坑之后我现在接入任何分布式事务方案前都会先把业务的失败场景和补偿逻辑画清楚再决定用哪种模式。这个习惯比记住多少配置项都管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑