分类:架构与高并发 · 作者:管理员 · 发布于 2026-10-08 · 阅读 0 · 标签:Seata / 分布式事务 / 微服务 / 电商中台 / TCC

一、痛点:为什么本地事务救不了电商下单

电商下单从来不是"插一张订单表"这么简单。典型链路是: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,避免把已释放的库存再冻结一次。

五、踩坑记录(都是线上真实教训)

  1. undo_log 漏建或表名不对:一阶段提交直接报 Table 'xxx.undo_log' doesn't exist。每个参与库都要建,且名字必须是 undo_log(可通过配置改,但默认别动)。
  2. 业务表没有单列主键:AT 回滚靠主键定位记录,复合主键或没主键的表回滚直接失败。官方 FAQ 明确要求单列主键,分库分表场景只支持"分库不分表"。
  3. 异常被 catch 吞掉:@GlobalTransactional 靠异常触发回滚,你在方法里 catch 住异常不往外抛,全局事务会正常提交,数据就偏了。同理,rollbackFor 不配的话,受检异常默认不回滚。
  4. DataSourceProxy 没生效:多数据源项目里 @Primary 指错 Bean,MyBatis 用的还是原始数据源,表现为"本地提交了、全局不回滚"且无任何报错,最隐蔽。
  5. 全局锁冲突:两个全局事务改同一行,后到者等锁,超时抛 Global lock acquire timeout。可用 client.rm.lock.retry-interval / retry-times 调节,但根治办法是缩小事务粒度、避免热点行。
  6. 版本不匹配:client 与 server 版本差太多会注册分支失败。升级 seata-server 时所有客户端要同步升级。
  7. 全局事务里嵌套长事务:在 @GlobalTransactional 方法里再开 @Transactional(REQUIRES_NEW) 会让分支脱离管控,回滚不干净。
  8. 事务分组不一致: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 的精准控制都用对地方,电商中台的跨库数据才能真正"靠得住"。