面试官:分布式事务怎么保证一致性?别再说 2PC 和 TCC 了——Seata AT 模式的正确打开方式

引言

面试现场,面试官抛出经典问题:"分布式事务怎么保证一致性?"

90% 的候选人脱口而出:"2PC 和 TCC。"

面试官追问:"你项目里用的哪个?"

候选人:"……了解过原理,没用过。"

这篇文章不讲理论,只讲实战。从一个转账场景出发,带你走完 2PC 的坑、TCC 的重、最后落地 Seata AT 模式——对业务零侵入,自动回滚,这才是生产环境真正在用的方案。


一、问题背景:跨服务转账

1.1 场景描述

用户 A 向用户 B 转账 100 元,涉及两个服务:

转账服务(Transfer Service)
  ├── 账户服务 A(Account Service A):扣减 A 的余额
  └── 账户服务 B(Account Service B):增加 B 的余额

两个服务连的是不同的数据库,本地事务管不了跨库操作。

1.2 问题在哪

@Service
public class TransferService {

    @Autowired
    private AccountServiceA accountServiceA;  // RPC 调用
    @Autowired
    private AccountServiceB accountServiceB;  // RPC 调用

    @Transactional  // 本地事务,只能管自己的库
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        accountServiceA.decrease(fromId, amount);   // A 扣钱
        // 异常发生在这里 ↓
        int i = 1 / 0;
        accountServiceB.increase(toId, amount);     // B 加钱(没执行)
    }
}

问题:A 扣了钱,B 没加上,钱凭空消失了。

1.3 三个候选方案

方案一致性侵入性性能复杂度
2PC(XA)强一致差(同步阻塞)
TCC强一致高(手写 3 个方法)
Seata AT强一致低(注解)

二、2PC:教科书里的理想主义

2.1 原理

两阶段提交:

阶段一:准备(Prepare)
  协调者 → 所有参与者:"准备提交了吗?"
  参与者:"准备好了" / "不行"

阶段二:提交/回滚(Commit/Rollback)
  如果都准备好了 → 协调者发"提交"
  如果有任何一个没准备好 → 协调者发"回滚"

2.2 为什么生产环境很少用

缺陷 1:同步阻塞

参与者执行 Prepare 后,会锁住资源,直到收到 Commit/Rollback。
如果协调者挂了,参与者会一直锁着,等其他参与者也卡住。

转账场景下:A 账户的钱被锁住,整个账户服务卡死。

缺陷 2:单点故障

协调者挂了,所有参与者都处于"等待"状态,无法自行决定提交还是回滚。

缺陷 3:数据不一致

阶段二中,协调者发送 Commit,部分参与者收到并提交了,部分没收到。
此时数据就不一致了。

MySQL XA 的真实表现

指标本地事务XA 事务
单次转账耗时5ms50-100ms
并发能力低(资源锁定)
死锁概率

结论:2PC 理论完美,工程上几乎不用。


三、TCC:能做但太重

3.1 原理

Try-Confirm-Cancel,把一个操作拆成三个:

// 转账的 TCC 实现
public interface TransferTccService {

    // Try:预留资源(冻结金额)
    boolean tryTransfer(Long fromId, Long toId, BigDecimal amount);

    // Confirm:确认提交(扣减冻结,实际转账)
    boolean confirmTransfer(Long fromId, Long toId, BigDecimal amount);

    // Cancel:取消(解冻)
    boolean cancelTransfer(Long fromId, Long toId, BigDecimal amount);
}

3.2 业务侵入严重

一个转账要写三个方法,每个方法都要实现:

// Try:冻结 A 的 100 元
public boolean tryTransfer(Long fromId, Long toId, BigDecimal amount) {
    // 1. 检查 A 余额是否足够
    Account account = accountMapper.selectById(fromId);
    if (account.getBalance().compareTo(amount) < 0) {
        throw new RuntimeException("余额不足");
    }
    // 2. 扣减余额,增加冻结金额
    accountMapper.decreaseBalance(fromId, amount);
    accountMapper.increaseFrozen(fromId, amount);
    // B 的 Try 可以空操作,也可以预先增加
    return true;
}

