1. 项目概述:一个让我又爱又恨的分布式事务方案
做微服务拆库之后,最头疼的问题之一就是跨服务的数据一致性。下单要扣库存、扣余额、写订单,三个服务三张表,本地事务各自提交,中间任何一个环节挂了,数据就对不上了。以前靠定时任务对账补数据,靠人工跑脚本修数据,说实话每次上线都得捏一把汗。
这个项目的核心,是用Seata的AT模式把跨服务事务统一管起来。简单说,AT模式就是在不侵入业务代码的前提下,把多个服务的本地事务串成一个全局事务,底层靠两阶段提交、全局锁和undo_log快照来保证要么全成功、要么全回滚。它解决的核心问题就是:当业务跨库、跨服务时,如何让数据最终保持一致,而不是天天靠人肉对账。
这篇文章写给谁呢?已经上了微服务但还没有分布式事务方案的同学,正在技术选型、纠结Seata的AT模式和TCC哪个更好的同学,以及已经用了Seata但遇到各种诡异问题、想深入了解原理方便排查的同学。我会把AT模式的执行流程、底层机制、实操配置和一些坑,从源码角度和实际排障角度都拆一遍。
先说一个比较反直觉的点:AT模式能做到业务代码无侵入,是因为它入侵了你的SQL执行链路。理解了这个本质,后面的全局锁、undo_log、回滚判断这些概念就不难了。
2. 分布式事务的前世今生:为什么本地事务搞不定微服务
2.1 单库事务的舒适区与拆库后的失控
在单体系统里,事务很简单。一个@Transactional注解,数据库的ACID帮你搞定一切:要么全提交,要么全回滚,并发安全由数据库的锁机制保证。但是当业务量上来、系统拆成订单服务、库存服务、账户服务,各用各的库之后,这个舒适区就没了。
你的操作可能是这样:订单库插入订单,账户库扣余额,库存库减库存。三个操作分别在三个数据库连接里执行,每个库各自有本地事务,但库与库之间没有事务协调机制。一旦订单插入成功、余额扣减完成、但库存减失败,前面两个操作是感知不到的,它们已经提交了。订单显示待支付,余额少了钱,库存没减,这就是典型的数据不一致。
2.2 XA的局限与AT模式的差异
有人会说有XA协议啊,两阶段提交很成熟。XA的问题在于它是数据库层级的同步阻塞协议,事务期间所有参与者的资源全部锁住,高并发场景下性能衰减极为明显,而且对数据库的支持要求高,很多中间件和云数据库不兼容。
Seata的AT模式也走两阶段提交的路线,但它做了一个很重要的改变:一阶段就直接提交本地事务,把变成回滚的“后悔药”提前存好,二阶段只是异步清理或反向补偿。这就把事务锁的持有时间从整个全局事务周期,压缩到完成SQL执行和快照写入的那一刻,并发能力提升非常明显。
关键差异整理如下:
| 对比项 | XA | Seata AT |
|---|---|---|
| 一阶段行为 | 只执行不提交,资源锁定 | 执行业务SQL并提交本地事务 |
| 全局锁 | 由数据库资源锁承担 | 由Seata的全局锁管理器统一控制 |
| 回滚方式 | 数据库binlog/事务日志 | 用undo_log快照反向补偿SQL |
| 业务侵入 | 低,但依赖数据库XA支持 | 几乎为零,注解即可 |
| 性能损耗 | 事务期间高,锁等待严重 | 相对较低,增加少量SQL开销 |
3. AT模式的核心机制拆解
3.1 一阶段:业务SQL执行与undo_log快照生成
先看一张执行时序的角度来看,AT模式的一阶段做了哪些事。
- 全局事务发起方在业务方法上加
@GlobalTransactional,事务管理器向TC注册全局事务,拿到全局事务ID。 - 业务SQL通过Seata增强过的数据源执行。比如执行
UPDATE storage SET count = count - 1 WHERE sku_id = 100,Seata会先解析这条SQL,把涉及行的原始数据和修改后数据分别保存为undo_log的前镜像和后镜像,和业务SQL放在同一个本地事务里提交。 - 分支事务完成一阶段提交后,向TC注册分支,并获取全局锁。这里注意,全局锁和本地事务锁都是在这里取的,顺序是先拿本地锁、做镜像、再尝试拿全局锁、最后提交本地事务。
- 拿不到全局锁怎么办?比如同一行数据被另一个全局事务改过,连接会自旋等待,超过阈值直接抛异常回滚本地事务。
一阶段提交完,数据对外已经是新值了,但undo_log里面保留了修复到旧值的完整信息。这个设计非常像增量备份:每次变更先把旧值存档,出问题再用存档回放。分支事务已经提交,不用长时间持有数据库连接,数据库的压力就释放了。
3.2 二阶段提交:异步清理undo_log
如果全局事务中所有分支都成功了,二阶段提交就很简单:TC通知各个分支事务管理器,删除各自的undo_log即可。这个阶段是异步的,即使删除失败,后续有重试机制,不影响业务数据本身。
这里有个很实用的小知识:全局事务成功后数据就已经是最终值了,undo_log只是临时存档,删与不删对的业务结果没有影响。所以哪怕你看undo_log里积压了一些没删掉的数据,只要查全局事务状态是"已提交",那就是安全的,可以等补偿任务慢慢清。
3.3 二阶段回滚:镜像比对与反向补偿
这是AT模式最核心的部分,也是最容易在理解上出偏差的地方。TC通知回滚后,分支事务处理流程是:
- 根据全局事务ID找到对应的分支事务和undo_log。
- 解析undo_log,拿到前镜像数据。
- 执行一个校验SQL:用前镜像的字段值作为WHERE条件去更新当前数据,如果影响行数为0或和预期的后镜像不一致,说明这行数据在分支事务提交之后又被其他事务修改过了,不能直接回滚,否则会覆盖别人的修改。
- 校验通过后,用前镜像数据生成反向UPDATE语句,把数据改回原值。
- 删除undo_log,向TC上报回滚完成。
这个"校验后镜像"的步骤非常关键。我在实际排查中见过很多次脏数据问题,最后定位都是在这个校验环节被绕过或实现有缺陷。正常流程下没人能篡改数据,但在异常双写、补偿任务和多服务并发场景下,这个校验就是最后一道防线。
4. 全局锁与隔离级别的真实面目
4.1 写隔离:两阶段锁在分布式环境下的实现
Seata AT模式默认的隔离级别是读未提交。这个说法一出来很多人就紧张,但实际没那么可怕,因为它的写隔离做得相当严。AT模式的写隔离原理是:
- 事务A操作某行数据时,TM会向TC申请该行数据的全局锁,锁住的是一段主键ID范围。
- 事务B也要改同一行,全局锁拿不到,会阻塞等待,不会直接写进去。
- 只有当全局事务A提交或回滚完成,锁释放,事务B才能继续。
需要注意一个区别:本地行锁和全局锁是两套体系。本地事务提交时数据库行锁会释放,但全局锁还持有。这个时间窗口内如果其他事务尝试修改同一行,会等全局锁,而不是直接改。所以在高并发下可能看到的现象是:数据库层面没有锁等待,但应用层在报全局锁冲突超时。这不是Bug,是AT模式在隔离并发写。
4.2 读隔离与脏读场景应对
由于一阶段本地事务已经提交,其他事务在全局事务未提交时是可以读到未提交的数据的,这就是"读未提交"的含义。Seata官方给的默认读级别也就是读已提交要靠业务SQL加SELECT FOR UPDATE来实现。加了之后,读操作也会去竞争全局锁,从而避免读到中间态数据。
实际生产里,多数业务场景可以接受读未提交或通过FOR UPDATE解决关键读。真要追求非常严格的读隔离,建议直接用TCC模式或业务层面做幂等等方案。我在某个项目里就遇到过一个定时任务扫数据,读到某个全局事务还没结束的脏值,导致做了错误的后续操作。后来解法就是在关键查询SQL上加了FOR UPDATE,问题就消失了。
4.3 全局锁的存储与性能损耗
全局锁是存在TC端的内存里的,TC重启就丢了,所以会有锁丢失风险。为了解决这个问题,Seata 1.5+版本引入了全局锁持久化可配置能力,可选存到数据库。代价是每次加锁释放都要多一次DB操作,性能有所下降。小规模业务用内存锁问题不大,但交易核心系统建议开持久化,避免TC重启窗口期的数据异常。
5. 实操落地:一步步把AT模式跑起来
5.1 环境准备与版本选择
我先后在多个Spring Cloud Alibaba版本组合里用过Seata,最稳的组合还是参考官方版本矩阵。我这里用一套较普及的组合作为示例:
- Spring Boot 2.x
- Spring Cloud Alibaba 2021.x
- Seata 1.6.x或1.7.x
- Nacos 2.x做配置中心和注册中心
- MySQL 8.x
需要下载并启动seata-server。Seata 1.6以后,file.conf、registry.conf合并成了单一的application.yml,这个变化容易在旧教程误导下踩坑。配置里最核心的两点是:注册中心和配置中心都连上Nacos,存储模式用db,把全局事务会话和全局锁存到数据库里。
一个典型的application.yml配置段如下:
seata: registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 group: SEATA_GROUP namespace: "" cluster: default username: nacos password: nacos config: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: "" group: SEATA_GROUP username: nacos password: nacos store: mode: db db: datasource: druid db-type: mysql url: jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&characterEncoding=utf8 driver-class-name: com.mysql.cj.jdbc.Driver user: root password: 你的密码 min-conn: 5 max-conn: 100 global-table: global_table branch-table: branch_table lock-table: lock_table query-limit: 1005.2 业务库建undo_log表
每个参与全局事务的业务库都要建undo_log表,这是回滚机制的数据基础。表结构官方脚本在各版本都有提供,核心字段包括branch_id、xid、context、rollback_info、log_status、log_created、log_modified。
CREATE TABLE IF NOT EXISTS `undo_log` ( `branch_id` BIGINT NOT NULL COMMENT 'branch transaction id', `xid` VARCHAR(128) NOT NULL COMMENT 'global transaction id', `context` VARCHAR(128) NOT NULL COMMENT 'undo log context,such as serialization', `rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info', `log_status` INT NOT NULL COMMENT '0:normal status,1:defense status', `log_created` DATETIME NOT NULL COMMENT 'create datetime', `log_modified` DATETIME NOT NULL COMMENT 'modify datetime', UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) ) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';我见过有同学把undo_log表只建在了一个库里,结果另一个服务回滚时疯狂报表不存在的错误。每个库都要建,不要在公共库里集中建。
5.3 客户端依赖与数据源代理配置
客户端每个服务要引入依赖。以Maven为例:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> </dependency>配置中心确认客户端能找到对应的配置。数据源代理是关键,Seata自动配置类会尝试用DataSourceProxy包装你的数据源。如果业务代码里自己手动构建了数据源,或者用的连接池比较特殊,一定要确保它被Seata代理,否则你的事务不会生效。
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/order_db?useUnicode=true&characterEncoding=utf8&useSSL=false username: root password: 你的密码5.4 使用@GlobalTransactional开启全局事务
在分布式事务发起方的业务方法上加上注解即可,其他服务的调用和普通Feign接口没有差别。以一个模拟的跨服务下单场景为例:
@Service public class OrderService { @Autowired private StockClient stockClient; @Autowired private AccountClient accountClient; @GlobalTransactional(name = "create-order", timeoutMills = 30000) public void createOrder(OrderDTO order) { // 本地订单表插入 orderMapper.insert(order); // 远程扣库存 stockClient.deductStock(order.getSkuId(), order.getCount()); // 远程扣余额 accountClient.deductBalance(order.getUserId(), order.getAmount()); } }stockClient和accountClient是Feign客户端,分别调用库存服务和账户服务。它们各自方法上加普通@Transactional即可,Seata会自动把连接绑定到当前全局事务里。注意timeoutMills的粒度是全局事务整体超时时间,不是单个分支。
5.5 验证回滚机制是否生效
跑完之后怎么确认AT模式真的生效了?分享几个亲测有效的验证手段:
- 在扣库存服务里人为抛一个运行时异常,观察订单库是否回滚。正常情况下订单插入操作被反向补偿掉,数据库查不到这条订单。
- 看seata-server日志,会有
begin new global transaction、branch register success、global rollback success之类的关键日志。 - 查看undo_log表,在事务执行过程中应该能查到前镜像、后镜像数据,全局事务结束后这条记录被删除。
- 用TC的运维界面或API查全局事务状态,如果最终状态是
Rollbacked或Committed,说明流程正常。
6. 实际运行中遇到的问题与排查思路
6.1 全局锁冲突导致的大量超时
现象是应用日志频繁出现Global lock acquire failed或类似超时异常,接口响应偶尔变慢。原因通常是多个全局事务并发操作同一行数据,后操作的事务等待前事务释放全局锁超时。排查时先区分是偶发还是持续,偶发多是因为某次长事务阻塞;持续则要看业务是否存在热点行更新。
遇到这个问题的优化思路有几个:缩短全局事务的耗时,SQL尽量精准命中索引,减少锁范围;热点数据在业务上做拆分或排队;适当调大锁重试等待参数。当时我们把库存扣减从按仓库维度改为按分区维度拆分,热点冲突一下就降下来了。
6.2 回滚时下发的SQL没有正确执行
回滚时镜像数据不对,会出现SQL执行了但影响行数为0的情况,最终回滚失败。排查方向一般分两类:并发覆盖、代理失效。
并发覆盖指的是我前面说的,一阶段提交后有其他本地事务改了数据,导致后镜像校验不通过。这个本质上是隔离级别不够造成的,只能从业务串行化或提前规避下手。代理失效指的是Seata的原理是通过代理数据源切走SQL,如果某些框架内部直接拿原始连接执行SQL,Seata就无从感知,回滚逻辑自然出错。排查时重点看数据源是否被DataSourceProxy包裹,以及是否有自定义连接池配置覆盖了Seata的逻辑。
6.3 TC频繁重启导致全局锁丢失
TC重启,内存里的全局锁就没了。如果业务量不大,重启瞬间的窗口期可能影响不显著;但在节假日大促场景下,流量一大,这个问题就是定时炸弹。建议生产环境打开全局锁数据库存储模式。当时在某个大促前,我们做了完整的TC故障演练,发现锁丢失后的数据异常居然需要靠人工补偿脚本才能恢复,于是果断上线了lock-table持久化方案。
6.4 与部分ORM框架或连接池的组合坑
某些ORM框架的二级缓存或批量操作会绕过Seata的SQL解析,导致分支注册成功但回滚时找不到完整镜像。遇到这类问题,第一选择是排查是否在同一个事务里执行了批量写、存储过程或DDL操作,AT模式对这类操作支持度有限的。如果必须用,建议将操作拆分为单条SQL,或者在业务层面控制。
6.5 一个容易被忽略的版本兼容性问题
Spring Cloud Alibaba版本和Seata版本不匹配,会出现分支事务注册不上、TC地址连不上、注解不生效等问题。这类问题不会报什么响亮的名字,通常是一些底层类缺失或方法签名对不上。我的习惯是直接用官方版本矩阵里推荐的最新稳定组合,不追新、不落伍,优先选已经被大量生产环境验证过的组合。
| 问题现象 | 原因分类 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 全局锁获取失败超时 | 并发冲突 | 看日志中冲突SQL与表 | 缩短事务耗时、拆分热点、调大重试 |
| 回滚SQL影响0行 | 镜像校验失败/连接绕过 | 对比undo_log镜像与当前数据 | 业务串行化或调整隔离策略 |
| TC重启后回滚异常 | 全局锁存储在内存 | 检查store模式配置 | 开启db存储模式 |
| 注解不生效 | 依赖版本不匹配 | 查版本矩阵、启动日志 | 统一Spring Cloud Alibaba与Seata版本 |
7. 实际体验与适用场景边界
用AT模式这几年,最大的感悟是它把分布式事务的入门门槛降到了很低。业务代码几乎零改造,注解一加,回滚自动完成,这在以前是不敢想的。但门槛低不等于没有成本,恰恰因为底层自动化程度高,出了问题排查的复杂度反而上去了。镜像比对、全局锁、分支注册,每个环节都可能有隐蔽的坑。
我的建议是:在核心交易链路、对一致性要求极高的场景,用AT模式是非常合适的,尤其是跨服务但并发量可控的业务。而如果单事务涉及的数据行数大、热点冲突剧烈、性能要求极高,或者你的SQL种类特别复杂(存储过程、大批量update和delete),就需要把AT模式放到备选位,优先评估TCC或Saga,或者干脆用业务补偿机制。
最后分享一个经验:接任何分布式事务框架之前,先问自己两个问题——这个场景真的需要分布式事务吗?能不能通过业务上最终一致、消息重试加幂等来化解?我见过很多项目其实只需要一个可靠的消息通知机制就能解决,结果为了上分布式事务,引入了一整套复杂的重量级组件,后续维护成本远超收益。分布式事务是手段,不是目的,按需引入才是正解。