乐途乐途
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
  • 学习路径
  • Spring Cloud Alibaba概述与技术选型
  • Nacos注册中心
  • Nacos配置中心
  • Sentinel流量控制
  • Sentinel降级与熔断
  • Seata分布式事务
  • RocketMQ消息驱动
  • Spring AI Alibaba与AI集成
  • Dubbo RPC服务调用
  • Gateway服务网关
  • GraalVM静态编译
  • 其他组件速览
  • Alibaba最佳实践与面试考点

Seata 分布式事务

定位

在微服务架构中,一个业务操作往往需要跨越多个服务、多个数据库。比如飞翔科技电商系统的下单流程:

用户点击"提交订单" → 订单服务(孔蓝负责)创建订单 → 库存服务(白歌负责)扣减库存 → 账户服务(小崔负责)扣减余额

这三个操作分布在三个独立的数据库上。如果库存扣减成功但余额扣减失败,就会出现"库存少了、钱没扣"的数据不一致问题——这正是分布式事务要解决的跨服务数据一致性难题。

Seata(Simple Extensible Autonomous Transaction Architecture)是阿里巴巴开源的分布式事务解决方案,核心设计目标是对业务零侵入——仅需一个 @GlobalTransactional 注解即可实现跨服务事务协调。

Seata 的架构围绕三种角色展开:

角色全称职责
TCTransaction Coordinator(事务协调器)Seata Server,维护全局事务和分支事务的状态,驱动全局提交或回滚
TMTransaction Manager(事务管理器)事务发起方,定义全局事务边界(标注 @GlobalTransactional 的方法所在服务)
RMResource Manager(资源管理器)管理分支事务处理的资源,向 TC 注册分支事务并上报状态,每个参与的微服务都是一个 RM

Seata 提供四种事务模式,覆盖不同场景需求:

模式侵入性性能核心机制适用场景
AT(Automatic Transaction)低(零侵入)中自动 UNDO LOG + 全局锁基于关系型数据库的通用微服务
TCC(Try-Confirm-Cancel)高(手写补偿)高资源预留 → 确认/取消对性能要求高的金融/支付场景
Saga中高正向流程 + 补偿流程长流程、老系统改造
XA中低数据库原生二阶段提交需要强一致性的金融场景

本文重点讲解 AT 模式(默认模式)的原理和实践,同时覆盖 TCC 模式的核心要点。


核心概念

XID(Global Transaction ID)

XID 是全局事务的唯一标识,由 TC 在收到 TM 的注册请求时生成,格式通常为 ip:port:transactionId。XID 通过 RPC 上下文(如 Feign Header)透明传递到下游服务,下游 RM 使用同一个 XID 向 TC 注册自己的分支事务。全局提交或回滚时,TC 通过 XID 定位所有关联的分支事务。

XID 示例:192.168.1.1:8091:2034567890

Branch ID(Branch Transaction ID)

每个参与全局事务的 RM 在向 TC 注册时,TC 为其分配一个唯一的 Branch ID。一个全局事务(一个 XID)下包含多个分支事务(多个 Branch ID),对应"订单库操作 → 库存库操作 → 账户库操作"的每一步。

UNDO LOG

UNDO LOG 是 AT 模式回滚的核心数据结构,记录数据修改前后的快照:

  • 前镜像(Before Image):执行业务 SQL 之前的数据行快照
  • 后镜像(After Image):执行业务 SQL 之后的数据行快照
  • 补偿 SQL:回滚时根据前镜像自动生成的反向 SQL(如 UPDATE 的补偿是还原原值的 UPDATE)

每个参与 AT 模式的业务数据库都必须创建 undo_log 表来持久化这些回滚信息。

全局锁(lock_table)

AT 模式通过全局锁防止多个全局事务并发修改同一行数据。全局锁存储在 Seata Server 的 lock_table 中,以 row_key(resourceId + 表名 + 主键)+ xid 为唯一约束。一阶段提交前 RM 向 TC 申请全局锁,TC 检查是否有其他 XID 持有同一行锁——无冲突则放行,有冲突则等待重试或超时抛出 LockConflictException。

全局事务状态表与分支事务状态表

Seata Server(TC)内部使用三张核心表维护事务状态:

  • global_table:记录全局事务状态(XID、状态、超时时间等)
  • branch_table:记录分支事务状态(Branch ID、所属 XID、状态等)
  • lock_table:记录全局锁持有情况(row_key、持有 XID、Branch ID 等)

核心原理(重点:AT 模式)

