做微服务久了,迟早会遇到分布式事务这个坎。跨服务调用链一长,订单创建要扣库存、加积分、写日志,任何一个环节挂了,账就对不上。Seata 就是专门用来解决这个问题的开源分布式事务框架,全称是 Simple Extensible Autonomous Transaction Architecture。我在生产环境里落地过几个项目,从最初的 AT 模式到后面的 TCC 模式都有实战。这篇就把 Seata 从安装到原理整条链路讲清楚,尤其是它内部那三个核心角色 TC、TM、RM 到底各自干什么、怎么协作,把网上那些零散的资料串成一套能直接落地的完整方案。
1. Seata 是什么,为什么我们需要它
1.1 分布式事务的痛点
先说说最常见的业务场景。你有一个订单服务、一个库存服务、一个积分服务,用户下单时订单服务调用库存服务扣减库存,再调用积分服务增加积分。传统的单机数据库事务只能保证自己库里的数据一致性,跨服务调用就是多个数据库之间的数据变更,没法用本地事务一把梭。要么引入基于两阶段提交的强一致性方案,要么直接用本地消息表做最终一致性。强一致性方案对性能影响大,而且很多 NoSQL 和消息中间件并不支持标准的 XA 协议。本地消息表方案又需要业务方写大量补偿代码,侵入性很强。
Seata 的出现就是要在这两种极端之间找一个平衡点。它用一个独立的协调者来管理全局事务,对业务代码侵入极小,尤其是 AT 模式,基本上只需要在业务方法上加一个注解,数据源交给 Seata 代理,就能获得分布式事务能力。对普通业务团队来说,这是上手成本最低的一条路。
1.2 三个关键角色:TC、TM、RM
理解 Seata 必须先弄懂它的三个核心角色。很多人第一次接触就被这几个缩写搞晕了,其实可以用一个生活化的类比来记:把一次分布式事务想象成一个公司项目。
TC(Transaction Coordinator)是事务协调者,相当于整个项目的总负责人,独立部署的服务端,负责维护全局事务的状态,给下面的执行者下发提交或回滚指令。TM(Transaction Manager)是事务管理器,相当于项目发起人,它存在于业务应用内部,负责向 TC 申请开启全局事务,在业务全部执行成功后通知 TC 做全局提交,执行失败则通知 TC 做全局回滚。RM(Resource Manager)是资源管理器,同样存在于业务应用内部,负责管理自己那部分数据库资源,向 TC 注册分支事务,执行 TC 下发的分支提交或回滚指令。
这三个角色的协作关系可以用一条线串起来:TM 先找 TC 开全局事务,拿到一个全局事务 ID(XID),XID 会通过调用链传播到各个服务,每个服务里的 RM 拿着这个 XID 向 TC 注册自己的分支事务。业务执行完后,TM 再找 TC 决断全局提交还是全局回滚,TC 通知所有 RM 各自执行分支的提交或回滚。整个过程很像项目经理分配任务、收集进度、最终拍板。
1.3 四种事务模式的定位
Seata 目前支持四种事务模式:AT、TCC、SAGA、XA。实际项目里最常用的是 AT 和 TCC。
AT 模式是 Seata 的招牌模式,基于全局锁和逆向 SQL 实现,对业务侵入最小,你不需要写额外的补偿逻辑,只需要让数据源被 Seata 代理,并在方法上加注解。代价是需要业务表附带一个 undo_log 日志表,并且要求数据库是支持事务的关系型数据库。TCC 模式把业务拆成 Try、Confirm、Cancel 三个阶段,需要业务方自己实现这三个接口,灵活性高,适合那些需要精细控制资源扣减时机的场景,比如账户资金冻结这类业务。SAGA 模式适合长事务,通过状态机编排流程,业务方需要定义每个步骤的补偿操作。XA 模式是最标准的强一致性方案,但需要数据库驱动支持 XA 协议,在 Seata 里用得相对少。
2. 服务端安装部署:让 TC 先跑起来
2.1 环境准备与下载
Seata 的 TC 是一个独立的 Java 服务端,安装前需要确认环境里有 JDK 8 或以上版本。到 Seata 官方 GitHub 的 Releases 页面下载 server 安装包即可,社区版都是开箱即用的压缩包。这里有个小建议:不要盲目追求最新版本,先看自己的业务客户端准备用哪个版本,然后让服务端版本和客户端版本保持基本一致。版本跨度太大容易出现协议不兼容,比如 Seata 1.5 和 1.6 之间的配置项写法就有差异,等到排查问题时会很痛苦。
下载完成后直接解压到安装目录,整个目录结构大致包含 bin 目录存放启动脚本,conf 目录存放配置文件,lib 目录存放依赖 jar 包。初次部署不用急着改代码,先搞清楚配置文件再动手。
2.2 修改 registry.conf:注册中心的接入
TC 启动后要被客户端找到,协调方式就是注册中心。Seata 支持 Nacos、Consul、Eureka、ZooKeeper 等多种注册中心,生产环境我推荐用 Nacos,因为大部分微服务项目本来就已经接入了 Nacos,复用现有设施成本最低。打开 conf 目录下的 registry.conf 文件,里面有两个核心配置块:registry 和 config。
registry 配置块决定 TC 如何对外注册自己。如果是 Nacos,核心配置如下:
registry { type = "nacos" nacos { application = "seata-server" serverAddr = "127.0.0.1:8848" group = "SEATA_GROUP" namespace = "" cluster = "default" username = "nacos" password = "nacos" } }config 配置块决定 TC 自身的配置从哪里读取,可以选择从文件读取,也可以从 Nacos 配置中心读取。本地单机测试用 file 最省事,生产环境建议用 nacos 配置中心,方便统一修改全局配置。上面示例里的 username 和 password 需要根据你实际 Nacos 的账号密码来改,如果 Nacos 没有开鉴权,可以留空或删掉这两项。
2.3 修改 file.conf:存储方式与事务组配置
如果 config 配的是 file 模式,TC 会读取 conf 目录下的 file.conf 文件。这个文件里最重要的是 store 配置,决定事务日志存哪里。默认是 file 模式,把事务日志写到本地磁盘文件里。生产环境强烈建议改成 db 模式,把事务日志存到数据库里,否则 TC 重启后临时事务状态可能丢失,而且多实例部署时文件模式无法共享状态。
db 模式需要配置数据库连接:
store { mode = "db" db { datasource = "druid" dbType = "mysql" driverClassName = "com.mysql.cj.jdbc.Driver" url = "jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai" user = "seata" password = "seata" minConn = 5 maxConn = 20 globalTable = "global_table" branchTable = "branch_table" lockTable = "lock_table" queryLimit = 100 } }注意 Seata 1.5 之后的版本开始逐步用 application.yml 替代 file.conf,目录里可能直接就是 application.yml。无论哪种形式,核心配置项逻辑一致。如果你的项目里还有旧教程的影子,不要照抄,先确认版本。
2.4 初始化全局事务表
db 存储模式必须在数据库里创建三张表:global_table、branch_table、lock_table,分别记录全局事务、分支事务和全局锁。Seata 安装包里的 script 目录下自带建表 SQL,直接执行即可。MySQL 的脚本在 script/server/db/mysql.sql 下面。注意这里有个容易忽略的问题:这三张表的字符集和排序规则要跟业务库保持一致,尤其是之前遇到过表里中文乱码的情况,后来发现是建表脚本里默认字符集和业务库不一致导致的。
执行建表 SQL 后可以用简单查询验证一下表是否建好,比如SHOW TABLES;看是否出现 global_table 等三张表。表结构本身不复杂,主要就是事务 ID、状态、创建时间这些字段。
2.5 启动 TC 并验证
配置完成后启动就简单了。Linux 环境进到 bin 目录,执行sh seata-server.sh。Windows 环境执行seata-server.bat。启动参数里可以用 -p 指定端口,默认是 8091。个人习惯加 -h 参数指定对外 IP,比如sh seata-server.sh -p 8091 -h 192.168.1.10,否则在多网卡环境下客户端可能拿到错误的地址。
验证 TC 是否启动成功,最直接的方式是看日志,出现Server started字样就说明启动成功。如果配置了 Nacos 注册中心,可以登录 Nacos 控制台,在服务列表里找到 seata-server 对应的服务,确认在线。这一步走通了,服务端就绪。
3. 客户端接入:TM 与 RM 的落地
3.1 引入依赖与版本对应
TC 部署完成后,剩下的工作都在业务应用里做。首先引入 Seata 的客户端依赖。如果是 Spring Boot 项目,最常用的是spring-cloud-starter-alibaba-seata或者seata-spring-boot-starter,具体看项目用的技术栈。如果是 Spring Cloud Alibaba 体系,推荐前者,它已经帮你管理好了版本对应关系。如果是纯 Spring Boot 项目,用io.seata:seata-spring-boot-starter,版本号和服务端保持一致。
这里要提醒一个常见的坑:Seata 客户端依赖里有时会传递引入一些老旧的类库,比如旧版 Druid 或者旧版 netty,有可能跟项目里现有的依赖冲突。碰到启动报 ClassNotFoundException 或 NoSuchMethodError 时,先用 mvn dependency:tree 查一下依赖冲突,而不是第一时间怀疑配置写错。
3.2 客户端配置与数据源代理
客户端要告诉 TM 和 RM 去哪找 TC,需要在 application.yml 里加 Seata 的配置。核心配置包括 registry 类型、服务端地址、事务组名称等。事务组这个概念的引入,是为了让客户端不直接绑定一个具体的 TC 地址,而是先找到事务组,再由事务组映射到具体的 TC 集群。配置示例如下:
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 application: seata-server group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP service: vgroup-mapping: my_test_tx_group: default事务组的名字可以自己定,但 vgroup-mapping 里的映射关系必须和 TC 端的 cluster 名称对应上,比如 TC 的 cluster 是 default,这里就要映射到 default。很多项目启动时报could not found service错误,八成就是这里映射没对齐。
AT 模式下还必须要做数据源代理。因为 Seata 要在本地事务执行前后记录 undo_log 和获取全局锁,它必须接管你的数据源。如果你的项目里用的是官方 starter,并且配置了seata.enabled: true,它会自动代理数据源。但如果项目里自己配了多数据源或者动态数据源,必须手动把数据源包装成DataSourceProxy。最简单的做法是通过配置类完成:
@Configuration public class SeataDataSourceConfig { @Bean @Primary public DataSource dataSource(DataSource originalDataSource) { return new DataSourceProxy(originalDataSource); } }这里有个细节:DataSourceProxy 包装之后,原来基于这个数据源做的 MyBatis 插件、分库分表组件、连接池监控等都要重新验证一遍。我曾经遇到一个项目,包装数据源后连接池监控失效,排查了很久才发现监控组件拿的是原生 DataSource 的 Bean,没有走代理。
3.3 使用 @GlobalTransactional 开启全局事务
业务侧使用 Seata 非常简单,在需要开启全局事务的方法上加@GlobalTransactional注解即可。这个注解的作用就是让 TM 向 TC 发起全局事务开启请求,并把事务上下文绑定到当前线程。
下面是一个典型的调用示例。订单服务创建订单后,调用库存服务扣库存,调用积分服务加积分,这三个操作合在一起构成一个全局事务:
@Service public class OrderService { @Autowired private StockFeignClient stockFeignClient; @Autowired private PointFeignClient pointFeignClient; @GlobalTransactional(name = "create-order", timeoutMills = 30000) public void createOrder(OrderDTO order) { orderDao.insert(order); stockFeignClient.deduct(order.getProductId(), order.getCount()); pointFeignClient.add(order.getUserId(), order.getPoint()); } }调用链上所有参与方必须有同一个 XID 才能加入同一个全局事务。Seata 通过拦截器自动把 XID 放到 RPC 调用的 Header 或附加字段里传递。如果你在调用链里混用了非 Seata 支持的非 HTTP 通信方式,比如自研 TCP 协议,就需要手动传递 XID。具体做法是先把 XID 从根服务取出来,透传到下游后手动绑定上下文。
3.4 端到端验证
客户端配置完成后,启动业务应用,观察日志里是否出现类似于begin global transaction的日志。接着调用一次业务接口,然后去 TC 日志或者控制台观察全局事务是否被正确记录。一个快速验证方案是故意让下游服务抛异常,看上游数据是否会回滚。比如在扣库存成功后让加积分操作失败,检查订单和库存是否都被回滚。注意这里必须在数据库客户端里实际查一下数据,只看日志还不够,因为日志里可能显示回滚成功,但由于示例代码逻辑问题实际上没走到回滚分支。
4. Seata 的工作原理:一次全局事务的完整旅程
4.1 两个阶段到底在做什么
Seata 的设计核心是两阶段提交思想,但它跟传统的 XA 两阶段提交有本质区别。XA 的一阶段是资源管理器准备并锁定资源,二阶段才真正提交;Seata 的 AT 模式一阶段就直接把本地事务提交了,二阶段根据全局事务的结果决定补一次提交还是反向补偿。这样做的好处是大幅缩短了资源锁定的时间,性能更好,也更贴合微服务场景下高并发的需求。
理解这个差异很重要。很多人在用 Seata 时总担心一阶段提交了是不是就不安全了,其实安全感来自二阶段的补偿机制和全局锁的配合。下面把两个阶段拆开细看。
4.2 一阶段:RM 注册分支并执行本地事务
一阶段的流程是这样:TM 调用 TC 开启全局事务,拿到 XID 后请求进入业务代码。订单服务执行本地 SQL 时,RM 通过代理数据源拦截到这条 SQL,做三件事:
第一,解析 SQL,生成前置镜像(before image)和后置镜像(after image)。前置镜像就是执行前查询一遍受影响的数据行,后置镜像是执行后查询一遍。这两个镜像会按行组织,存在 undo_log 表里。第二,在同一个本地事务里执行业务 SQL 并插入 undo_log 记录。第三,向 TC 注册分支事务,拿到分支事务 ID,然后发送本地事务提交指令。这里的关键点是业务 SQL 和 undo_log 的插入必须在同一个本地事务里,否则镜像数据和业务数据对不上。
一批所有参与方的本地事务都提交成功后,RM 会向 TC 上报分支执行成功。此时所有本地数据已经改完了,但全局锁还占着,也就是说别的事务在这些数据上的写操作会被阻塞,直到全局事务最终决断。
4.3 二阶段:TC 决断后的提交与回滚
二阶段由 TC 发起。所有分支都成功,TM 通知 TC 全局提交,TC 通知各 RM 删掉 undo_log 记录并释放全局锁。因为数据已经改了,提交分支的处理非常轻量,只需要删日志、放锁。如果任何一个分支失败或超时,TM 通知 TC 全局回滚,TC 再通知各 RM 执行反向补偿,根据 undo_log 里的前置镜像把数据改回去。补偿动作也是在一个本地事务里完成的,同样需要先修改数据,再删除对应的 undo_log。
回滚的核心逻辑是逆向 SQL。RM 拿到 undo_log 里的前后镜像后,对比当前数据是否跟后置镜像一致。如果一致,说明数据没有被别的分支修改过,可以安全地用前置镜像覆盖回去;如果不一致,说明发生了脏读或数据冲突,这时候 Seata 就会记录异常并抛出异常,由人工介入处理。
4.4 AT 模式的自动回滚实现
提供一段伪代码来展示二阶段回滚的核心判断逻辑:
public void branchRollback(UndoLog undoLog) { // 读取当前数据库中的最新数据 List<RowRecord> currentRows = queryCurrentRows(undoLog.getTableName(), undoLog.getPkValues()); // 比对当前数据是否等于 undo_log 中的后置镜像 boolean dataMatches = compareRows(currentRows, undoLog.getAfterImage()); if (dataMatches) { // 数据未被其他事务改动,使用前置镜像恢复 executeReverseSql(undoLog.getBeforeImage()); deleteUndoLog(undoLog.getBranchId()); } else { // 数据已被其他事务改变,需要人工介入 throw new BranchRollbackException("data has been modified by another transaction"); } }这个判断逻辑是全局锁策略的直接体现。因为全局锁在二阶段之前一直持有,理论上正常提交的事务不会出现数据被并发修改的情况。一旦出现 after image 对不上,就说明存在绕过全局锁的写操作,比如直接改了数据库,这是使用 Seata 时特别需要注意的纪律问题。
4.5 全局锁如何避免脏读
全局锁是 Seata 实现隔离的关键。举一个具体的例子:订单服务扣减库存时,库存服务里的那条库存记录的全局锁就被锁住了。此时另一个事务也想扣减同一商品库存,它就会受阻,直到前一个全局事务最终提交并释放锁。这样保证了多服务同时改同一行数据时不会互相覆盖。
但全局锁也带来一个副作用:在高并发场景下,热点数据容易成为瓶颈。比如秒杀商品的库存记录,所有扣减都集中在同一行,即使本地事务已经提交,全局锁依然要等到二阶段结束才能释放,这跟本地数据库的行锁释放时机不一样。做过 Seata 性能优化的人应该都懂,热点扣减要用 TCC 模式做资源预扣减,或者通过库存分片把热点行打散,否则 AT 模式在超高并发下容易积累锁等待。
5. 常见问题与排查技巧实录
5.1 连接不上 TC 怎么办
客户端日志里出现endpoint format error或者connect timed out时,先按以下顺序排查:
第一步,检查客户端配置的 registry 类型和服务端实际使用的一致。Nacos 里能看到服务列表不代表客户端就能正常连接,还有可能在客户端的 vgroup-mapping 里把事务组映射错了 cluster。第二步,检查网络连通性,在客户端机器上直接 telnet TC 的端口,确认防火墙没有拦截。第三步,检查客户端是否引入了正确版本的 seata 依赖,很多链接问题其实是客户端和服务端协议版本不一致导致的。
5.2 undo_log 表的常见问题
undo_log 表建错是最常见的问题。Seata 要求每个参与全局事务的数据库里都必须有 undo_log 表,而且表名和字段名必须完全符合规范。字段包括 branch_id、xid、context、rollback_info、log_status、log_created、log_modified 等。如果业务库的表缺列或者字段长度不对,一阶段就会直接报错。
还有一个隐蔽的问题:业务表中没有主键,或者主键不是单一列而是复合主键。Seata 生成前后镜像时依赖主键定位数据行,如果表没有主键,SQL 解析会直接抛异常。解决办法很简单,给业务表加上唯一主键,哪怕这个主键没有业务含义,只是为了 Seata 能用。
5.3 性能与超时调优
事务超时是最容易踩的坑。默认的全局事务超时时间是 60 秒,如果你一个全局事务里包含了慢 SQL、外部接口调用、文件上传等耗时操作,很有可能整体时间超过超时阈值。这时候 TM 会被告知超时,TC 强制触发回滚,但业务线程可能还在继续执行,导致数据已经被改回来,但业务代码还在往下跑,最终出现很难判断的脏数据。
我的经验是:先估算业务链路的最坏耗时,再在超时阈值上留 20% 的余量。但也不要无脑加大超时,超时时间越长,全局锁占用越久,并发能力越差。真正要解决的是接口性能本身,定位到链路中耗时最高的服务,去做本地缓存、异步化等优化。
5.4 踩坑经验汇总
列出几个我实际踩过的坑:
第一,分库分表场景下不要直接套 AT 模式,Seata 的 SQL 解析对分表后的表名处理不完整,后续镜像定位容易出错。分库分表加强一致事务建议用 TCC 或者干脆选别的方案。第二,幂等性问题。下游服务接口如果没有实现幂等,全局事务回滚后重试时可能产生重复数据。Seata 本身只负责分布式事务的一致性,不负责接口层面的幂等,这需要业务方自己用唯一键约束或去重表做保护。第三,开发环境中经常有人直接连生产环境的 TC,导致生产环境的全局事务表被开发数据污染。规范一点的团队应该把环境隔离做干净,开发、测试、生产部署单独的 TC 实例,或者至少在 Nacos 里做好命名空间隔离。
6. 从安装到落地的最后一条建议
如果要把 Seata 落地到正式项目,我个人比较推荐先在一个旁路业务上试点,比如积分、日志这类不涉及核心收入的系统。用它能真实感受到分布式事务解决的问题,也会暴露不少只能在实践中发现的问题,但不会一上来就把核心交易链路搞崩。
还有一个小技巧:上线初期设置一个监控面板,把 TC 的全局事务成功率、分支事务数量、锁等待时长这几个指标盯住。分布式事务的问题往往潜伏在流量高峰期,事前监控比事后排查有用得多。等你把 Seata 的脾性摸透了,再逐步推广到订单、支付这类核心链路也不迟。