☰
微服务分布式事务Seata:核心原理、AT模式与回滚实践
2026/10/21 15:32:52 网站建设 项目流程

服务拆开以后,“数据一致”这件事就从数据库单机问题变成了跨进程问题。这次花了两周时间把分布式事务中间件 Seat 从原理到落地完整啃了一遍,线上不敢直接上,先在本地搭了一套模拟链路把回滚逻辑跑通。这篇把核心概念、设计思路、实操步骤和排查经验都写出来,给准备接分布式事务或者正在看这块源码的同学一个参考。

开头先说明一个习惯:我在项目里把这个中间件统一叫 Seat,正式文档里大家通常按 Seata 来拼,这里用代号只是为了学习笔记里方便区分,不影响你看源码和配置。

1. 分布式事务学习:先搞清楚为什么需要 Seat 这个角色

想理解分布式事务,得先回到“事务为什么会失效”这个起点。单体时代,订单、库存、账户都在同一个数据库里,三步操作放进一个本地事务,要么全成功要么全回滚,数据库的 undo 日志能把中间状态擦干净。但微服务拆分后,订单服务、库存服务、账户服务各自连接不同的数据库,甚至用不同的数据库产品,原本由一个数据库管理的一整套逻辑,现在分散到多个物理资源上。此时没有任何一个数据库能看到全局状态,也没法替其他库做回滚。

1.1 本地事务解决不了的是跨资源协调

很多人第一次接触分布式事务时,会先想到“把多个 SQL 塞进同一个事务里不就行了”。在一个数据库连接里这么做没问题,但拆库之后,每个服务自己管理一套连接池,服务之间的 SQL 完全隔离。

举例:一个下单场景里,订单库先扣库存,再扣用户余额。扣库存成功、扣余额失败,此时订单库里已经写入了库存扣减记录,但账户库没有任何变化。如果直接在订单服务里抛异常回滚,只能回滚库存这条记录,无法通知账户服务做任何动作。更麻烦的是,两个库之间还有可能存在网络超时、服务重启等中间态,这种“半成功半失败”的状态需要靠一个专门的角色来协调。

Seat 这一类中间件给出的答案是:引入一个独立的全局事务协调者,它记录每个参与方的状态,并在某一方失败时对所有参与方执行补偿操作。这样就把“跨库不可控”转成了“由协调者统一调度”。

1.2 CAP、BASE 和“最终一致”的取舍

要理解 Seat 的设计,绕不开 CAP 理论。CAP 说的是分布式系统里一致性、可用性、分区容错性三者不可兼得。网络分区一定会发生,所以 P 必须保留,剩下的只能在 C 和 A 之间取舍。

传统数据库追求的是强一致,事务提交后立刻对所有读操作可见。分布式场景里如果强一致,就必须在写入时锁住所有节点,牺牲可用性。所以要选最终一致:允许一段时间内数据不一致,但通过补偿机制,最终所有节点都会回到一致状态。BASE 理论里的“软状态”和“最终一致”,就是 Seat 这类方案的基石。

理解了这个背景,再看 Seat 的工作模式就清楚很多。它做的事情可以概括为:用一个全局事务号串联所有参与分支,提交阶段尽量保证业务操作的原子性,失败阶段通过补偿把已经执行过的分支恢复原状。它不是为了消灭分布式问题,而是把分布式问题收敛到了协调者身上。

2. 核心设计思路拆解:Seat 的角色划分和三种事务模式

Seat 的整体设计非常清晰,核心就三个角色:事务协调器、事务管理器、资源管理器。把这三个角色理解透,看源码和配置都会顺畅很多。

2.1 三个角色各司其职:TC 管状态,TM 发指令,RM 管分支

事务协调器(TC)是全局事务的大脑,负责接收全局事务的注册、分支事务的注册,记录事务状态,并在全局提交或回滚时向各个分支下发指令。它本身不执行业务代码,只维护一张全局事务状态表。

事务管理器(TM)在业务方这一端,通常由全局事务发起方使用。发起方通过注解或 API 开启一个全局事务,拿到全局事务 XID,然后这个 XID 会通过调用链传递到各个下游服务。TM 负责告诉 TC 我这个事务准备提交还是回滚。

