☰
Seata分布式事务实战:从环境搭建到AT/TCC模式接入与排错
2026/10/23 12:12:05 网站建设 项目流程

分布式事务这个话题,相关原理文章已经多到泛滥,但真正动起手来,很多人还是会在接入阶段就卡住一两天。我见过不少团队,理论讲得头头是道,一上 Seata 就倒在配置、版本、XID 传递这些细节上。这篇是系列第三篇,前两篇把 XA、TCC、消息事务这些基础理论都过了一遍,这篇不再重复概念,直接用 Seata 把一套下单流程从零落地走完整:环境怎么搭、四种模式怎么选、代码怎么接、踩过的坑怎么排。如果你在做订单、支付、库存这类强一致场景,或者正准备给微服务体系引入分布式事务方案,这篇应该能帮你少走不少弯路。

1. 先搞清楚:你的场景到底该不该上 Seata

很多人一听说分布式事务就条件反射地要上 Seata,但分布式事务不是银弹,先想清楚再动手,比急着写代码重要得多。微服务拆分之后,一次业务操作必然跨多个数据库,比如一个下单动作,订单库、库存库、账户库各自都要写数据。本地事务管不了跨库,数据一致性全靠业务层面自己兜,这就是分布式事务要解决的切口。

Seata 的核心价值是给业务方提供一个全局事务框架:事务发起方开启一个全局事务,所有参与方作为分支事务加入,最后由协调器统一决定全部提交还是全部回滚。它解决的是强一致性问题,也就是"要么全成,要么全败"的场景。而像积分发放、短信通知、日志上报这类最终一致就可以接受的流程,用本地消息表或者消息队列做补偿就行,没必要让它们挤进全局事务里,白白拉长事务时间。

再说直白点,适合上 Seata 的场景有几个特征:第一,数据不一致会直接造成资损或者严重业务事故,比如扣库存和扣余额必须同步成功;第二,流程链路不长,通常涉及两到五个微服务;第三,事务内操作以数据库写为主,没有太多耗时的外部调用。如果你的场景是长流程编排、需要人工介入审批这种,那更适合 SAGA 或者干脆不用全局事务,这一点在选型上要提前想清楚。

2. Seata 核心架构与模式选型

2.1 三个角色一次看懂

Seata 的架构不复杂,抓住三个角色就能理解全部机制。TC 是事务协调器,也就是独立部署的 Seata Server,它负责全局事务的开启、分支的注册和最终提交回滚的决策;TM 是事务管理器,通常就是业务入口那个服务,负责向 TC 申请开启全局事务、在业务结束后发起全局提交或回滚;RM 是资源管理器,每个参与分布式事务的服务都是一个 RM,它负责把自己那一分支的数据库操作注册给 TC,并执行协调器下发的提交或回滚指令。

整个协作流程是这样的:TM 调 TC 开启全局事务,TC 返回一个全局唯一 XID;XID 随着服务调用链一路传递到下游;每个 RM 在执行本地 SQL 时向 TC 注册分支事务;业务跑完后 TM 向 TC 发全局提交请求;TC 再挨个通知每个 RM 提交或回滚分支事务。理解了这条主链路,后面所有配置和排错都有方向。

2.2 AT、TCC、SAGA、XA 怎么选

Seata 一共提供四种事务模式,很多人会在这一关纠结很久。XA 模式最"正统",利用数据库原生事务能力,两阶段提交,但同步阻塞严重,性能损耗大,现在生产环境已经很少直接用了。SAGA 模式适合长事务,有点像编写逆向补偿流程,状态机编排灵活,但补偿逻辑全要手写,对业务侵入也不小。实际生产里用得最多的是 AT 和 TCC。