AT 模式基于两阶段提交协议,但与传统 XA 有本质区别:AT 模式在一阶段就提交了本地事务,二阶段只是异步清理或补偿。

AT 模式二阶段提交流程

关键点:一阶段每个 RM 的本地事务已经提交,全局提交的二阶段只是异步删除 UNDO LOG,非常轻量。

AT 模式二阶段回滚流程

关键点:回滚时 RM 根据 UNDO LOG 的前镜像自动生成反向补偿 SQL——INSERT 变 DELETE,UPDATE 变还原原值的 UPDATE,DELETE 变 INSERT。全程无需开发者编写补偿代码。

TM / TC / RM 交互架构全视图

全局锁工作原理详解

当两个全局事务同时修改同一行数据时,Seata 通过全局锁保证写隔离。整个流程如下:

全局事务 A(XID=XA)                    全局事务 B(XID=XB)
     │                                       │
     │ 1. UPDATE stock SET count=5            │
     │    WHERE product_id=100                │
     │                                       │
     │ 2. 向 TC 申请全局锁                    │
     │    row_key = "stock_db:stock:100"     │
     │    TC 检查 lock_table → 无冲突         │
     │    TC 插入 (row_key, XA) → 获取锁      │
     │                                       │
     │ 3. 本地事务提交                        │
     │                                       │ 4. UPDATE stock SET count=3
     │                                       │    WHERE product_id=100
     │                                       │
     │                                       │ 5. 向 TC 申请全局锁
     │                                       │    row_key = "stock_db:stock:100"
     │                                       │    TC 检查 lock_table → 有冲突!
     │                                       │    XA 持有该行锁
     │                                       │
     │                                       │ 6. 等待重试(默认最多重试 30 次)
     │                                       │    每次间隔 10ms
     │                                       │
     │ 7. 全局事务提交                         │
     │    TC 释放全局锁(删除 lock_table 记录) │
     │                                       │
     │                                       │ 8. 重试成功,获取锁
     │                                       │    TC 插入 (row_key, XB) → 获取锁
     │                                       │ 9. 执行 SQL + 保存 UNDO LOG

配置参数:

  • client.rm.lock.retryInterval:重试间隔(默认 10ms)
  • client.rm.lock.retryTimes:最大重试次数(默认 30 次)
  • client.rm.lock.retryPolicyBranchRollbackOnConflict:重试耗尽后是否回滚分支(默认 true)

UNDO LOG 脏写检查机制

回滚时,Seata 必须确认当前数据库中的行数据与后镜像一致,否则说明在全局事务提交到回滚之间发生了脏写(其他事务修改了同一行数据)。检查流程:

回滚时 RM 的操作顺序:
1. 从 undo_log 表读取 rollback_info(前镜像 + 后镜像)
2. 用后镜像的主键 + 所有字段值查询数据库当前行
3. 比对:当前行数据 == 后镜像?
   ├── 一致 → 安全,执行反向补偿 SQL
   └── 不一致 → 脏写发生 → 抛出异常,需要人工介入
4. 执行反向补偿 SQL
5. 删除该 UNDO LOG 记录

最佳实践:对于需要严格写隔离的场景,在业务 SQL 中使用 SELECT ... FOR UPDATE 获取数据库行锁,配合全局锁形成双重保护。


环境准备

Seata Server 下载与启动

Seata Server(TC)需要独立部署,推荐使用 db 模式存储事务日志。

# 下载 Seata Server(以 2.1.0 版本为例)
wget https://github.com/seata/seata/releases/download/v2.1.0/seata-server-2.1.0.zip

# 解压
unzip seata-server-2.1.0.zip -d /opt/seata
cd /opt/seata/bin

# Windows 启动(db 模式 + Nacos 注册)
seata-server.bat -p 8091 -m db

使用 Nacos 作为 Seata 注册中心和配置中心

在 Seata Server 的 conf/application.yml 中配置:

seata:
  registry:
    type: nacos
    nacos:
      application: seata-server
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace: ""
      username: nacos
      password: nacos
  config:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace: ""
      data-id: seataServer.properties
  store:
    mode: db
    db:
      datasource: druid
      db-type: mysql
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true
      user: root
      password: root

Seata Server 所需的三张元数据表(在 TC 所在数据库中创建):