// Confirm:实际转账
public boolean confirmTransfer(Long fromId, Long toId, BigDecimal amount) {
    // 1. A:扣减冻结金额
    accountMapper.decreaseFrozen(fromId, amount);
    // 2. B:增加余额
    accountMapper.increaseBalance(toId, amount);
    return true;
}

// Cancel:解冻
public boolean cancelTransfer(Long fromId, Long toId, BigDecimal amount) {
    // A:解冻,恢复余额
    accountMapper.decreaseFrozen(fromId, amount);
    accountMapper.increaseBalance(fromId, amount);
    return true;
}

3.3 还要解决的坑

  • 空回滚:Try 没执行,Cancel 却被调用了
  • 幂等性:Confirm/Cancel 可能被重试多次
  • 悬挂:Cancel 先于 Try 执行

每个坑都要写代码解决。一个转账业务,TCC 要写 3 倍的代码 + 额外的容错逻辑

3.4 适用场景

只有资源需要"预留"的场景才适合 TCC:

场景是否适合 TCC原因
转账不适合钱可以直接扣,不需要预留
库存扣减适合真实库存有限,需要预占
机票预订适合座位有限,需要锁座

大部分业务场景,并不需要"预留"资源,直接扣减就行。这时候 AT 模式更合适。


四、Seata AT 模式:生产首选

4.1 为什么选 AT

对比项2PCTCCAT
业务侵入高(3 方法)无(注解)
一致性
性能
实现复杂度
学习成本

AT = Automatic Transaction,自动事务。核心思想:自动生成回滚 SQL,对业务零侵入。

4.2 核心概念

┌─────────────────────────────────────────────┐
│            TC(Transaction Coordinator)       │
│              事务协调器(独立部署)              │
└─────────────────────────────────────────────┘
            ▲                    ▲
            │ 注册/上报            │ 注册/上报
            │                    │
┌────────────┴──────┐  ┌──────────┴──────────┐
│   TM(事务管理器)   │  │   RM(资源管理器)     │
│  开启/提交/回滚     │  │   分支事务执行+回滚   │
│  @GlobalTransactional │  │  自动生成 undo_log  │
└───────────────────┘  └─────────────────────┘
       转账服务                账户服务 A/B

三个角色:

  • TC(Transaction Coordinator):事务协调器,独立部署,负责维护全局事务状态
  • TM(Transaction Manager):事务管理器,开启全局事务,决定提交还是回滚
  • RM(Resource Manager):资源管理器,每个微服务都是一个 RM,负责本地事务执行和回滚

4.3 工作流程

1. TM 向 TC 开启全局事务,获得 XID(全局事务 ID)
2. XID 通过 RPC 请求传播到各个 RM
3. 每个 RM 执行本地事务前,先记录 undo_log(前镜像)
4. RM 执行本地业务 SQL
5. RM 记录 undo_log(后镜像),向 TC 注册分支事务
6. 所有 RM 执行完毕,TM 向 TC 发起提交/回滚
7. 如果提交:异步删除 undo_log
8. 如果回滚:根据 undo_log 生成反向 SQL 执行回滚

4.4 undo_log:自动回滚的魔法

以扣减 A 账户余额为例:

执行前(前镜像):

SELECT * FROM account WHERE id = 1;
-- id=1, balance=500, frozen=0

执行业务 SQL

UPDATE account SET balance = balance - 100 WHERE id = 1;
-- id=1, balance=400, frozen=0

执行后(后镜像):

SELECT * FROM account WHERE id = 1;
-- id=1, balance=400, frozen=0

生成的 undo_log(存储在 undo_log 表):

{
  "branchId": 123,
  "beforeImage": {"id": 1, "balance": 500, "frozen": 0},
  "afterImage":  {"id": 1, "balance": 400, "frozen": 0},
  "sqlType": "UPDATE"
}

如果全局事务回滚,Seata 根据 beforeImage 自动生成反向 SQL:

UPDATE account SET balance = 500 WHERE id = 1;

完全自动,业务代码不用管。


五、实战:Seata AT 转账

5.1 环境搭建

docker-compose.yml

version: '3.8'