AT 模式是 Seata 的招牌,最大的优点是"无侵入"。业务代码不需要改 SQL、不需要拆接口,框架通过数据源代理自动记录数据变更前镜像和后镜像,回滚时就靠这些镜像数据反向补偿。代价是它会多一步写 undo_log,而且执行期间要持有全局锁,并发冲突明显的场景要慎重。TCC 模式则是把 Try、Confirm、Cancel 三个方法由你手写,业务可以在 Try 阶段做资源预留,控制和性能都更好,但你的接口设计和状态管理要额外扛两座山:幂等和空回滚。我的建议是,新增业务、以简单 CRUD 为主、并发冲突不严重,直接上 AT;涉及账户扣减、库存冻结这类资源预留型业务,或者并发要求高,优先考虑 TCC。

3. 环境准备:Seata Server 部署的关键决策

3.1 版本选型是第一道坎

Seata 的版本迭代蛮快,不同版本配置方式差异很大,服务端和客户端版本必须严格配套,不然会出现各种莫名其妙的分支注册失败。我这边用 1.6.1 和 1.7.x 都跑过生产,整体稳定。选版本时先看两件事:你的 Spring Boot / Spring Cloud 版本是否在官方兼容列表里;客户端和服务端是否同一个大版本。很多人在 1.4 时代照着旧博客配 registry.conf,拿到新版 Server 上完全不起作用,因为新版已经改用 application.yml 管理 server 端配置。先确认版本再找资料,能省下一天时间。

3.2 Server 端的两种部署形态

Seata Server 有两种典型部署方式。测试环境为了方便,可以直接单机 file 模式启动,事务状态存本地文件,注册中心也不用配;但生产环境强烈建议用 Nacos 做注册中心、MySQL 存事务状态。事务状态如果只存在本地文件里,Server 一重启,未完成的事务就全部丢掉了,这在生产上是不可接受的。用 MySQL 存储模式,TC 的全局会话和分支会话都会持久化到数据库,配合至少两个 Server 实例做高可用,才能保证全局事务协调不单点。

部署时先把 Server 的 conf 目录改清楚。核心配置有两块:注册中心部分配置 Nacos 地址和服务发现,存储部分配置 db 模式的连接串。启动命令类似 seata-server.sh -p 8091 -h 本机IP -m db,后面几个参数含义网上资料很多,我这里不展开,但提醒一点:-h 千万别配 127.0.0.1,客户端从注册中心拿到的如果是回环地址,跨机器调用就直接连不上,这个问题非常隐蔽。

3.3 客户端接入的最小配置

客户端接入其实就三步:引入依赖、配置事务分组和注册中心、确认数据源能被 Seata 接管。依赖一般用 seata-spring-boot-starter,注意版本与服务端一致。事务分组的逻辑是:客户端配置一个 tx-service-group,比如 my_test_tx_group,Server 端配置这个分组映射到哪个集群,客户端真正拿到的服务地址由 Server 端决定。这里最容易踩坑的是两边分组名不一致,启动时控制台直接报 No available service,排查半天注册中心,最后发现就是大小写或者下划线差了一个字符。

以下是一份可用的客户端 yml 模板:

seata: enabled: true application-id: order-service tx-service-group: my_test_tx_group registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: "" group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP

对应的 Server 端需要有这样的分组映射:

seata: service: vgroup-mapping: my_test_tx_group: default

注意:较早版本用 registry.conf 和 file.conf 配这些内容,如果你网上搜到的是这两个文件名的教程,先核对一下版本再照抄。

4. AT 模式实战:订单场景从零接入

4.1 业务场景与表结构设计

为了演示,我们就做一个最典型的"下单扣库存减余额"流程。三个服务分别是订单服务、库存服务、账户服务,各自连各自的库。订单服务的 createOrder 方法作为全局事务入口,它先写订单表,然后远程扣库存,最后远程扣用户余额,任何一个环节失败,前面写的数据都要回滚。这个场景如果你在自己项目里复刻,请把服务名、表名、字段名都替换成自己的。

三个业务库的表结构很简单,业务表之外每个库还必须额外建一张 undo_log 表,这是 AT 模式回滚的地基。漏建这张表,启动不报错,但一执行事务就会在分支注册阶段卡住或者回滚时报错,而且报错信息藏得比较深。表结构官方文档里有,直接复制即可:

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 AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

