一、痛点:为什么本地事务救不了电商下单
电商下单从来不是"插一张订单表"这么简单。典型链路是:app-backend 创建订单 → stock-backend 扣减库存 → user-backend 扣减余额,三个服务三个库。本地 @Transactional 只能管住自己库里的那几条 SQL——订单写成功了、库存扣减却抛了异常,库存服务自己回滚了,订单那边毫不知情,钱照扣、货照发,对账时才发现一堆幽灵订单。
这就是分布式事务要解决的问题:把跨服务、跨库的多个本地事务,编排成一个要么全成功、要么全回滚的整体。Seata 是目前 Java 生态用得最广的分布式事务框架,核心角色就三个:
- TC(Transaction Coordinator):事务协调器,也就是 seata-server,维护全局事务状态;
- TM(Transaction Manager):发起方,用
@GlobalTransactional开启/提交/回滚全局事务; - RM(Resource Manager):每个参与方,管理各自分支事务并上报状态。
二、方案选型:四种模式怎么选
Seata 提供四种模式,先搞清楚差异再动手:
- AT 模式:对业务代码侵入最小,靠 undo_log 前后镜像 自动回滚,适合大多数订单/库存/账户场景;
- TCC 模式:Try/Confirm/Cancel 三段式,补偿逻辑自己写,适合对隔离性、性能要求高的场景;
- XA 模式:依赖数据库原生 XA,强一致但锁粒度大、性能差,用得少;
- Saga 模式:长流程状态机编排,正向调用 + 反向补偿,适合有人工审批、外部系统参与的长事务。
务实建议:链路短、一致性要求高 → AT;有预留/冻结语义(余额、库存)→ TCC;长流程编排 → Saga;能容忍短暂不一致的异步链路 → 本地消息表 + MQ(这个方案在幂等文章里讲过,适合发券、加积分这类旁路业务)。
三、AT 模式实战:最小可运行示例
以"下单扣库存扣余额"为例,三个服务都引入依赖(以 seata-spring-boot-starter 1.8.0 为例):
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.8.0</version>
</dependency>
每个参与方配置 seata 客户端,注册中心与配置中心用 Nacos:
spring:
datasource:
url: jdbc:mysql://db.example.com:3306/app_db?useUnicode=true&characterEncoding=utf8
username: app_user
password: ******
driver-class-name: com.mysql.cj.jdbc.Driver
seata:
enabled: true
application-id: app-backend
tx-service-group: my_test_tx_group
registry:
type: nacos
nacos:
server-addr: nacos.example.com:8848
namespace: public
group: SEATA_GROUP
config:
type: nacos
nacos:
server-addr: nacos.example.com:8848
namespace: public
group: SEATA_GROUP
service:
vgroup-mapping:
my_test_tx_group: default
关键点 1:每个参与方的业务库都必须建 undo_log 表(官方 SQL):
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` tinyint(4) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
关键点 2:数据源必须包一层 DataSourceProxy,否则 AT 的镜像记录不会生成:
@Configuration
public class SeataDataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource dataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@Primary
public DataSourceProxy dataSourceProxy(DataSource dataSource) {
return new DataSourceProxy(dataSource);
}
}
关键点 3:发起方方法上加 @GlobalTransactional,内部照常写本地 SQL、照常 Feign 远程调用:
@Service
public class OrderService {
@Resource
private OrderMapper orderMapper;
@Resource
private StockFeignClient stockClient;
@Resource
private UserFeignClient userClient;
@GlobalTransactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
// 1. 本地写订单(app_db)
orderMapper.insert(buildOrder(dto));
// 2. 远程扣库存(stock-backend,stock_db)
stockClient.deduct(dto.getSkuId(), dto.getCount());
// 3. 远程扣余额(user-backend,user_db)
userClient.deductBalance(dto.getUserId(), dto.getAmount());
// 任一步抛异常 → 三个库一起回滚
}
}
AT 模式原理一句话:一阶段各分支本地提交,同时把前后镜像写进 undo_log;二阶段如果全局成功,异步批量删除 undo_log;如果失败,TC 通知各分支按镜像生成反向 SQL 回滚。回滚前还会校验"后镜像 vs 当前数据",防止数据被全局事务之外的操作改过。
四、TCC 模式:把补偿逻辑攥在自己手里
AT 靠镜像自动回滚,但遇到"扣减余额"这类敏感操作,很多人希望 try 阶段先冻结、confirm 才真正扣、cancel 解冻,于是用 TCC:
@LocalTCC
public interface StockTccService {
@TwoPhaseBusinessAction(name = "stockDeductTcc",
commitMethod = "confirm", rollbackMethod = "cancel")
boolean tryDeduct(BusinessActionContext ctx,
@BusinessActionContextParameter(paramName = "skuId") Long skuId,
@BusinessActionContextParameter(paramName = "count") Integer count);
boolean confirm(BusinessActionContext ctx);
boolean cancel(BusinessActionContext ctx);
}
实现时库存表加一个 frozen_count 冻结字段:try 把可用库存转成冻结库存;confirm 把冻结库存真正扣掉;cancel 把冻结库存还回去。TCC 三个方法都必须幂等(重复调用不重复扣/还),还要处理两个经典问题:
- 空回滚:try 还没执行成功 cancel 就到了,要能识别"没预留过"直接返回成功;
- 悬挂:cancel 先于 try 执行完,try 才姗姗来迟——必须在 try 里检查是否已被 cancel,避免把已释放的库存再冻结一次。
五、踩坑记录(都是线上真实教训)
- undo_log 漏建或表名不对:一阶段提交直接报
Table 'xxx.undo_log' doesn't exist。每个参与库都要建,且名字必须是undo_log(可通过配置改,但默认别动)。 - 业务表没有单列主键:AT 回滚靠主键定位记录,复合主键或没主键的表回滚直接失败。官方 FAQ 明确要求单列主键,分库分表场景只支持"分库不分表"。
- 异常被 catch 吞掉:
@GlobalTransactional靠异常触发回滚,你在方法里 catch 住异常不往外抛,全局事务会正常提交,数据就偏了。同理,rollbackFor不配的话,受检异常默认不回滚。 - DataSourceProxy 没生效:多数据源项目里
@Primary指错 Bean,MyBatis 用的还是原始数据源,表现为"本地提交了、全局不回滚"且无任何报错,最隐蔽。 - 全局锁冲突:两个全局事务改同一行,后到者等锁,超时抛
Global lock acquire timeout。可用client.rm.lock.retry-interval / retry-times调节,但根治办法是缩小事务粒度、避免热点行。 - 版本不匹配:client 与 server 版本差太多会注册分支失败。升级 seata-server 时所有客户端要同步升级。
- 全局事务里嵌套长事务:在
@GlobalTransactional方法里再开@Transactional(REQUIRES_NEW)会让分支脱离管控,回滚不干净。 - 事务分组不一致:
tx-service-group与配置中心的service.vgroupMapping映射对不上,报no available service。所有服务必须指向同一个事务组。
六、最佳实践总结
- 控制事务范围:全局事务里只放必须强一致的操作,远程慢调用、外部 HTTP、发短信统统移出去;全局锁持有越久并发越差。
- 分支方法自身也要幂等:Seata 保证回滚,但不保证不重试——分支接口(尤其 TCC)必须能扛住重复调用。
- 监控卡死事务:seata-server 用 DB 模式时,定期看
global_table / branch_table / lock_table,清理长时间未结束的悬挂事务,配合 Prometheus metrics 观察全局事务成功率。 - 不是所有链路都需要 Seata:下单主链路用 AT/TCC,旁路(发券、加积分、异步通知)用本地消息表 + MQ 最终一致性,成本和性能都更优。先用数据一致性要求倒推方案,别一上来就上分布式事务。
分布式事务没有银弹,本质是在一致性、性能、业务侵入度三者之间做取舍。把 AT 的自动化和 TCC 的精准控制都用对地方,电商中台的跨库数据才能真正"靠得住"。