-- global_table
CREATE TABLE IF NOT EXISTS `global_table` (
    `xid` VARCHAR(128) NOT NULL,
    `transaction_id` BIGINT,
    `status` TINYINT NOT NULL,
    `application_id` VARCHAR(32),
    `transaction_service_group` VARCHAR(32),
    `transaction_name` VARCHAR(128),
    `timeout` INT,
    `begin_time` BIGINT,
    `application_data` VARCHAR(2000),
    `gmt_create` DATETIME,
    `gmt_modified` DATETIME,
    PRIMARY KEY (`xid`),
    KEY `idx_status_gmt_modified` (`status`, `gmt_modified`),
    KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

-- branch_table
CREATE TABLE IF NOT EXISTS `branch_table` (
    `branch_id` BIGINT NOT NULL,
    `xid` VARCHAR(128) NOT NULL,
    `transaction_id` BIGINT,
    `resource_group_id` VARCHAR(32),
    `resource_id` VARCHAR(256),
    `branch_type` VARCHAR(8),
    `status` TINYINT,
    `client_id` VARCHAR(64),
    `application_data` VARCHAR(2000),
    `gmt_create` DATETIME(6),
    `gmt_modified` DATETIME(6),
    PRIMARY KEY (`branch_id`),
    KEY `idx_xid` (`xid`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

-- lock_table
CREATE TABLE IF NOT EXISTS `lock_table` (
    `row_key` VARCHAR(128) NOT NULL,
    `xid` VARCHAR(128),
    `transaction_id` BIGINT,
    `branch_id` BIGINT NOT NULL,
    `resource_id` VARCHAR(256),
    `table_name` VARCHAR(32),
    `pk` VARCHAR(36),
    `status` TINYINT NOT NULL DEFAULT 0,
    `gmt_create` DATETIME,
    `gmt_modified` DATETIME,
    PRIMARY KEY (`row_key`, `xid`),
    KEY `idx_branch_id` (`branch_id`),
    KEY `idx_xid` (`xid`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

业务库创建 undo_log 表

每个参与 AT 模式全局事务的数据库都必须创建此表(否则回滚时会因找不到表而失败):

CREATE TABLE `undo_log` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `branch_id` BIGINT NOT NULL COMMENT '分支事务 ID',
    `xid` VARCHAR(128) NOT NULL COMMENT '全局事务 XID',
    `context` VARCHAR(128) NOT NULL COMMENT '序列化后的上下文',
    `rollback_info` LONGBLOB NOT NULL COMMENT '回滚信息(前镜像 + 后镜像的序列化数据)',
    `log_status` INT NOT NULL COMMENT '日志状态:0-正常,1-已全局提交',
    `log_created` DATETIME NOT NULL COMMENT '日志创建时间',
    `log_modified` DATETIME NOT NULL COMMENT '日志修改时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

seata-server 配置要点

file.conf(Seata Server 端,存储在 Nacos 配置中心时无需此文件):

# 事务日志存储模式
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true
store.db.user=root
store.db.password=root

# 服务配置
service.vgroupMapping.default_tx_group=default
service.default.grouplist=127.0.0.1:8091

registry.conf(Seata Server 端注册中心配置):

registry {
  type = "nacos"
  nacos {
    application = "seata-server"
    serverAddr = "127.0.0.1:8848"
    group = "SEATA_GROUP"
    namespace = ""
    cluster = "default"
    username = "nacos"
    password = "nacos"
  }
}

config {
  type = "nacos"
  nacos {
    serverAddr = "127.0.0.1:8848"
    group = "SEATA_GROUP"
    namespace = ""
    dataId = "seataServer.properties"
    username = "nacos"
    password = "nacos"
  }
}

客户端 application.yml(每个微服务都需要配置):

seata:
  registry:
    type: nacos
    nacos:
      application: seata-server
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace: ""
      username: nacos
      password: nacos
  config:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace: ""
      data-id: seataServer.properties
  tx-service-group: default_tx_group
  service:
    vgroup-mapping:
      default_tx_group: default

完整示例 1:AT 模式——电商下单(最经典场景)

场景描述

飞翔科技电商系统,CTO 大翔提出硬性要求:用户下单操作必须同时完成以下三步,数据必须一致:

  1. 订单服务(孔蓝负责):创建订单记录 → 订单库
  2. 库存服务(白歌负责):扣减商品库存 → 库存库
  3. 账户服务(小崔负责):扣减用户余额 → 账户库

三个服务各自连接独立的数据库,任何一个环节失败,前面的操作必须全部回滚。

Maven 依赖

<!-- 每个微服务都需引入 Seata Starter -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
<!-- 远程调用使用 OpenFeign -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

订单服务(孔蓝)——TM + RM

订单服务作为全局事务的发起方(TM),同时也管理自己的分支事务(RM)。

// Feign 客户端:调用库存服务
@FeignClient(name = "storage-service", path = "/storage")
public interface StorageFeignClient {

    @PostMapping("/deduct")
    Result deduct(@RequestParam("productId") Long productId,
                  @RequestParam("count") Integer count);
}

// Feign 客户端:调用账户服务
@FeignClient(name = "account-service", path = "/account")
public interface AccountFeignClient {

    @PostMapping("/debit")
    Result debit(@RequestParam("userId") Long userId,
                 @RequestParam("amount") BigDecimal amount);
}
@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private StorageFeignClient storageClient;

    @Autowired
    private AccountFeignClient accountClient;

    /**
     * 下单核心方法——@GlobalTransactional 是全局事务入口。
     * 任何一步抛出异常,前面已提交的本地事务会被 Seata 自动回滚。
     */
    @GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
    public void createOrder(Order order) {
        // 分支事务 1:订单库——创建订单
        orderMapper.insert(order);

        // 分支事务 2:远程调用库存服务——扣减库存
        Result storageResult = storageClient.deduct(
            order.getProductId(), order.getCount());
        if (!storageResult.isSuccess()) {
            throw new RuntimeException("库存扣减失败");
        }

        // 分支事务 3:远程调用账户服务——扣减余额
        Result accountResult = accountClient.debit(
            order.getUserId(), order.getAmount());
        if (!accountResult.isSuccess()) {
            throw new RuntimeException("余额扣减失败");
        }
    }
}

库存服务(白歌)——RM

@RestController
@RequestMapping("/storage")
public class StorageController {

    @Autowired
    private StorageMapper storageMapper;

    @PostMapping("/deduct")
    @Transactional  // 本地事务
    public Result deduct(@RequestParam Long productId,
                         @RequestParam Integer count) {
        // Seata 代理会在此处自动:
        // 1. 查询前镜像
        // 2. 执行 SQL
        // 3. 查询后镜像
        // 4. 保存 UNDO LOG 到 undo_log 表
        // 5. 向 TC 注册分支事务
        int rows = storageMapper.deduct(productId, count);
        if (rows <= 0) {
            throw new RuntimeException("库存不足");
        }
        return Result.success();
    }
}

对应的 Mapper SQL:

<update id="deduct">
    UPDATE storage
    SET count = count - #{count}
    WHERE product_id = #{productId} AND count >= #{count}
</update>

账户服务(小崔)——RM

@RestController
@RequestMapping("/account")
public class AccountController {

    @Autowired
    private AccountMapper accountMapper;

    @PostMapping("/debit")
    @Transactional
    public Result debit(@RequestParam Long userId,
                        @RequestParam BigDecimal amount) {
        int rows = accountMapper.debit(userId, amount);
        if (rows <= 0) {
            throw new RuntimeException("余额不足");
        }
        return Result.success();
    }
}
<update id="debit">
    UPDATE account
    SET balance = balance - #{amount}
    WHERE user_id = #{userId} AND balance >= #{amount}
</update>

操作前后对比

没有 Seata 时的典型事故:

1. 订单服务 → INSERT INTO orders → 成功,本地事务提交
2. 库存服务 → UPDATE storage SET count = count - 1 → 成功,本地事务提交
3. 账户服务 → UPDATE account SET balance = balance - 100 → 失败!余额不足

结果:订单创建了,库存扣了,钱没扣——数据永久不一致。
     除非手写补偿代码逐一回滚前两步,否则数据库永远处于错误状态。

有 Seata 之后:

1. 订单服务 → INSERT INTO orders + 保存 UNDO LOG → 本地事务提交
2. 库存服务 → UPDATE storage + 保存 UNDO LOG → 本地事务提交
3. 账户服务 → UPDATE account → 失败!抛出异常
4. @GlobalTransactional 捕获异常 → TM 通知 TC 全局回滚
5. TC 通知库存服务的 RM → 读取 UNDO LOG → 执行反向补偿 SQL
   UPDATE storage SET count = count + 1 WHERE product_id = ?
6. TC 通知订单服务的 RM → 读取 UNDO LOG → 执行反向补偿 SQL
   DELETE FROM orders WHERE id = ?
7. 全局回滚完成,数据回到下单前的状态

测试方法

在账户服务中故意抛出异常,验证库存是否成功回滚:

// 测试代码:在 AccountController.debit() 中注入异常
@PostMapping("/debit")
@Transactional
public Result debit(@RequestParam Long userId,
                    @RequestParam BigDecimal amount) {
    int rows = accountMapper.debit(userId, amount);
    if (rows <= 0) {
        throw new RuntimeException("余额不足");
    }
    // === 故意抛出异常,模拟故障 ===
    throw new RuntimeException("模拟账户服务故障,测试全局回滚");
}

验证步骤:

  1. 查看库存表:确认库存数量已恢复(说明 UNDO LOG 补偿成功)
  2. 查看订单表:确认测试订单已被删除(说明订单库也回滚成功)
  3. 查看 undo_log 表:确认对应的 UNDO LOG 记录已被删除(Seata 回滚后自动清理)
  4. 在 Seata Server 日志中查看回滚链路

完整示例 2:TCC 模式——自定义补偿逻辑

场景描述

账户服务(小崔)使用 TCC 模式处理余额扣减。与 AT 模式不同,TCC 需要手动编码实现 Try(预留资源)→ Confirm(确认提交)→ Cancel(补偿回滚) 三个阶段。扣款操作需要先冻结资金(Try),二阶段再确认扣款(Confirm)或解冻(Cancel)。

@LocalTCC + @TwoPhaseBusinessAction 注解

// TCC 接口:声明三阶段行为
@LocalTCC
public interface AccountTccAction {

    @TwoPhaseBusinessAction(
        name = "accountTccAction",
        commitMethod = "commit",
        rollbackMethod = "rollback"
    )
    boolean prepareDebit(
        @BusinessActionContextParameter(paramName = "userId") Long userId,
        @BusinessActionContextParameter(paramName = "amount") BigDecimal amount
    );

    boolean commit(BusinessActionContext context);

    boolean rollback(BusinessActionContext context);
}

Try / Confirm / Cancel 方法实现

@Component
public class AccountTccActionImpl implements AccountTccAction {

    @Autowired
    private AccountMapper accountMapper;

    /**
     * Try 阶段:冻结余额。
     * available_amount - N,frozen_amount + N。
     * 此时用户余额尚未真正减少,只是被"锁定"。
     */
    @Override
    @Transactional
    public boolean prepareDebit(Long userId, BigDecimal amount) {
        // 防悬挂:检查是否存在 Cancel 记录(Cancel 先于 Try 到达的情况)
        if (accountMapper.existsTccRecord(userId, "CANCEL")) {
            return true; // Cancel 已先行到达,拒绝执行 Try
        }

        // 幂等:检查是否已经 Try 过
        if (accountMapper.existsTccRecord(userId, "TRY")) {
            return true;
        }

        // 插入 TCC 控制记录(用于防悬挂和幂等)
        accountMapper.insertTccRecord(userId, amount, "TRY");

        // 冻结余额
        int rows = accountMapper.freeze(userId, amount);
        if (rows == 0) {
            throw new RuntimeException("余额不足");
        }
        return true;
    }

    /**
     * Confirm 阶段:确认扣款。
     * frozen_amount - N,真正扣减冻结的余额。
     */
    @Override
    @Transactional
    public boolean commit(BusinessActionContext context) {
        Long userId = Long.valueOf(context.getActionContext("userId").toString());
        BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());

        // 幂等:已确认过则直接返回
        if ("CONFIRM".equals(accountMapper.getTccRecordStatus(userId))) {
            return true;
        }

        accountMapper.commitDebit(userId, amount);
        accountMapper.updateTccRecordStatus(userId, "CONFIRM");
        return true;
    }

    /**
     * Cancel 阶段:解冻余额。
     * frozen_amount - N,available_amount + N。
     */
    @Override
    @Transactional
    public boolean rollback(BusinessActionContext context) {
        Long userId = Long.valueOf(context.getActionContext("userId").toString());
        BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());

        String status = accountMapper.getTccRecordStatus(userId);
        if (status == null) {
            // 空回滚:Try 从未执行,Cancel 却到达(网络超时后 TC 补发 Cancel)
            accountMapper.insertTccRecord(userId, BigDecimal.ZERO, "CANCEL_EMPTY");
            return true;
        }
        if ("CANCEL".equals(status)) {
            return true; // 幂等:已回滚过
        }

        accountMapper.unfreeze(userId, amount);
        accountMapper.updateTccRecordStatus(userId, "CANCEL");
        return true;
    }
}

与 AT 模式对比

维度AT 模式TCC 模式
侵入性低,只需 @GlobalTransactional + undo_log 表高,需手动实现 Try / Confirm / Cancel
性能中,有全局锁开销高,无全局锁,资源预留后可并行
补偿代码自动生成(UNDO LOG)手写
适用场景通用数据库操作对性能要求高的金融/支付场景
数据库要求关系型数据库 + undo_log 表无特殊要求
回滚语义物理回滚(还原数据行)业务回滚(解冻/退款等业务操作)

完整示例 3:事务回滚失败——并发冲突场景解析

场景描述

飞翔科技爆款新品上线,大翔和小崔同时下单购买同一种商品。两个全局事务同时扣减同一条库存记录:

  • 全局事务 A(大翔下单):扣减 product_id=100 的库存
  • 全局事务 B(小崔下单):扣减 product_id=100 的库存

全局锁冲突演示

时间线 →

全局事务 A(XID=XA)                    全局事务 B(XID=XB)
     │                                       │
T1:  │ UPDATE stock SET count=count-1        │
     │ WHERE product_id=100                  │
     │                                       │
T2:  │ 向 TC 申请全局锁                      │
     │ row_key = "stock_db:stock:100"       │
     │ TC 无冲突 → 获锁                       │
     │                                       │
T3:  │ 本地事务提交                           │
     │                                       │
T4:  │                                       │ UPDATE stock SET count=count-1
     │                                       │ WHERE product_id=100
T5:  │                                       │ 向 TC 申请全局锁
     │                                       │ row_key = "stock_db:stock:100"
     │                                       │ TC → 冲突!XA 持有该锁
     │                                       │
T6:  │                                       │ 进入等待重试循环:
     │                                       │   retry 1... retry 2... retry 3...
     │                                       │
T7:  │ 全局提交 → TC 释放锁                    │
     │                                       │
T8:  │                                       │ 重试成功!获锁 → 执行 SQL
     │                                       │ → 保存 UNDO LOG → 本地事务提交

lock_table 表数据查看

在并发冲突发生时,可以查询 Seata Server 的 lock_table 观察锁持有情况:

-- 查看当前持有的全局锁
SELECT row_key, xid, transaction_id, branch_id,
       table_name, pk, gmt_create
FROM lock_table
WHERE row_key LIKE '%stock%100%';

结果示例:

row_keyxidbranch_idtable_namepk
stock_db^^^stock^^^100192.168.1.1:8091:20123456782012345679stock100

解决方案

方案一:调大全局锁重试次数

seata:
  client:
    rm:
      lock:
        retryInterval: 10       # 重试间隔(ms)
        retryTimes: 60          # 最大重试次数(默认 30)
        retryPolicyBranchRollbackOnConflict: true

方案二:在业务 SQL 中使用 SELECT FOR UPDATE(推荐)

在扣减库存前先锁定行,配合全局锁形成双重保护:

<!-- 在扣减前先 SELECT FOR UPDATE 获取数据库行锁 -->
<select id="lockAndGetStock" resultType="int">
    SELECT count FROM storage
    WHERE product_id = #{productId}
    FOR UPDATE
</select>

<update id="deduct">
    UPDATE storage
    SET count = count - #{count}
    WHERE product_id = #{productId} AND count >= #{count}
</update>
@PostMapping("/deduct")
@Transactional
public Result deduct(@RequestParam Long productId,
                     @RequestParam Integer count) {
    // 先获取数据库行锁
    Integer currentStock = storageMapper.lockAndGetStock(productId);
    if (currentStock < count) {
        throw new RuntimeException("库存不足");
    }
    // 再执行扣减
    storageMapper.deduct(productId, count);
    return Result.success();
}

SELECT FOR UPDATE 在 @Transactional 内获取的是数据库行锁,与 Seata 的全局锁形成互补:前者保证一阶段内不被同库事务干扰,后者保证跨全局事务的隔离性。


易错场景

1. undo_log 表未创建导致回滚失败(最常见的初学问题)

现象:发起全局事务后,业务正常提交,但回滚时报 Table 'xxx.undo_log' doesn't exist。

原因:AT 模式回滚时需要从 undo_log 表读取前镜像生成反向补偿 SQL,但目标数据库中没有这张表。

解决:在每个参与 AT 模式全局事务的数据库中都执行 DDL 创建 undo_log 表。特别注意:新增业务库时容易遗漏。

2. 全局事务日志表数据膨胀未清理

现象:Seata Server 的 global_table、branch_table、lock_table 持续增长,查询变慢。

原因:Seata Server 默认不会自动清理历史事务日志。高并发场景下每天可能产生数十万条记录。

解决:配置定期清理任务。Seata 1.5+ 支持配置 store.db.globalTable / store.db.branchTable 的保留天数。或在运维层面设置定时任务:

-- 清理 7 天前的全局事务日志
DELETE FROM global_table WHERE gmt_modified < DATE_SUB(NOW(), INTERVAL 7 DAY);
DELETE FROM branch_table WHERE gmt_modified < DATE_SUB(NOW(), INTERVAL 7 DAY);
DELETE FROM lock_table WHERE gmt_modified < DATE_SUB(NOW(), INTERVAL 7 DAY);

3. @GlobalTransactional timeout 设置过短

现象:正常的长时间业务操作被 TC 判定超时并强制回滚。

原因:@GlobalTransactional 默认超时时间为 60 秒(Seata 默认 server.max.commit.retry.timeout 为 -1 表示无限,但实际受全局事务超时控制)。复杂的业务流程(如批量导入、大文件处理)可能超过默认超时。

解决:根据业务特点适当增大超时:

@GlobalTransactional(name = "batch-import", timeoutMills = 300000) // 5 分钟
public void batchImport(List<Record> records) { ... }

4. TCC 模式空回滚和悬挂问题

空回滚:Try 阶段因网络超时未执行,但 TC 仍然触发了 Cancel。此时 Cancel 到达时,Try 的资源预留从未发生——如果 Cancel 方法不做判断直接解冻,会导致数据错误。

悬挂:Cancel 先于 Try 到达。网络延迟导致 Try 请求超时,TC 先发出 Cancel 指令,Cancel 执行完毕后 Try 请求才到达——此时 Try 会错误地冻结已被解冻的资源。

解决:在 Try 方法中检查是否已有 Cancel 记录(悬挂判断),在 Cancel 方法中检查 Try 是否执行过(空回滚判断)。通过一张 TCC 控制表记录 Try / Confirm / Cancel 的状态:

// Try: 防悬挂
if (tccMapper.existsRecord(bizId, "CANCEL")) {
    return true; // Cancel 已先行到达,拒绝执行 Try
}

// Cancel: 防空回滚
if (tccMapper.getStatus(bizId) == null) {
    tccMapper.insertRecord(bizId, "CANCEL_EMPTY");
    return true;
}

// Try / Confirm / Cancel: 幂等
if (tccMapper.getStatus(bizId).equals(currentPhase)) {
    return true;
}

5. AT 模式 SELECT FOR UPDATE 的必要性

问题:AT 模式的默认隔离级别是读已提交(Read Committed)。在以下场景会出现脏读:

全局事务 A                           全局事务 B
  UPDATE stock SET count=5             SELECT count FROM stock
  WHERE product_id=100                 WHERE product_id=100
  (count 变为 5,但尚未全局提交)         → 读到了 5(脏读!)
  全局回滚,count 恢复为 10

解决:对需要严格一致性的读操作使用 SELECT ... FOR UPDATE。此语句会等待全局锁释放后才返回数据,从而保证读到的是已全局提交的数据。

6. @Transactional 与 @GlobalTransactional 混合使用

问题:在同一个方法或调用链中混合使用声明式事务和分布式事务,回滚行为可能不一致。

// 错误示例
@Transactional  // 本地事务
@GlobalTransactional  // 全局事务
public void createOrder(Order order) {
    orderMapper.insert(order);      // 受两个事务管理
    accountClient.debit(...);        // 只受全局事务管理
}
// 当 debit 失败时,@Transactional 已提交或已在独立事务中,
// 可能出现 insert 未被正确回滚的情况

最佳实践:AT 模式下,TM 所在方法只使用 @GlobalTransactional,RM 所在方法使用 @Transactional(管理本地事务)。不要在同一个方法上同时标注两个注解。

// 正确做法
@Service
public class OrderService {
    @GlobalTransactional  // TM:只标注全局事务
    public void createOrder(Order order) { ... }
}

@RestController
public class StorageController {
    @PostMapping("/deduct")
    @Transactional  // RM:只标注本地事务
    public Result deduct(...) { ... }
}

面试考点

1. AT 模式的二阶段提交与 XA 的二阶段提交有何区别?

维度Seata ATXA
一阶段行为提交本地事务,释放数据库连接和本地锁仅 prepare,不提交,持续持有数据库锁
二阶段提交异步删除 UNDO LOG(极轻量)提交本地事务
二阶段回滚根据 UNDO LOG 前镜像反向补偿回滚本地事务
锁持有时间短(一阶段提交即释放数据库本地锁,转为 Seata 全局锁)长(一阶段到二阶段之间始终持有数据库锁)
性能高(数据库连接和锁快速释放)低(长时间占用数据库资源)
对数据库要求需额外创建 undo_log 表数据库本身需支持 XA 协议

核心差异:AT 在一阶段就提交了本地事务,把"未确定状态"从数据库层转移到 Seata 的 UNDO LOG 层。这使得数据库锁持有时间极短,性能远超 XA。

2. AT 模式如何保证读已提交隔离级别?

AT 模式默认全局事务的隔离级别为读已提交(Read Committed)。原因:

  • 一阶段本地事务已提交,其他事务可以读到该数据(读已提交)
  • 如果全局事务最终回滚,UNDO LOG 会将数据补偿回去,但中间时刻其他事务读到了"未全局提交"的数据

提升隔离级别:在 SELECT 语句后加 FOR UPDATE,该语句会向 TC 申请全局锁——如果该行数据被其他未完成的全局事务持有全局锁,则当前 SELECT FOR UPDATE 会等待,从而读到已全局提交的数据。

3. TCC 模式三大问题:空回滚、悬挂、幂等,如何解决?

问题成因解决方案
空回滚Try 因网络超时未执行,Cancel 却到达Cancel 方法检查是否已有 Try 记录,无则记录"空回滚"标记并直接返回
悬挂Cancel 先于 Try 到达(网络导致 Try 请求延迟)Try 方法检查是否已有 Cancel 记录,有则拒绝执行
幂等网络重试导致同一 Try / Confirm / Cancel 被多次调用通过 TCC 控制表的状态字段做去重判断("TRY" → 不重复执行)

核心手段:引入一张 TCC 事务控制表,记录业务主键 + 操作阶段(TRY / CONFIRM / CANCEL)+ 状态,所有阶段方法先查表再做判断。

4. Seata 事务分组(tx-service-group)和集群(cluster)的概念及路由机制

  • tx-service-group:事务分组,是客户端的逻辑分组标识。客户端通过 seata.tx-service-group=default_tx_group 配置。
  • vgroup-mapping:事务分组到 TC 集群的映射。例如 default_tx_group → default 集群。
  • cluster(集群):TC 的物理部署单元。同一个 cluster 下的 TC 实例互为备份,共享事务日志。

路由流程:

客户端 tx-service-group → vgroup-mapping → TC cluster
       ↓                      ↓                ↓
 default_tx_group   →    default cluster  →  从 Nacos 获取该 cluster
                                              下的可用 TC 实例列表

这种设计使得同一个应用在不同环境(开发/测试/生产)只需修改 tx-service-group 的值,无需改动其他配置。

5. 全局锁 vs 数据库行锁的区别和配合

维度全局锁(Seata)数据库行锁(如 InnoDB)
作用范围跨数据库、跨服务单个数据库实例内
管理方Seata Server(TC)数据库引擎
存储位置lock_table(Seata 库)数据库内存 / 锁表
生命周期一阶段注册 → 全局事务完成事务内有效
获取时机一阶段提交前向 TC 申请SQL 执行时自动获取

配合机制:一阶段执行业务 SQL 时,数据库行锁先获取;一阶段提交前,向 TC 申请全局锁。本地事务提交后数据库行锁释放,但全局锁继续持有直到全局事务完成。这样设计保证了:

  • 一阶段内:数据库行锁防止同库并发修改
  • 一阶段后:全局锁防止跨全局事务的并发修改
  • SELECT FOR UPDATE 请求全局锁,将隔离级别提升到读已提交之上

6. 什么场景不适合用 AT 模式?

场景原因替代方案
使用不支持标准 SQL 解析的 ORMSeata 需要解析 SQL 生成前/后镜像,非标准 ORM 或动态拼接的复杂 SQL 可能解析失败TCC
频繁表结构变更表结构变更期间,UNDO LOG 中序列化的前后镜像与当前表结构不匹配,导致回滚失败TCC 或 Saga
超高并发热点数据全局锁在热点数据行上成为瓶颈,大量事务排队等待全局锁TCC(预留资源方式)
非关系型数据库AT 模式强依赖关系型数据库的 SQL 解析和本地事务TCC 或 Saga
超长事务(数小时)UNDO LOG 长期占用存储空间,且回滚时数据可能已被多次修改Saga(正向+补偿流水线)

参考资源:Seata 官方文档 https://seata.io | Spring Cloud Alibaba 官方文档 https://sca.aliyun.com

上一页
Sentinel降级与熔断
下一页
RocketMQ消息驱动