我每次接入新服务,第一步做的事就是先确认这个库有没有 undo_log 表,没有就先建,别等联调期再被它坑一顿。

4.2 事务入口与回滚边界

全局事务入口只需要加一个注解,这是 AT 模式对业务最友好的地方。入口方法的所属服务就是 TM,方法执行前 Seata 会向 Server 申请全局事务并生成 XID,方法正常返回就发起全局提交,方法抛出异常就发起全局回滚。这里有个关键习惯:注解上一定要写 rollbackFor = Exception.class,不要偷懒。默认情况下框架只对运行时异常回滚,如果你的业务代码抛了个受检异常,事务照样提交,数据就永远一致不起来了。

@Service public class OrderService { @Resource private StockClient stockClient; @Resource private AccountClient accountClient; @Resource private OrderMapper orderMapper; @GlobalTransactional(rollbackFor = Exception.class, timeoutMills = 30000) public void createOrder(OrderDTO order) { orderMapper.insert(order); stockClient.deduct(order.getProductId(), order.getCount()); accountClient.deduct(order.getUserId(), order.getAmount()); } }

看懂这段代码,也就看懂了全局事务的边界:它不是包住三个服务里的所有代码,而是以这个方法为起点、以 RPC 调用链为路径,把所有涉及的数据库操作纳入同一个全局事务。订单服务本地写订单表没什么难点,真正关键的是库存和账户两个服务的调用怎么把 XID 带过去。

4.3 XID 传递:全局事务的生命线

全局事务能不能成立,取决于 XID 能不能跟着调用链传到每一个 RM。用不同的服务通信方式,传递机制不一样。如果是 Dubbo,Seata 的集成包会自动通过隐式参数传递;如果是 Spring Cloud OpenFeign,需要自己配一个 RequestInterceptor 把 XID 塞进请求头,下游再取出来绑定到当前线程。引入 seata-spring-boot-starter 后,新版本通常会自带 Feign 和 WebMvc 的拦截器配置,但版本老一点就得手写,建议不管自带不自带,先看日志确认下游真的拿到了 XID,再继续往下做。

手写 Feign 拦截器很常规,核心代码就这几行:

@Configuration public class SeataFeignRequestInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { String xid = RootContext.getXID(); if (xid != null) { template.header(RootContext.KEY_XID, xid); } } }

下游服务需要在业务方法执行前把请求头里的 XID 绑定到当前线程,Seata 的 WebMvc 拦截器会做这件事。如果你用了 Spring Cloud Gateway,还需要额外配置全局过滤器做同样的转发,否则网关层就会把 XID 拦腰切断。排查分布式事务问题,第一件事永远是确认整条链路每个节点的日志里有没有同一个 XID,这个方法能筛掉一半的幻觉型 bug。

4.4 AT 模式在数据层到底做了什么

理解 AT 模式的数据层行为,排错会轻松很多。当服务进入分支事务,Seata 会代理你的数据源,执行每一条业务 SQL 之前,先查一次原始数据生成 before image,再执行 SQL,执行完再查一次生成 after image,这三部分打包写进当前事务同库的 undo_log 表。全局提交时,Seata 会把 undo_log 里的记录异步删掉;全局回滚时,则根据 before image 恢复数据,恢复前还会对比当前数据和 after image,如果发现不一致,说明事务期间数据又被别的地方改过,回滚会被判定为冲突,这也是 AT 模式"一边写一边并发写就难受"的根本原因。

所以 AT 模式适合的数据库操作一定是行级变更可追踪的、主键明确的,像那种不带 where 条件直接 update 大表的 SQL,会生成巨大的镜像数据,性能和空间都扛不住。我通常会在代码评审阶段就把这种 SQL 拦下来,要求必须带明确主键条件,这既是为了数据库本身,也是为 undo_log 的体积考虑。

5. TCC 模式实战:两个接口设计出三种事故

5.1 为什么 TCC 仍然不可替代