services:
  # Seata Server(TC)
  seata-server:
    image: seataio/seata-server:2.0.0
    container_name: seata-server
    ports:
      - "8091:8091"
      - "7091:7091"
    environment:
      - SEATA_IP=seata-server
      - STORE_MODE=db
      - SEATA_CONFIG_NAME=file:/root/seata-config/registry

  # 账户服务 A 的数据库
  mysql-a:
    image: mysql:8.0
    container_name: mysql-a
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: account_a
    ports:
      - "3306:3306"

  # 账户服务 B 的数据库
  mysql-b:
    image: mysql:8.0
    container_name: mysql-b
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: account_b
    ports:
      - "3307:3306"

  # Nacos(Seata 配置中心)
  nacos:
    image: nacos/nacos-server:v2.3.0
    container_name: nacos
    ports:
      - "8848:8848"
    environment:
      - MODE=standalone

5.2 数据库准备

每个业务库都要建 undo_log 表

-- account_a 库和 account_b 库都要执行
CREATE TABLE undo_log (
    branch_id     BIGINT       NOT NULL COMMENT '分支事务ID',
    xid           VARCHAR(128) NOT NULL COMMENT '全局事务ID',
    context       VARCHAR(128) NOT NULL COMMENT '上下文',
    rollback_info LONGBLOB     NOT NULL COMMENT '回滚信息',
    log_status    INT          NOT NULL COMMENT '状态',
    log_created   DATETIME(6)  NOT NULL COMMENT '创建时间',
    log_modified  DATETIME(6)  NOT NULL COMMENT '修改时间',
    UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE=InnoDB COMMENT='AT模式 undo_log 表';

-- 账户表
CREATE TABLE account (
    id        BIGINT PRIMARY KEY,
    user_id   BIGINT,
    balance   DECIMAL(10,2),
    frozen    DECIMAL(10,2) DEFAULT 0
) ENGINE=InnoDB;

5.3 添加依赖

<!-- Seata Spring Boot Starter -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
    <version>2023.0.1.0</version>
</dependency>

<!-- Seata AT 模式依赖 -->
<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-spring-boot-starter</artifactId>
    <version>2.0.0</version>
</dependency>

5.4 配置文件

seata:
  enabled: true
  application-id: transfer-service
  tx-service-group: my_tx_group
  service:
    vgroup-mapping:
      my_tx_group: default
    grouplist:
      default: seata-server:8091
  data-source-proxy-mode: AT  # 关键:AT 模式

spring:
  datasource:
    url: jdbc:mysql://mysql-a:3306/account_a
    username: root
    password: root123
    driver-class-name: com.mysql.cj.jdbc.Driver

5.5 业务代码

转账服务(TM)

@Service
public class TransferService {

    @Autowired
    private AccountClient accountClient;  // Feign 调用账户服务

    /**
     * 转账入口,开启全局事务
     */
    @GlobalTransactional  // 关键:开启全局事务
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // 1. A 账户扣钱(调用账户服务 A)
        accountClient.decrease(fromId, amount);

        // 2. 模拟异常
        if (amount.compareTo(new BigDecimal("1000")) > 0) {
            throw new RuntimeException("超过单笔限额");
        }

        // 3. B 账户加钱(调用账户服务 B)
        accountClient.increase(toId, amount);
    }
}

账户服务 A(RM)

@Service
public class AccountServiceA {

    @Autowired
    private AccountMapper accountMapper;

    /**
     * 注意:这里用本地事务注解即可
     * Seata 会自动代理 DataSource,记录 undo_log
     */
    @Transactional
    public void decrease(Long userId, BigDecimal amount) {
        Account account = accountMapper.selectByUserId(userId);
        if (account.getBalance().compareTo(amount) < 0) {
            throw new RuntimeException("余额不足");
        }
        accountMapper.decreaseBalance(userId, amount);
    }
}

账户服务 B(RM)

@Service
public class AccountServiceB {

    @Autowired
    private AccountMapper accountMapper;

    @Transactional
    public void increase(Long userId, BigDecimal amount) {
        accountMapper.increaseBalance(userId, amount);
    }
}

Feign 客户端(XID 自动传播):

@FeignClient(name = "account-service")
public interface AccountClient {

