1. 分布式事务到底在解决什么问题
1.1 从一个真实的翻车现场说起
去年我参与过一个电商中台项目,订单服务、库存服务、账户服务拆成了三个独立的微服务,各自有独立的数据库。上线第一天就出了事故:用户下单成功,订单库里有了记录,但库存服务因为网络抖动超时了,库存没扣。结果就是超卖,仓库那边发不出货,客服电话被打爆。
这就是典型的分布式事务问题。在单体应用时代,一个@Transactional注解就能搞定的事情,拆成微服务之后彻底失效了。因为本地事务只能管住自己这个库,跨库、跨服务的操作,它鞭长莫及。
分布式事务要解决的核心问题就一句话:多个服务、多个数据库之间的操作,要么全部成功,要么全部回滚,不能出现中间状态。听起来简单,做起来极难,因为网络不可靠、服务可能挂、消息可能丢,任何一个环节出问题都会导致数据不一致。
1.2 为什么选 Seata 而不是其他方案
市面上分布式事务的方案不少,我大致梳理一下常见的几种,以及为什么最后我选了 Seata。
| 方案 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|
| 2PC/XA | 数据库层面两阶段提交 | 强一致 | 性能差,锁持有时间长 |
| TCC | 手动写 Try-Confirm-Cancel | 灵活、性能好 | 代码侵入大,开发成本高 |
| 本地消息表 | 消息队列+本地事务 | 实现简单 | 只能保证最终一致,有延迟 |
| 最大努力通知 | 反复重试 | 简单 | 不保证一致性 |
| Seata AT | 自动生成反向SQL回滚 | 无侵入、开发快 | 依赖全局锁,有性能损耗 |
Seata 的 AT 模式最大的吸引力在于对业务代码几乎零侵入。你只需要在业务方法上加一个@GlobalTransactional注解,剩下的脏数据记录、反向补偿 SQL、全局锁管理,Seata 全帮你干了。对于已经上线的老系统改造,这个优势太关键了——你不可能让团队把几百个接口全部重写成 TCC。
当然,Seata 也不是银弹。它的 AT 模式在高并发场景下,全局锁会成为瓶颈。所以我的建议是:核心链路用 TCC 或 Saga,非核心链路用 AT。这个后面会详细展开。
1.3 Seata 的三个核心角色
理解 Seata 的架构,先记住三个角色,我用一个生活化的类比来解释。
假设你要组织一场多人聚餐,涉及订餐厅、买食材、通知朋友三件事,必须全部搞定才算成功。
- TC(Transaction Coordinator)事务协调者:就是那个"总指挥",负责协调所有人的进度,决定最终是提交还是回滚。在 Seata 里就是 seata-server 这个独立部署的服务。
- TM(Transaction Manager)事务管理者:就是"发起人",也就是你。你负责开启全局事务,最后告诉 TC 是提交还是回滚。在代码里就是加了
@GlobalTransactional的那个方法所在的服务。 - RM(Resource Manager)资源管理者:就是"执行人",订餐厅的、买食材的、通知朋友的。每个微服务都是一个 RM,负责管理自己的本地事务,并向 TC 汇报状态。
整个流程是这样的:TM 向 TC 申请开启全局事务,拿到一个全局唯一的 XID;这个 XID 会随着调用链路一路传递下去;每个 RM 执行本地事务时,会把自己的分支事务注册到 TC;最后 TM 根据所有分支的执行情况,通知 TC 提交或回滚,TC 再指挥各个 RM 完成最终操作。
提示:XID 的传递是 Seata 能串起整个链路的关键。它通过拦截器自动注入到 RPC 调用的上下文中,你不需要手动传。但如果你用了非标准的 RPC 框架,就需要自己实现传递逻辑。
2. Seata AT 模式的底层原理拆解
2.1 两阶段提交的"改良版"
Seata AT 模式本质上还是两阶段提交,但它做了关键改良,把"锁"从数据库层面挪到了 Seata 层面,大幅缩短了锁的持有时间。
第一阶段:业务 SQL 执行时,Seata 通过代理数据源拦截 SQL,做两件事。第一,解析 SQL 语义,找到涉及的表和主键,查询出修改前的数据(before image)和修改后的数据(after image),把这些信息作为"undo log"记录到undo_log表里。第二,执行业务 SQL,同时向 TC 注册分支事务,并申请全局锁。
第二阶段:如果所有分支都成功,TC 通知各 RM 提交,RM 异步删除 undo log 即可,非常快。如果有分支失败,TC 通知各 RM 回滚,RM 根据 undo log 里的 before image 生成反向 SQL,把数据恢复回去。
这里有个精妙的设计:全局锁。当 RM 在第一阶段申请全局锁时,如果发现这条记录已经被其他全局事务锁住了,它会重试等待。这样就避免了不同全局事务修改同一行数据导致的脏写问题。
2.2 undo log 表结构长什么样
undo_log表是 AT 模式的核心,每个业务库都需要建这张表。建表语句如下:
CREATE TABLE `undo_log` ( `branch_id` bigint(20) NOT NULL COMMENT '分支事务ID', `xid` varchar(100) NOT NULL COMMENT '全局事务ID', `context` varchar(128) NOT NULL COMMENT '上下文', `rollback_info` longblob NOT NULL COMMENT '回滚信息', `log_status` int(11) NOT NULL COMMENT '状态', `log_created` datetime NOT NULL COMMENT '创建时间', `log_modified` datetime NOT NULL COMMENT '修改时间', UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;rollback_info字段存的就是序列化后的 before image 和 after image。回滚的时候,Seata 会反序列化这个字段,对比当前数据和 after image 是否一致。如果一致,说明没有脏写,可以安全回滚;如果不一致,说明数据被其他事务改过了,这时候就需要人工介入。
注意:
undo_log表的数据量会随着业务增长而膨胀。虽然第二阶段提交后会异步删除,但如果 TC 挂了或者网络异常,可能会残留。建议定期清理超过一定时间的 undo log,比如保留 7 天。
2.3 全局锁的竞争与重试机制
全局锁是 AT 模式性能的关键点,也是最容易出问题的地方。我踩过一次坑:两个全局事务同时操作同一批订单,结果其中一个一直重试超时。
Seata 的全局锁重试逻辑是这样的:RM 在第一阶段执行本地事务前,会先尝试获取全局锁。如果获取失败,会根据配置的重试策略进行重试。默认重试次数是 30 次,每次间隔 10ms。如果 30 次都失败,就抛出异常,触发全局回滚。
这个默认配置在高并发场景下明显不够用。我的经验是,根据业务 RT 来调整。如果单个业务操作平均耗时 50ms,那么重试次数至少应该覆盖 3-5 倍的 RT,也就是 150-250ms。按 10ms 间隔算,重试次数设到 20-30 次比较合理。但也不能无限重试,否则会拖垮整个链路。
# 客户端配置全局锁重试 client.rm.lock.retryInterval=10 client.rm.lock.retryTimes=30 client.rm.lock.retryPolicyBranchRollbackOnConflict=true最后这个retryPolicyBranchRollbackOnConflict参数很关键。设为 true 时,如果获取全局锁冲突,会直接回滚当前分支,释放本地锁,避免死锁。设为 false 则会一直重试。生产环境建议设为 true,宁可回滚重试,也不要死等。
3. 从零搭建 Seata 实战环境
3.1 服务端部署与配置要点
Seata Server 的部署方式有几种:直接下载二进制包、Docker 部署、或者自己编译源码。我推荐用 Docker,干净利落,升级也方便。
docker run -d --name seata-server \ -p 8091:8091 \ -p 7091:7091 \ -e SEATA_IP=192.168.1.100 \ -e SEATA_PORT=8091 \ -v /data/seata/config:/root/seata-config \ seataio/seata-server:1.7.0这里有两个端口需要说明。8091 是 Seata Server 与客户端通信的端口,7091 是控制台端口,可以查看全局事务状态、分支事务详情,排查问题非常有用。
配置文件application.yml里,最关键的是存储模式(store.mode)。默认是 file 模式,只适合单机测试。生产环境必须改成 db 模式,把全局事务会话信息存到数据库,这样 Seata Server 重启后事务状态不会丢。
seata: store: mode: db db: datasource: druid url: jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true user: seata password: seata driver-class-name: com.mysql.cj.jdbc.Driver对应的数据库需要建三张表:global_table、branch_table、lock_table。这些表的建表语句在 Seata 源码的script/server/db目录下能找到,直接执行即可。
提示:
lock_table是全局锁表,高并发下这张表的行锁竞争会很激烈。建议给table_name和pk字段建联合索引,能显著提升锁查询性能。
3.2 客户端接入的完整步骤
客户端接入分四步走,我按顺序说。
第一步,加依赖。在需要参与分布式事务的微服务里引入 Seata 的 starter。
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> </dependency>第二步,配数据源代理。这是 AT 模式的核心,Seata 需要代理你的数据源才能拦截 SQL。如果你用的是 Druid 连接池,需要把DruidDataSource替换成SeataDataSourceProxy。
@Bean @ConfigurationProperties(prefix = "spring.datasource") public DataSource druidDataSource() { return new DruidDataSource(); } @Bean public DataSourceProxy dataSourceProxy(DataSource druidDataSource) { return new DataSourceProxy(druidDataSource); }第三步,配注册中心和配置中心。Seata 支持 Nacos、Eureka、Zookeeper 等。我用 Nacos 比较多,配置如下:
seata: enabled: true application-id: order-service tx-service-group: my_tx_group service: vgroup-mapping: my_tx_group: default grouplist: default: 192.168.1.100:8091 registry: type: nacos nacos: server-addr: 192.168.1.100:8848 namespace: seata这里tx-service-group和vgroup-mapping的关系容易搞混。简单说,tx-service-group是逻辑分组名,vgroup-mapping把它映射到具体的 Seata Server 集群。一个 Seata Server 集群可以服务多个业务分组,方便隔离。
第四步,加注解。在全局事务的发起方法上加@GlobalTransactional。
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class) public void createOrder(OrderDTO orderDTO) { orderService.create(orderDTO); inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity()); accountService.debit(orderDTO.getUserId(), orderDTO.getAmount()); }rollbackFor = Exception.class这个一定要加,否则默认只对RuntimeException回滚,受检异常不会触发回滚,这是个常见的坑。
3.3 一个完整的下单场景演示
我模拟一个下单场景,三个服务:订单服务、库存服务、账户服务。正常流程是创建订单、扣库存、扣余额,三步全成功才提交。
// 订单服务 - 全局事务发起方 @GlobalTransactional(name = "place-order", rollbackFor = Exception.class) public void placeOrder(Long userId, Long productId, Integer count) { // 1. 创建订单 Order order = new Order(); order.setUserId(userId); order.setProductId(productId); order.setCount(count); order.setStatus("CREATED"); orderMapper.insert(order); // 2. 扣库存(远程调用) inventoryFeignClient.deduct(productId, count); // 3. 扣余额(远程调用) accountFeignClient.debit(userId, order.getAmount()); }库存服务的扣减逻辑:
@Transactional(rollbackFor = Exception.class) public void deduct(Long productId, Integer count) { // 先查库存 Inventory inventory = inventoryMapper.selectByProductId(productId); if (inventory.getStock() < count) { throw new BusinessException("库存不足"); } // 扣减 inventoryMapper.deductStock(productId, count); }如果库存不足抛异常,Seata 会捕获到这个异常,通知 TC 回滚全局事务。订单服务插入的订单记录会被回滚,账户服务如果已经扣了钱也会被回滚。整个过程对业务代码完全透明。
注意:库存服务的
deduct方法上必须加@Transactional,否则 Seata 无法管理它的本地事务,回滚会失效。这是新手最容易忽略的点。
4. 生产环境踩坑与优化经验
4.1 常见问题速查表
我把实际运维中遇到的问题整理成一张表,方便快速定位。
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 全局事务不回滚 | 异常被 catch 了没抛出 | 检查业务代码 try-catch | 在 catch 里重新抛出或手动回滚 |
| 回滚后数据不一致 | undo log 被篡改 | 查 undo_log 表 | 检查是否有非 Seata 途径修改数据 |
| 全局锁获取超时 | 并发太高或事务太长 | 看 Seata Server 日志 | 调大重试次数,缩短事务链路 |
| TC 连接不上 | 网络或配置错误 | 检查注册中心 | 确认 vgroup-mapping 配置 |
| 分支事务注册失败 | 数据源没代理 | 检查 DataSourceProxy | 确认数据源被正确包装 |
4.2 性能优化的几个关键点
Seata AT 模式的性能损耗主要来自三个方面:SQL 解析、undo log 写入、全局锁竞争。针对这三点,我总结了几个优化手段。
第一,缩小事务范围。全局事务里只放必须保证一致性的操作,查询、日志、发消息这些能挪出去就挪出去。事务链路越短,锁持有时间越短,性能越好。
第二,异步化非核心分支。如果某个分支失败不影响主流程,可以用@GlobalTransactional的lockRetryInterval配合异步执行,或者干脆拆成独立事务,用消息队列保证最终一致。
第三,合理设置隔离级别。Seata 默认的读隔离是读未提交,如果你需要读已提交,可以用SELECT ... FOR UPDATE加全局锁。但这样性能会下降,需要权衡。
第四,undo log 表优化。给undo_log表加索引,定期清理历史数据。我一般会写个定时任务,每天凌晨清理 7 天前的数据。
DELETE FROM undo_log WHERE log_created < DATE_SUB(NOW(), INTERVAL 7 DAY);4.3 什么时候不该用 Seata
说了这么多 Seata 的好,也得说说它的局限。有些场景,我强烈建议不要用 Seata。
超高并发场景。比如秒杀,每秒几万笔订单,Seata 的全局锁会成为致命瓶颈。这种场景更适合用消息队列做最终一致,或者用 Redis 做预扣减。
跨公司、跨网络的服务。Seata 需要 TC 和 RM 之间保持长连接,跨公网的话网络抖动会导致大量事务超时。这种场景用 TCC 或者对账补偿更合适。
非事务性操作。比如发短信、发邮件,这些操作本身就不需要回滚,硬塞进全局事务里只会拖慢性能。
我的原则是:强一致且并发不高的核心链路用 Seata AT,高并发场景用最终一致方案,复杂业务用 TCC。没有银弹,只有合适的场景。
5. 从 AT 到 TCC 的进阶思路
5.1 TCC 的三个方法怎么写
当 AT 模式扛不住性能压力时,就得考虑 TCC 了。TCC 的核心是把一个操作拆成 Try、Confirm、Cancel 三个阶段,全部由开发者手动实现。
以扣库存为例,TCC 的接口定义是这样的:
public interface InventoryTccService { @TwoPhaseBusinessAction(name = "deductStock", commitMethod = "confirm", rollbackMethod = "cancel") boolean tryDeduct(@BusinessActionContextParameter(paramName = "productId") Long productId, @BusinessActionContextParameter(paramName = "count") Integer count); boolean confirm(BusinessActionContext context); boolean cancel(BusinessActionContext context); }Try 阶段做资源预留,比如把库存从"可用库存"挪到"冻结库存"。Confirm 阶段把冻结库存扣掉。Cancel 阶段把冻结库存还回去。
TCC 的难点在于幂等、空回滚、悬挂这三个问题。Confirm 和 Cancel 可能被重复调用,必须保证幂等。Cancel 可能在 Try 还没执行时就来了,这叫空回滚。Try 可能在 Cancel 之后才到,这叫悬挂。这三个问题不处理好,数据照样乱。
5.2 三种模式的选型对比
最后给一张选型对比表,方便你做决策。
| 维度 | AT 模式 | TCC 模式 | Saga 模式 |
|---|---|---|---|
| 代码侵入 | 极低 | 高 | 中 |
| 一致性 | 强一致 | 强一致 | 最终一致 |
| 性能 | 中 | 高 | 高 |
| 适用场景 | 通用业务 | 高并发核心链路 | 长流程业务 |
| 开发成本 | 低 | 高 | 中 |
| 回滚方式 | 自动反向SQL | 手动Cancel | 手动补偿 |
我个人的经验是,新项目起步阶段直接用 AT,快速验证业务。等业务稳定了,再把性能瓶颈明显的链路改造成 TCC。不要一上来就追求完美架构,过度设计反而会拖慢迭代速度。
Seata 这个框架本身也在快速演进,1.7 版本之后对 Saga 模式的支持越来越完善,控制台也越来越好用。建议保持关注,但生产环境不要盲目追新,稳定压倒一切。我在实际使用中最大的体会是:分布式事务没有完美方案,只有权衡。理解每种模式的边界,比会用某个框架更重要。