AT 模式再省事,也替代不了 TCC。AT 模式主要在数据层做文章,它并不知道你的业务语义,只是在物理数据上做镜像对比和恢复。但有些业务,比如电商的"冻结库存""预占余额",要求 Try 阶段就预留资源,Confirm 阶段真正扣减,Cancel 阶段释放预留。这种语义 AT 做不了,只能用 TCC 在业务层显式编码。另一个原因是性能和锁策略,AT 的全局锁粒度更粗,TCC 把资源固定在本地事务里,锁的持有时间可控得多,高并发场景优势明显。

5.2 一个标准的 TCC 接口长什么样

TCC 的核心是一个接口三个方法。以账户余额扣减为例,Try 方法做资源预留,就是把"可用余额"冻结一部分到"冻结余额"字段;Confirm 方法做真正的扣减,把冻结余额清掉、可用余额减少;Cancel 方法做释放,把冻结余额还原到可用余额。用注解把三个方法绑定在一起,Seata 才能在二阶段正确回调对应的方法。

@LocalTCC public interface AccountTccService { @TwoPhaseBusinessAction( name = "deductTcc", commitMethod = "confirmDeduct", rollbackMethod = "cancelDeduct" ) boolean deduct( BusinessActionContext context, @BusinessActionContextParameter(paramName = "userId") Long userId, @BusinessActionContextParameter(paramName = "amount") BigDecimal amount); boolean confirmDeduct(BusinessActionContext context); boolean cancelDeduct(BusinessActionContext context); }

实现类里最关键的不是正常流程,而是对异常路径的处理。Seata 在二阶段回调时可能因为网络等原因多次重试,Confirm 和 Cancel 必须幂等;Cancel 也可能被调用在 Try 还没执行的情况下,也就是空回滚问题;极端情况下 Cancel 先到了,Try 后到,这就是悬挂。这三个问题不在接口设计阶段处理好,上线后迟早出事故。

5.3 空回滚、幂等、悬挂三个坑怎么填

先说空回滚。一个分支事务在 Try 阶段没执行成功,比如网络超时、服务宕机,全局事务要回滚时就会调用 Cancel。这时候业务数据根本没有被 Try 动过,Cancel 如果直接去释放资源,就会产生错误的负数,所以 Cancel 第一步要先判断 Try 到底执行了没有。我的做法是给冻结记录一个事务状态字段,Try 执行前先落一条状态为"预备"的记录,Cancel 里发现没有记录或状态不对就直接返回成功。

再说幂等。二阶段回调没有做"最多一次"的保证,Cancel 可能被连续调多次,第一次已经把冻结余额释放了,第二次再释放就重复扣了。所以在 Confirm 和 Cancel 里都要先查状态,已经被处理过就直接返回。悬挂问题则是 Try 比 Cancel 晚到,Cancel 已经把事务标记为已回滚,Try 再执行就相当于凭空多出一个分支,解决方式是在 Try 的最前面检查是否已经存在回滚标记,有就直接跳过。

如果你觉得这三个问题听起来绕,我给你一个更直白的说法:TCC 的本质不是写三个方法,而是为你的资源状态机写一套完整的"状态迁移加防重逻辑"。状态字段至少要有"初始、预留、已提交、已回滚"四种,所有方法进门前先核对当前状态是否允许迁移。把这个状态机设计清楚,TCC 就成功了一大半。

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

Seata 的排错,最怕的不是错误报得怪,而是很多错误没有任何表面症状,数据就这么悄悄不一致了。所以先说一条铁律:接 Seata 的每个服务,日志级别至少要把 seata 相关包开到 info,线上保留全局事务开始、分支注册、二阶段提交回滚这几类关键日志。下面整理几个我实际工作中撞过的问题。