资源管理器(RM)是真正操作业务数据的角色。每一个参与全局事务的服务都是一个 RM,它管理自己所在数据库资源的分支事务,并向 TC 注册分支事务。RM 收到提交或回滚指令后,执行对应的资源操作。

为了便于记忆,可以把它们类比成一个项目团队:TM 是项目经理,负责启动项目和最终拍板;TC 是项目协调办公室,负责跟进所有任务状态;RM 是具体执行任务的人,每个任务的结果都要上报协调办公室。业务代码只需要在入口处标记“这是一个全局事务”,剩下的协调交给中间件。

2.2 AT 模式:全局锁加 undo_log 的补偿逻辑

AT 模式是 Seat 最常用、也是侵入性最低的模式。业务开发者不需要写复杂的补偿代码,只需要在调用入口加一个全局事务注解,再配置好数据源代理即可。

AT 模式的原理可以拆成两个阶段。第一阶段,执行本地业务 SQL 时,中间件会拦截 SQL,生成快照并记录到 undo_log 表,同时向 TC 注册分支事务。第二阶段,TC 收到全局提交指令后,各分支删除 undo_log 并释放全局锁;如果收到全局回滚指令,各分支根据 undo_log 里的前镜像数据逆向生成补偿 SQL,把数据恢复到执行前的状态。

这里的关键是全局锁。AT 模式在操作数据时,会对涉及的行加一个全局锁,防止多个全局事务同时修改同一行数据造成脏写。等等,我这个描述可以再补充一个细节:undo_log 表必须与业务表在同一个数据库里,回滚操作才能在本地事务中完成,保证了补偿本身的原子性。所以配置 AT 模式时,数据源代理是必须的,它负责在 SQL 执行前后自动写 undo_log。

AT 模式适合的场景是单体应用向微服务演进过程中的平滑过渡,尤其适合大多数 CRUD 操作,不需要业务代码配合改造。但它的代价是引入了全局锁,吞吐量会受一定影响,高并发场景需要评估全局锁等待时间。

2.3 TCC 模式:把“预留”从业务系统中真正立起来

TCC 模式对应的是 Try、Confirm、Cancel 三个阶段,是业务侵入性很强但控制力最强的模式。简单说,每个参与分支都要实现三个方法:预留资源、确认提交、补偿回滚。

TCC 的思路是:在业务开始前先做资源预留。比如扣减余额时,Try 阶段把余额从账户可用余额转到冻结余额;Confirm 阶段把冻结余额真正扣走;Cancel 阶段把冻结余额释放回可用余额。通过“冻结”这个中间状态,避免了直接扣减后再回滚的麻烦。

与 AT 模式相比,TCC 不需要 undo_log,也不依赖数据库全局锁,它对数据库资源的占用时间更短,更适合长事务或跨系统调用复杂的高并发场景。但缺点也明显:每个接入方都要维护三套逻辑,还要保证 Try、Confirm、Cancel 之间的幂等性。比如网络超时后,Confirm 可能被重复调用多次,必须保证第二次调用不会重复扣款。

初次接触 TCC 的同学,容易把“Try 里直接更新业务数据”当作预留。这是最常见的错误。Try 阶段如果直接把余额扣掉了,Cancel 阶段再去加回来,看起来也完成了补偿,但这样会导致两个问题:一是中间状态对业务方可见,用户会看到余额变少但又未完成支付;二是如果多个事务并发操作同一账户,没有冻结逻辑就会出现负数余额和脏写。所以 TCC 的核心不是“我加了 try 方法”,而是“预留状态是否真正隔离”。

2.4 Saga 与 XA 模式:长事务和强一致的边界场景

Saga 模式适合流程长、链路多的业务。它把一个全局事务拆成多个本地事务,每个本地事务完成后都更新状态,如果某个环节失败,则依次反向调用前面环节的补偿操作。Saga 没有全局锁,也不需要预留资源,因此对数据库压力很小,适合像订票、长途物流这类长时间运行且必须保证最终完成的业务。

XA 模式则是另一种极端,它依赖数据库原生的分布式事务协议。通过数据库连接层直接做两阶段提交,强一致,适合对一致性要求极高、性能压力不大的场景。但 XA 对数据库版本有要求,而且事务时间越长,锁定的资源越久,所以实际项目里用得不多。

