1. 为什么分布式事务是微服务绕不开的一道坎
1.1 从单体到微服务,事务问题是怎么冒出来的
早些年做单体应用的时候,事务这件事其实挺省心的。一个@Transactional注解往 Service 方法上一贴,Spring 的DataSourceTransactionManager就帮你把本地数据库的 ACID 兜住了。下单、扣库存、扣余额,全在一个数据库连接里,要么全成功,要么全回滚,干净利落。
但业务一旦拆成微服务,这套逻辑就彻底失效了。订单服务有自己的库,库存服务有自己的库,账户服务也有自己的库。每个服务只对自己那个库负责,本地事务管不到别人家的数据。这时候一个下单请求跨了三个服务,订单写成功了,库存扣失败了,账户还没动——数据就歪了。
这就是分布式事务要解决的核心问题:跨多个服务、多个数据源的操作,如何保证最终的一致性。注意我说的是“最终一致性”,不是“强一致性”。这个区别很关键,后面会展开讲。
1.2 分布式事务的几种主流解法,以及为什么我最终选了 Seata
业内解决分布式事务的路子其实不少,我大致梳理一下常见的几种:
- 两阶段提交(2PC):强一致,但性能差、协调者单点、阻塞严重,互联网高并发场景基本不用。
- TCC(Try-Confirm-Cancel):业务侵入性强,每个操作都要写三个方法,补偿逻辑得自己扛,适合金融级场景但开发成本高。
- 本地消息表 / 事务消息:靠消息队列做最终一致,实现相对简单,但需要额外的消息表和定时补偿任务。
- Saga 模式:长事务拆成一串本地事务,每个都配一个补偿操作,适合流程长的业务,但补偿逻辑复杂。
- Seata AT 模式:对业务几乎无侵入,靠全局锁和 undo log 做自动回滚,开发体验最接近本地事务。
我选 Seata 的 AT 模式,理由很直接:业务代码改动最小。你只需要在发起方加一个@GlobalTransactional注解,剩下的脏活累活 Seata 帮你干了。对于大多数中小团队来说,这是性价比最高的方案。TCC 那种要写三个方法的模式,团队里但凡有一个人补偿逻辑写错了,排查起来能让你怀疑人生。
1.3 Seata 的三大角色,用大白话讲清楚
Seata 的架构里有三个核心角色,官方文档写得比较抽象,我用一个生活场景给你翻译一下。
假设你要在网上买一台电脑,涉及下单(订单服务)和扣款(账户服务):
- TC(Transaction Coordinator,事务协调者):相当于一个“裁判”。它独立部署,负责协调整个全局事务的提交或回滚。所有分支事务都要向它汇报。
- TM(Transaction Manager,事务管理者):相当于“发起人”。它通常就是你的业务入口服务,负责开启全局事务、发起提交或回滚指令。
@GlobalTransactional注解就是标在 TM 这一侧的。 - RM(Resource Manager,资源管理者):相当于“执行人”。每个参与事务的微服务都是一个 RM,它管着自己那个库的连接和 undo log,负责执行分支事务并向 TC 汇报状态。
一次完整的流程是这样的:TM 向 TC 申请开启全局事务,拿到一个全局唯一的 XID;XID 通过服务调用链路传递到各个 RM;每个 RM 执行自己的本地事务,把 undo log 记下来,然后向 TC 注册分支;所有分支都成功了,TM 通知 TC 提交,TC 再通知各 RM 提交;任何一个分支失败,TC 就通知所有 RM 用 undo log 回滚。
提示:XID 的传递是 Seata 能串起整个链路的关键。它默认通过 HTTP header 或 Dubbo 的 attachment 传递,如果你用的是自定义的 RPC 框架,需要自己实现 XID 的透传,否则分支事务注册不上,全局回滚就会失效。
2. Seata AT 模式的核心原理拆解
2.1 一阶段:业务 SQL 和 undo log 是怎么一起落库的
AT 模式最精妙的地方在于一阶段。很多人以为 Seata 是“先执行业务 SQL,再单独记日志”,其实不是。Seata 通过代理数据源,把这两件事放在同一个本地事务里完成。
具体过程是这样的:RM 拦截你的业务 SQL,先解析 SQL 语义,拿到涉及的表、主键、更新前后的镜像数据。然后在一个本地事务里,先执行业务 SQL,再往undo_log表里插一条回滚日志。这两步要么一起成功,要么一起失败。
undo_log 里存了什么?主要是三样东西:
- beforeImage:更新前的数据快照,回滚时用来还原。
- afterImage:更新后的数据快照,提交后用来做并发校验。
- XID 和 branchId:标识这条日志属于哪个全局事务的哪个分支。
我举个例子。假设账户表里 id=1 的记录余额是 100,业务要扣 30。Seata 会记录 beforeImage 为{id:1, balance:100},执行UPDATE account SET balance = balance - 30 WHERE id = 1,再记录 afterImage 为{id:1, balance:70}。回滚的时候,它就用 beforeImage 把数据还原成 100。
这里有个细节值得注意:beforeImage 是在业务 SQL 执行前查的,afterImage 是在执行后查的。而且查询用的是SELECT ... FOR UPDATE,加了行锁,防止在快照和更新之间有其他事务插进来改数据。这个设计保证了镜像数据的准确性。
2.2 二阶段提交:为什么快,快在哪
二阶段提交的时候,RM 收到 TC 的提交指令,做的事情非常简单:异步删除 undo_log。因为业务数据在一阶段就已经落库了,二阶段不需要再动业务数据,只是清理日志而已。
这就是 AT 模式性能好的根本原因。相比 2PC 那种二阶段还要持有锁、还要协调提交的方式,Seata 的二阶段提交几乎是“零成本”的。删除日志这个操作可以异步做,甚至失败了还能重试,不影响业务数据的正确性。
但这里有个坑我得提醒你:undo_log 表必须和业务表在同一个库。因为一阶段要把业务 SQL 和日志写入放在同一个本地事务里,如果分在不同库,本地事务就保证不了了。我见过有人把 undo_log 单独建在一个公共库,结果回滚的时候各种诡异问题,排查了半天才发现是这个问题。
2.3 二阶段回滚:undo log 是怎么把数据还原的
回滚比提交复杂得多,因为要处理并发和脏写的问题。RM 收到回滚指令后,会做这么几件事:
首先,根据 XID 和 branchId 找到对应的 undo_log。然后拿 beforeImage 和当前数据库里的数据做比对。如果当前数据和 afterImage 一致,说明没有其他事务改过这条数据,可以安全回滚,直接用 beforeImage 覆盖。如果当前数据和 afterImage 不一致,说明在全局事务执行期间,有别的线程改了这条数据,这时候就要看配置了。
Seata 提供了两种策略,通过client.undo.dataValidation参数控制:
| 配置值 | 行为 | 适用场景 |
|---|---|---|
| true(默认) | 校验不一致时抛异常,回滚失败 | 对数据一致性要求高,宁可失败也不脏写 |
| false | 跳过校验,强制用 beforeImage 覆盖 | 允许覆盖,但可能丢失其他事务的更新 |
我个人的建议是保持默认的 true。虽然回滚失败会带来麻烦,但至少你知道出问题了,能人工介入。如果设成 false,数据被悄悄覆盖了,你可能几个月后才发现对不上账,那才是灾难。
回滚完成后,undo_log 同样会被删除。如果回滚过程中 RM 挂了,TC 会不断重试,直到成功或者达到重试上限。
3. 从零搭建 Seata 实战环境
3.1 版本选型和组件准备
版本兼容性是 Seata 落地最容易踩的坑,没有之一。SpringCloud Alibaba、Seata、Nacos 三者之间的版本必须对得上,否则启动就报各种莫名其妙的错。
我用的这套组合是经过实测比较稳的:
| 组件 | 版本 | 说明 |
|---|---|---|
| Spring Boot | 2.3.12.RELEASE | 别用太新的,2.4+ 有配置加载顺序变化 |
| SpringCloud Alibaba | 2.2.7.RELEASE | 对应 SpringCloud Hoxton |
| Seata | 1.4.2 | 1.4.x 系列比较成熟 |
| Nacos | 1.4.2 | 注册中心和配置中心二合一 |
| MySQL | 5.7 或 8.0 | 8.0 注意驱动和时区配置 |
注意:Seata 1.4.2 之后版本号跳到了 1.5、1.6,API 有些变化。如果你是新项目,可以直接上 1.6.x,但网上大部分教程还是 1.4.x 的,遇到问题搜起来方便。我选 1.4.2 主要是求稳。
3.2 Seata Server 的部署和配置
Seata Server 就是 TC,需要独立部署。下载解压后,主要改两个文件。
第一个是conf/registry.conf,配置注册中心和配置中心的类型。我用 Nacos 做两者:
registry { type = "nacos" nacos { application = "seata-server" serverAddr = "127.0.0.1:8848" group = "SEATA_GROUP" namespace = "" cluster = "default" } } config { type = "nacos" nacos { serverAddr = "127.0.0.1:8848" group = "SEATA_GROUP" namespace = "" } }第二个是conf/file.conf,配置存储模式。生产环境一定要用 db 模式,把全局事务会话信息存到数据库,否则 TC 重启后事务状态就丢了:
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&serverTimezone=Asia/Shanghai" user = "root" password = "你的密码" } }然后需要在数据库里建三张表:global_table、branch_table、lock_table。这三张表的建表语句在 Seata 的script/server/db/mysql.sql里,直接执行就行。
配置推送到 Nacos 这一步很多人会漏。Seata Server 从 Nacos 读配置,你得先把config.txt里的配置项推到 Nacos 上。官方提供了脚本,但 Windows 下跑起来有点麻烦,我一般是手动在 Nacos 控制台建一个seataServer.properties的配置,把关键项填进去。
3.3 业务库的 undo_log 表准备
每个参与全局事务的业务库,都要建一张undo_log表。注意是每个业务库,不是只在 TC 的库里建。建表语句如下:
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, `ext` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;这个表平时是空的,事务执行期间会有数据,提交或回滚后会被清理。如果你发现这个表里堆积了大量数据,说明有事务卡住了,得去查 TC 的日志。
4. 业务代码改造与全局事务落地
4.1 依赖引入和配置项详解
每个参与事务的微服务都要引入 Seata 的 starter:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> </dependency>然后在application.yml里配置 Seata:
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 group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP这里有几个点容易搞错。tx-service-group是你自定义的事务分组名,vgroup-mapping把这个分组映射到 TC 的集群名。TC 那边registry.conf里配的cluster = "default",所以这里映射到 default。如果对不上,启动时会报“no available service”的错误。
application-id建议和spring.application.name保持一致,方便在 TC 日志里定位是哪个服务。
4.2 发起方加 @GlobalTransactional 的正确姿势
全局事务的发起方,也就是 TM 那一侧,在业务入口方法上加注解:
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class) public void createOrder(OrderDTO orderDTO) { // 1. 创建订单 orderService.create(orderDTO); // 2. 扣减库存 storageService.deduct(orderDTO.getProductId(), orderDTO.getCount()); // 3. 扣减余额 accountService.debit(orderDTO.getUserId(), orderDTO.getAmount()); }rollbackFor = Exception.class这个一定要加。默认情况下,@GlobalTransactional只对RuntimeException和Error回滚,受检异常不回滚。业务里如果抛了受检异常,全局事务不会回滚,数据就歪了。这个坑我踩过,排查的时候一脸懵。
另外,name属性给全局事务起个名字,方便在 TC 的日志和监控里识别。别小看这个,线上出问题的时候,日志里一堆 XID,有个可读的名字能省不少事。
4.3 参与方什么都不用加吗
这是 AT 模式最爽的地方:参与方的方法上不需要加任何注解。只要它被 TM 通过 RPC 调用到,并且 XID 正确传递过来了,Seata 就会自动把它纳入全局事务。
但有个前提:参与方的数据源必须被 Seata 代理。SpringCloud Alibaba 的 starter 会自动帮你做这件事,前提是你的数据源是通过 Spring 容器管理的。如果你在代码里手动new了一个 DataSource,那 Seata 代理不到,分支事务就注册不上。
我见过一个案例,某同学在工具类里自己创建了数据源,结果那个库的操作死活不回滚。查了半天才发现是数据源没被代理。所以记住一句话:数据源交给 Spring 管,别自己造。
4.4 一个完整的下单场景代码演示
我把上面那个下单场景的完整代码贴一下,方便你直接抄。
订单服务(TM 侧):
@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StorageFeignClient storageFeignClient; @Autowired private AccountFeignClient accountFeignClient; @Override @GlobalTransactional(name = "create-order", rollbackFor = Exception.class) public void create(OrderDTO orderDTO) { // 本地事务:创建订单 Order order = new Order(); order.setUserId(orderDTO.getUserId()); order.setProductId(orderDTO.getProductId()); order.setCount(orderDTO.getCount()); order.setAmount(orderDTO.getAmount()); order.setStatus(0); orderMapper.insert(order); // 远程调用:扣库存 storageFeignClient.deduct(orderDTO.getProductId(), orderDTO.getCount()); // 远程调用:扣余额 accountFeignClient.debit(orderDTO.getUserId(), orderDTO.getAmount()); } }库存服务(RM 侧):
@Service public class StorageServiceImpl implements StorageService { @Autowired private StorageMapper storageMapper; @Override public void deduct(Long productId, Integer count) { // 普通本地事务,不需要加 @GlobalTransactional Storage storage = storageMapper.selectByProductId(productId); if (storage.getCount() < count) { throw new RuntimeException("库存不足"); } storageMapper.deduct(productId, count); } }注意库存服务这里抛了RuntimeException,这个异常会沿着调用链传回订单服务,触发全局回滚。订单服务的本地事务回滚,库存服务的本地事务也回滚,账户服务如果已经执行了也会回滚。
5. 踩坑实录与常见问题排查
5.1 全局锁冲突:并发下单时的死锁问题
AT 模式在一阶段会申请全局锁,这是为了保证全局事务之间的隔离性。但全局锁也带来了并发问题。
场景是这样的:两个全局事务同时操作同一条库存记录。事务 A 先拿到全局锁,事务 B 拿不到,会重试。如果事务 A 执行时间很长,事务 B 一直重试,最终可能超时失败。
更麻烦的是死锁。事务 A 锁了库存表的 id=1,想锁账户表的 id=1;事务 B 锁了账户表的 id=1,想锁库存表的 id=1。两个互相等,死锁了。
Seata 的全局锁默认重试 30 次,每次间隔 10ms,可以通过client.rm.lock.retryInterval和client.rm.lock.retryTimes调整。但调参数只是缓解,根本解法是减少全局事务的粒度。能拆成多个小事务的,别塞进一个大事务里。全局事务越长,锁持有时间越久,冲突概率越大。
提示:如果业务允许,可以在查询库存的时候用
SELECT ... FOR UPDATE提前锁定,把冲突暴露在业务早期,而不是等到更新的时候才发现。这样至少能快速失败,而不是卡在那里重试。
5.2 undo_log 表膨胀和清理策略
正常情况下,undo_log 表在事务提交或回滚后会被清理。但如果你发现表里数据越来越多,通常是这几种情况:
- 事务卡住了:TC 联系不上某个 RM,或者 RM 一直回滚失败,日志删不掉。
- 异步删除失败:二阶段提交时删除日志是异步的,如果 RM 在删除前挂了,日志会残留。
- 脏数据:手动改过数据库,导致 undo_log 和实际数据对不上。
排查的时候,先看log_created时间,超过一天的基本可以判定是异常残留。然后拿 XID 去 TC 的global_table里查,看这个全局事务是什么状态。如果是Begin状态一直不变,说明 TC 也在等,得看 RM 的日志。
清理的话,可以写个定时任务,删除log_created超过 7 天的记录。但删之前一定要确认对应的全局事务已经结束了,否则删了日志,回滚就做不了了。
5.3 常见报错速查表
我把实际运维中遇到的高频报错整理成一张表,方便你快速定位:
| 报错信息 | 可能原因 | 解决方向 |
|---|---|---|
| no available service 'default' found | TC 没启动,或 vgroup-mapping 配错 | 检查 TC 是否运行,核对分组映射 |
| can not get cluster name in registry config | registry.conf 里 cluster 没配 | 补上 cluster 配置 |
| Global lock acquire failed | 全局锁冲突 | 减少事务粒度,或调大重试参数 |
| undo_log table not exists | 业务库没建 undo_log 表 | 在每个业务库执行建表语句 |
| XID not found in context | XID 没传递过来 | 检查 RPC 框架的 header 透传 |
| branch rollback failed | 回滚时数据校验不通过 | 查是否有并发修改,检查 dataValidation 配置 |
5.4 生产环境的几个保命建议
最后分享几条我在生产环境总结的经验,都是血泪换来的。
第一,TC 一定要集群部署。单点 TC 挂了,所有全局事务都卡住。Seata Server 支持集群,多个实例注册到同一个 Nacos 集群下,TM 会自动负载均衡。
第二,全局事务别嵌套太深。A 调 B,B 调 C,C 调 D,链路越长,出问题的概率越大,排查也越难。能扁平化就扁平化。
第三,关键业务加对账兜底。分布式事务再可靠,也不能保证 100% 不出问题。资金相关的业务,一定要有定时对账任务,发现不一致及时告警和人工介入。技术手段是兜底,不是万能。
第四,压测的时候重点测回滚。很多人压测只测正常流程,回滚路径根本没跑过。结果线上一出问题,回滚逻辑各种报错。我建议专门写个开关,能强制让某个分支抛异常,专门压测回滚路径。
第五,日志级别别开太细。Seata 的 DEBUG 日志量非常大,生产环境开 DEBUG 能把磁盘写满。用 INFO 级别,出问题的时候再临时调。
这套东西我从最早的单体事务一路踩过来,Seata 算是目前综合体验最好的方案了。但它不是银弹,理解它的原理和边界,比会用它更重要。尤其是全局锁和回滚校验这两个机制,直接决定了你的业务能不能扛住并发。把这些搞明白了,再去做技术选型和架构设计,心里就有底了。