现象可能原因排查手段与解决办法
启动时报 No available service客户端事务分组与 Server 端 vgroup 映射不一致核对两边的 tx-service-group 和 vgroup-mapping,注意空格和大小写
调用链下游日志没有 XIDFeign/Gateway 拦截器缺失,或异步线程未绑定检查 RequestInterceptor 和网关过滤器,先确认服务间调用方式
数据库没有生成 undo_log代理数据源未生效或表名错误确认业务库已建 undo_log 表,查看 RM 分支注册日志
全局锁等待超时多个事务并发修改同一行,或事务跨度内耗时过长缩短全局事务耗时,调整锁重试次数与超时参数
回滚时出现 dirty data 校验失败分支事务期间数据被其他事务修改检查业务是否在全局事务外改了同一行数据,或者 before 镜像生成时机不当

除了表格里这些,还有一个高频故障值得单独说:seata-server 重启后,客户端所有新事务都失败,旧事务状态也容易丢失。这就是我前面说生产必须用 db 存储模式的原因。如果已经用了 file 模式,排查未完成事务非常痛苦,最省心的做法是尽快迁移到 db 模式,并给全局事务表做好备份。

还有一个我踩过好几次的坑是数据库驱动版本。Seata 的数据源代理会用到数据库元信息解析,不同驱动版本对某些 SQL 的解析行为有差异,尤其是 MySQL 8 的驱动和旧版本驱动在连接属性上容易出问题。接入失败时如果日志里出现 SQLException 或元信息相关报错,先检查驱动版本是否和 Seata 版本兼容列表匹配,再栽赃给业务代码。

7. 性能调优与生产建议

7.1 全局事务时长是性能的第一杀手

AT 模式下,全局事务从开启到提交或者回滚,每个分支事务的一阶段提交都是在等 TC 的统一指令,事务持有的资源锁时间被拉长了。多个并发用户同时操作用一行库存,后到的请求会一直等待全局锁释放,这比单库本地事务的锁等待时间指数级上升。所以性能调优的第一条准则,不是调参数,而是把全局事务的边界压缩到最小。能在事务外做的查询、预校验、数据组装,一律放外面;事务里只保留必要的写操作和 RPC。

7.2 锁顺序和并发强度的提前预判

如果多个服务在同一个全局事务里都要写数据,尽量让它们访问资源的顺序在所有事务里保持一致,否则容易出现交叉等待。比如所有下单流程都先扣库存、再扣余额、最后写订单,不要某个分支反过来写。另一个思路是控制并发量,依赖全局事务解决一致性的时候,业务本身就不太适合承担过高的并发峰值,必要的时候在入口做限流,比事后调一大堆锁参数有效得多。

7.3 别让无关业务挤进全局事务

我见过一个项目把短信通知、优惠券发放、用户积分这些边缘业务全塞进一个全局事务,理由是"保证一定成功"。结果下游短信服务偶尔超时,整个订单流程跟着回滚,用户被反复退款,业务投诉直接炸了。正确的做法是:只把真正要求强一致的写操作放进全局事务,边缘业务用消息队列异步补偿,让它的失败不影响主干。这个设计原则在选型那一节就提过,这里再说一遍是因为它真的决定你线上是安心还是天天救火。

再补充一点事务隔离级别相关的经验:Seata 的默认隔离级别是读已提交,AT 模式下通过全局锁实现写隔离,但未提交的数据在二阶段前是可能被看到的。如果你的业务对中间状态敏感,比如统计报表直接在事务执行期间去查了数据,看到的就是还没最终确定的结果。这种情况要么加缓存控制查询时机,要么接受最终一致的报表口径,别指望 AT 模式给你数据库隔离级别的全部保证。

最后聊一下扩展。这套 Seata 实战跑通之后,后面可以继续折腾的方向不少:比如把 seata-server 从单机升级成集群并接入生产环境告警,比如给 TCC 分支事务增加可视化监控,再比如把 AT 和 TCC 混合使用——订单主流程用 AT,账户余额这类热点资源单独用 TCC。我个人的体会是,分布式事务方案没有一劳永逸,每次业务形态变化,都可能需要对模式选择和事务边界重新做一次推演,这一篇给的接入路径和排错思路,是这套方法论里最核心的部分。至少我现在接到新项目,第一反应不是哪个框架好用,而是先按这套思路把场景、边界、模式定下来,后面基本不慌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询