对比来看:AT 适合快速接入,TCC 适合高并发强管控,Saga 适合长流程,XA 适合强一致低并发的特殊场景。没有哪个模式绝对最优,只有适不适合业务场景。刚开始学分布式事务时,建议先从 AT 模式入手,理解框架的补偿思想之后,再去看 TCC 和 Saga,否则容易被各种名词绕晕。

3. 实操复盘:用 Seat 跑通下单扣库存

理论看再多,不落地跑一遍总会觉得不踏实。我本地搭建了一套模拟链路,包含订单服务、库存服务、账户服务和一个 TC 服务,中间用 HTTP 调用串联,模拟下单并支付的过程。

3.1 搭建一整套能本地复现的模拟链路

我用的技术栈是 Spring Boot 加 MyBatis-Plus,数据库选择 MySQL,三张业务表分别存放在三个独立数据库中。订单表、库存表、账户表各自独立,模拟真实微服务的分库场景。

先在所有业务数据库里各建一张 undo_log 表,这是 AT 模式回滚的核心。建表语句可以参照官方文档,注意字段要包含 branch_id、xid、context、rollback_info 等。这一步漏了,启动时不会报错,但真正发生回滚时会报“找不到 undo_log 表”,非常坑。

然后在每个需要参与全局事务的服务里引入依赖,并把数据源配置改为代理数据源。核心配置如下:

seat: registry: type: file config: type: file tx-service-group: my_test_tx_group service: vgroup-mapping: my_test_tx_group: seat-server

这里配置了两个关键点:事务分组要统一,所有服务的 tx-service-group 名称保持一致;服务端注册地址要能被各个服务访问到。我本地直接把 TC 服务跑在默认端口,业务服务通过本地网络访问。

数据源代理也很关键。AT 模式必须让中间件拦截 SQL 才能生成 undo_log,所以要把原来的数据源包装一层代理。我用的是默认的代理方式,配置好后所有通过 MyBatis 执行的操作都会被接管。

3.2 关键代码与执行流程

全局事务入口放在订单服务的下单方法上:

@GlobalTransactional(name = "create-order", timeoutMills = 60000) public void createOrder(OrderRequest request) { orderMapper.insert(buildOrder(request)); stockClient.deductStock(request.getProductId(), request.getCount()); accountClient.deductBalance(request.getUserId(), request.getAmount()); }

三个服务的方法都是普通业务方法。启动订单服务后,调用下单接口会发现日志里出现了注册全局事务和注册分支事务的输出。这说明 TM 已经向 TC 注册了全局事务 XID,XID 也通过调用链传递到了库存服务和账户服务。

走查一下成功路径:库存扣减成功、余额扣减成功,全局事务正常提交,三个库的数据同时生效。从 TC 控制台看,事务状态会从 Begin 变到 Commit。这里要注意,提交不是三库同时提交,而是 TC 下发提交指令后各自异步执行,最终一致不等于同时一致。

3.3 失败路径:如何观察自动回滚

为了模拟失败,我故意在账户服务里抛了一个运行时异常。正常情况下订单服务会异常向上抛,此时 TM 收到失败信号后向 TC 发起全局回滚请求,TC 会给所有已注册分支发送回滚指令。

此时去数据库里看库存和订单,会发现数据已经恢复原状。中间最关键的是 undo_log 表不再是空表,里面记录了前镜像和后镜像。从日志可以看到,回滚阶段生成了反向 SQL,把刚插入的订单和扣减的库存一并撤销了。

这里有个值得注意的细节:回滚发生在每个分支的本地事务里,所以库存回滚和订单回滚是分库完成的,中间存在一个时间窗口,查询数据可能看到“订单回滚了但库存还没回滚”。这就是最终一致的表现。高可用场景下,如果担心这个中间窗口影响业务,可以配合状态机和定时对账来兜底,而不是指望中间件把“瞬时一致”也帮你搞定。

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

纸上谈兵容易,真跑起来还是会遇到不少坑。我把自己踩过的问题整理成一个速查表,每条都是真实的操作记录。