    @PostMapping("/account/decrease")
    void decrease(@RequestParam Long userId, @RequestParam BigDecimal amount);

    @PostMapping("/account/increase")
    void increase(@RequestParam Long userId, @RequestParam BigDecimal amount);
}

XID 自动通过 HTTP Header 传播,不需要手动传。

5.6 验证

# 正常转账
curl -X POST 'http://localhost:8080/transfer?fromId=1&toId=2&amount=100'
# A 余额 500→400,B 余额 300→400

# 异常转账(超过 1000 触发回滚)
curl -X POST 'http://localhost:8080/transfer?fromId=1&toId=2&amount=1500'
# A 扣了钱 → 触发异常 → 全局事务回滚 → A 余额恢复

查看 Seata 控制台 http://localhost:7091

全局事务 XID: 192.168.1.10:8091:123456789
  分支事务 1: account_a.decrease → 已回滚
  分支事务 2: account_b.increase → 未执行
状态:Rollbacked

六、@GlobalTransactional 使用误区

6.1 误区一:所有方法都加 @GlobalTransactional

// ❌ 错误:内层方法也加
@Service
public class TransferService {

    @GlobalTransactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        accountClient.decrease(fromId, amount);
        accountClient.increase(toId, amount);
    }
}

// 内层服务也加(错误)
@Service
public class AccountServiceA {

    @GlobalTransactional  // ❌ 不应该加!
    @Transactional
    public void decrease(Long userId, BigDecimal amount) {
        accountMapper.decreaseBalance(userId, amount);
    }
}

正确做法:只有入口方法(TM)加 @GlobalTransactional,内层方法(RM)只加 @Transactional

@Service
public class AccountServiceA {

    @Transactional  // ✅ 本地事务即可
    public void decrease(Long userId, BigDecimal amount) {
        accountMapper.decreaseBalance(userId, amount);
    }
}

原因:内层方法加 @GlobalTransactional 会开启新的全局事务,导致事务传播混乱。

6.2 误区二:忘记加 @Transactional

// ❌ 错误:RM 方法没有本地事务
@Service
public class AccountServiceA {

    // 漏了 @Transactional
    public void decrease(Long userId, BigDecimal amount) {
        accountMapper.decreaseBalance(userId, amount);
    }
}

原因:AT 模式的 undo_log 是在本地事务提交时一起记录的。如果没有 @Transactional,undo_log 不会和业务 SQL 在同一事务里,回滚时数据不一致。

6.3 误区三:手动控制连接

// ❌ 错误:手动获取连接,绕过了 Seata 代理
@Service
public class AccountServiceA {

    public void decrease(Long userId, BigDecimal amount) throws SQLException {
        Connection conn = DriverManager.getConnection(url, user, pwd);
        PreparedStatement ps = conn.prepareStatement("UPDATE account SET balance = balance - ? WHERE user_id = ?");
        ps.setBigDecimal(1, amount);
        ps.setLong(2, userId);
        ps.executeUpdate();
        conn.close();
    }
}

原因:Seata AT 通过代理 DataSource 来记录 undo_log。手动获取连接绕过了代理,Seata 感知不到,回滚失败。

6.4 误区四:在 @GlobalTransactional 中执行耗时操作

// ❌ 错误:全局事务中调用外部接口,耗时长
@GlobalTransactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    accountClient.decrease(fromId, amount);

    // 调用短信通知(耗时 5 秒)
    smsClient.notify(fromId, "转出成功");  // ❌ 全局事务超时

    accountClient.increase(toId, amount);
}

原因:全局事务默认超时 60 秒。在事务中调用慢接口会导致超时回滚。

正确做法:非核心操作放到事务外。

@GlobalTransactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    accountClient.decrease(fromId, amount);
    accountClient.increase(toId, amount);
}

// 事务外调用
public void transferAndNotify(Long fromId, Long toId, BigDecimal amount) {
    transfer(fromId, toId, amount);  // 事务
    smsClient.notify(fromId, "转出成功");  // 事务外
}

6.5 误区五:嵌套调用同一个 RM