现象可能原因排查方向
全局锁等待超时两个事务同时更新同一行查 TC 日志里的全局锁持有时间,改小事务范围
回滚时报找不到 undo_log 表业务库没建 undo_log,或表名写错核对所有参与业务的数据库连接,不要只看一个库
数据源代理没生效配置了代理却仍用原生数据源确认连接工厂是否正确返回代理对象
服务间 XID 未传递自定义了 Feign 拦截器或 HTTP 头被覆盖调用链中检查是否存在 tx-service-group 和 XID
Confirm 重复执行导致数据翻倍TCC 接口没有做幂等控制在 Confirm 和 Cancel 里用事务状态表判断是否已处理

4.1 全局锁等待超时是最常见的性能瓶颈

AT 模式有一个全局锁的概念,作用是防止多个全局事务并发修改同一行。我在压测时模拟过十个线程同时扣减同一件商品库存,很快出现了锁等待超时异常。

核心原因是全局锁的持有时间很长。普通数据库行锁只在事务执行期间持有,全局锁则从第一阶段开始一直持有到第二阶段结束。如果全局事务内部调用了外部接口,锁的持有时间就是整个链路的时间。解决办法是把长事务拆短,避免在全局事务里做耗时的远程同步调用。比如下单链路可以拆分出“预下单”和“确认支付”两步,各走一个短事务,而不是在一个事务里做全部事情。

4.2 业务数据库没建 undo_log 表

AT 模式要求每个参与分支的数据库都必须有一张 undo_log 表。我刚开始只在订单库建了表,库存服务和账户服务都没建。结果测试正常,因为第一次测试的失败路径只回滚了订单数据,库存服务的回滚接口没有被触发。第二次模拟余额不足时,库存回滚直接报了错误。

排查过程比较曲折,因为报错信息埋在回滚日志里,不去翻数据库还真看不出来。所以建议上线前把建表脚本一次性下发到所有参与数据库,不要靠手工逐个建。

4.3 回滚后数据仍异常:幂等和脏写问题

AT 模式回滚依赖前镜像生成反向 SQL。如果业务代码在事务执行过程中修改了同一行数据,比如先查余额再做计算再更新,中间件对这条记录的前后镜像判断会失效。更麻烦的是,如果任意的分支在本地事务里提交了一部分数据,回滚时可能无法覆盖全部变更。

实际场景里,我会在代码层面尽量减少“先读再写”的复合操作,尽量用单条 SQL 完成更新。分布式事务解决的是跨库一致性,不是帮你在本地方法里兜底脏数据。这是理解中间件边界很重要的一点。

4.4 数据源代理与连接池兼容问题

很多项目用了自定义的数据源连接池,或者包装过层的多数据源组件。这类情况下 Seat 的数据源代理可能拦截不到真实 SQL,导致日志里看不到 undo_log 生成。有一次我排查了很久,发现是项目里自己定义了动态路由数据源,代理对象和目标对象没对上。

解决办法是梳理数据源创建链路,确保代理层包裹的是最底层的物理数据源,而不是包裹在路由这一层。另外要注意 Spring 事务管理器使用的数据源与 Seat 代理后的数据源必须是同一个对象,否则本地事务和全局事务会相互覆盖,出现“回滚了但数据没变”的诡异现象。

5. 踩过坑之后的一些个人体会

这次分布式事务学习,最大的收获不是记住了三个模式的名字,而是明白了中间件解决的是“协调问题”,不是“容错问题”。Seat 可以帮你把跨库回滚的流程自动化,但它不能替代业务侧的幂等设计、状态机设计和对账机制。真正的高可用方案,永远是多层次组合:业务状态机保证流程可控,幂等表挡住重复调用,定时对账兜底数据遗漏,中间件负责降低跨库操作的复杂度。

另外,刚开始学这类框架,不建议直接在生产环境试,更不建议一上来就接 TCC。先用 AT 模式把本地链路跑通,弄明白 undo_log 和全局锁的作用,再手动构造一次失败场景观察回滚日志,这个过程比刷十篇理论文章都管用。我后来排查线上疑问时,很多判断都来自这次本地实验建立的直觉。分布式事务的门槛不在概念,而在对异常路径的熟悉程度,多制造几种失败场景,你才能真正把框架用稳。

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

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

立即咨询