// ❌ 错误:A 调 B,B 又调 A
@Service
public class ServiceA {
    @GlobalTransactional
    public void methodA() {
        serviceB.methodB();  // 调 B
    }
}

@Service
public class ServiceB {
    public void methodB() {
        serviceA.methodC();  // 又调回 A
    }
}

原因:循环依赖会导致死锁或事务状态混乱。


七、AT 模式的坑与注意点

7.1 写隔离(脏写问题)

场景

事务 T1:UPDATE account SET balance = 400 WHERE id = 1
事务 T2:UPDATE account SET balance = 300 WHERE id = 1  ← 脏读 T1 未提交的数据

AT 模式的解决:全局锁

T1 执行时,先获取 id=1 的全局锁。
T2 执行前,也要获取全局锁,发现被 T1 占用 → 等待。
T1 提交/回滚后,释放全局锁,T2 才能执行。

7.2 读隔离(脏读问题)

场景

事务 T1:UPDATE account SET balance = 400 WHERE id = 1(未提交)
事务 T2:SELECT balance FROM account WHERE id = 1  ← 读到 400(脏读)

AT 模式默认是读未提交

解决方案:用 @GlobalLock + @Transactional 强制读已提交。

@GlobalLock  // 先查后改的场景,加全局锁
@Transactional
public Account selectForUpdate(Long id) {
    return accountMapper.selectById(id);
}

7.3 不支持的 SQL

AT 模式通过解析 SQL 生成 undo_log,以下 SQL 不支持:

SQL 类型是否支持原因
INSERT-
UPDATE-
DELETE-
SELECT FOR UPDATE-
带子查询的 UPDATE解析复杂
存储过程无法解析
多表 JOIN UPDATE无法确定回滚范围

7.4 性能影响

操作本地事务AT 模式说明
单次 SQL5ms15-20ms多了 undo_log 记录
远程 RPC-50-100msTC 通信
回滚5ms20-30ms解析 undo_log + 反向 SQL

全局事务不要包太多操作,控制在 3-5 个分支事务内。


八、AT vs TCC vs SAGA:选哪个

维度ATTCCSAGA
一致性最终一致
侵入性
性能
适用场景大部分业务资源预留长事务
复杂度

8.1 AT 适合

  • 90% 的常规业务(转账、订单、库存)
  • 不想改业务代码
  • 团队对分布式事务不熟

8.2 TCC 适合

  • 库存扣减(需要预占)
  • 机票/酒店预订(需要锁资源)
  • 资源有限,需要先占后用

8.3 SAGA 适合

  • 长事务(流程长,跨度大)
  • 涉及第三方系统(无法 undo)
  • 对性能要求高,容忍最终一致

九、总结

选择决策树

是否需要强一致性?
├── 否 → 消息队列 + 本地事务表(最终一致)
└── 是 → 业务能否接受零侵入?
    ├── 是 → Seata AT 模式(生产首选)
    └── 否 → 是否需要资源预留?
        ├── 是 → TCC
        └── 否 → SAGA

三种方案一句话总结

方案一句话
2PC理论完美,工程不用
TCC能用但太重,业务侵入大
AT生产首选,零侵入自动回滚

面试正确回答

面试官问:"分布式事务怎么保证一致性?"

回答:"我们生产环境主要用 Seata AT 模式。它通过全局事务协调器+分支事务+undo_log 表实现,对业务零侵入,只需要在入口方法加 @GlobalTransactional 注解。相比 2PC 没有同步阻塞问题,相比 TCC 不用手写 try/confirm/cancel 三个方法。核心原理是 RM 执行本地事务时自动记录前镜像和后镜像,如果全局事务回滚,根据 undo_log 自动生成反向 SQL 回滚。需要注意全局事务超时、写隔离用全局锁、以及不要在事务里做耗时操作。"

这个回答,比一句"2PC 和 TCC"强 10 倍。

互动话题:你们项目用的哪种分布式事务方案?有没有踩过 Seata 的坑?欢迎留言讨论!


参考资料


标题:面试官:分布式事务怎么保证一致性?别再说 2PC 和 TCC 了——Seata AT 模式的正确打开方式
作者:jiangyi
地址:http://jiangyi.space/articles/2026/08/08/1786155894220.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消