【Java项目技术亮点】TCC分布式事务模式

写在前面:分布式事务这东西,理论上谁都能讲两句,但真到了生产环境,能落地的人不多。我见过太多项目用最终一致性当借口,该扣的库存没扣,该加的积分没加,最后对账发现一堆烂账。TCC模式虽然侵入性强,但它能给业务一个明确的一致性保证。今天咱们把TCC从原理到实战彻底搞清楚。

文章目录


一、为什么需要分布式事务?

1.1 一个电商下单的真实困境

先看一个最经典的场景:用户下单。

复制代码
电商下单流程(涉及三个独立服务):

用户点击"下单"
    |
    ├──> 订单服务:创建订单(MySQL)
    |
    ├──> 库存服务:扣减库存(MySQL)
    |
    └──> 积分服务:增加积分(MySQL)

问题来了:
- 订单创建成功了,库存扣减失败了 → 用户下了单但没扣库存,超卖!
- 库存扣减成功了,积分增加失败了 → 用户付了钱但没拿到积分,投诉!
- 三个操作分布在不同的数据库,本地事务管不了!

这就是分布式事务要解决的核心问题:跨服务、跨数据库的操作,如何保证要么全部成功,要么全部失败?

1.2 生活类比:转账

A给B转100元,这个操作其实包含两步:

  1. A的账户扣100元
  2. B的账户加100元

这两步必须同时成功或同时失败。如果A扣了100但B没加上,钱就凭空消失了;如果B加了100但A没扣,钱就凭空多出来了。

本地事务(同一个数据库)用@Transactional就能搞定。但如果A在工商银行,B在建设银行,两个银行是独立的系统,这就是分布式事务了。

1.3 本地事务 vs 分布式事务

对比维度 本地事务 分布式事务
参与方 单个数据库/服务 多个数据库/服务
一致性保证 ACID(强一致性) 视方案而定
实现方式 @Transactional TCC/Saga/消息等
复杂度
性能影响 较大
故障影响范围 单个服务 可能影响多个服务

二、分布式事务理论

2.1 CAP定理

CAP定理是分布式系统的基石,三个字母代表三个特性:

特性 含义 说明
C(Consistency) 一致性 所有节点看到的数据是一致的
A(Availability) 可用性 每个请求都能在合理时间内得到响应
P(Partition Tolerance) 分区容错性 网络分区时系统仍能正常工作

CAP定理告诉我们:三者只能同时满足两个

在分布式系统中,P是必须保证的(网络分区不可避免),所以只能在C和A之间做选择:

  • CP:保证一致性,牺牲可用性(如Zookeeper)
  • AP:保证可用性,牺牲一致性(如Eureka)

2.2 BASE理论

BASE理论是对CAP中AP的延伸,是最终一致性的理论基础:

特性 含义 举例
BA(Basically Available) 基本可用 响应时间可以稍微长一点
S(Soft State) 软状态 允许数据在中间状态存在一段时间
E(Eventually Consistent) 最终一致性 数据最终会达到一致状态

类比一下:强一致性就像银行转账,A扣了B必须马上加上。最终一致性就像微信转账,A扣了B可能过几秒才收到,但最终一定会收到。

2.3 强一致性 vs 最终一致性的选择

场景 推荐方案 原因
资金交易 强一致性(TCC) 涉及钱,不能有中间状态
库存扣减 强一致性(TCC) 超卖问题代价太大
积分发放 最终一致性(消息) 积分晚几秒到账用户可以接受
通知推送 最终一致性(消息) 推送延迟可以容忍
数据同步 最终一致性(Canal+MQ) 允许短暂不一致

三、常见分布式事务方案对比

3.1 六种主流方案

方案 一致性 性能 复杂度 侵入性 适用场景
2PC(两阶段提交) 强一致 低(数据库层面) 传统数据库分布式事务
3PC(三阶段提交) 强一致 理论方案,实际很少用
TCC(Try-Confirm-Cancel) 强一致 高(业务代码侵入) 资金、库存等核心业务
Saga 最终一致 中(编排/协调) 长流程业务编排
本地消息表 最终一致 中(需建消息表) 异步场景,如积分发放
事务消息 最终一致 低(MQ层面) 异步解耦场景

3.2 各方案的核心思想

2PC(Two-Phase Commit):协调者问所有参与者"能提交吗?",都OK就提交,有一个不行就全部回滚。问题是同步阻塞,性能差。

Saga:把长事务拆成多个本地事务,每个本地事务有对应的补偿操作。如果某步失败了,逆序执行补偿。像多米诺骨牌一样,倒了一个就往回推。

本地消息表:业务操作和消息写入放在同一个本地事务里,定时任务扫描消息表发送消息,消费者消费成功后标记。简单可靠但需要额外建表。

事务消息:RocketMQ原生支持。先发半消息(消费者看不到),执行本地事务成功后再提交消息。RocketMQ会定期回查本地事务状态。

踩坑提醒:这个坑我踩过!当时用2PC做分布式事务,一个服务响应慢导致整个事务阻塞了30秒,所有连接都卡住了。从那以后,核心业务我宁愿用TCC多写点代码,也不用2PC。


四、TCC模式原理

4.1 什么是TCC?

TCC是Try-Confirm-Cancel 三个词的缩写,是一种业务层面的两阶段提交协议

和2PC不同的是,TCC不依赖数据库的事务机制,而是在业务代码层面实现两阶段提交

4.2 三阶段详解

转账场景为例:A给B转100元。

Try阶段:预留资源
java 复制代码
// Try:冻结资源,不实际扣减
public boolean tryTransfer(String fromAccount, String toAccount, BigDecimal amount) {
    // 1. 在A账户冻结100元(A的可用余额减少,冻结金额增加)
    accountDao.freeze(fromAccount, amount);
    // 2. 在B账户预增加100元(冻结金额增加)
    accountDao.preAdd(toAccount, amount);
    return true;
}

Try阶段只是冻结资源,不实际扣减。就像转账前先把钱放到"待转出"里。

Confirm阶段:确认操作
java 复制代码
// Confirm:确认转账,实际扣减冻结的资源
public boolean confirmTransfer(String fromAccount, String toAccount, BigDecimal amount) {
    // 1. A账户:扣减冻结的100元
    accountDao.deductFrozen(fromAccount, amount);
    // 2. B账户:将预增加的100元转为可用余额
    accountDao.confirmAdd(toAccount, amount);
    return true;
}

Confirm阶段才是真正的操作。把冻结的资源实际扣减掉。

Cancel阶段:取消操作
java 复制代码
// Cancel:取消转账,释放冻结的资源
public boolean cancelTransfer(String fromAccount, String toAccount, BigDecimal amount) {
    // 1. A账户:释放冻结的100元(退回可用余额)
    accountDao.unfreeze(fromAccount, amount);
    // 2. B账户:取消预增加的100元
    accountDao.cancelPreAdd(toAccount, amount);
    return true;
}

Cancel阶段释放Try阶段冻结的资源,恢复原状。

4.3 完整流程图

复制代码
正常流程(Try → Confirm):
  TM发起全局事务
      |
      v
  [Try] A账户冻结100元 → 成功
      |
  [Try] B账户预增加100元 → 成功
      |
  TM决定:所有Try都成功 → 提交
      |
  [Confirm] A账户扣减冻结100元 → 成功
      |
  [Confirm] B账户确认增加100元 → 成功
      |
  事务完成 ✓

异常流程(Try → Cancel):
  TM发起全局事务
      |
      v
  [Try] A账户冻结100元 → 成功
      |
  [Try] B账户预增加100元 → 失败!
      |
  TM决定:有Try失败 → 回滚
      |
  [Cancel] A账户释放冻结100元 → 成功
      |
  事务回滚完成 ✓

4.4 TCC的三大经典问题

问题 描述 后果
空回滚 Try未执行,Cancel先到了 Cancel找不到要释放的资源
悬挂 Cancel比Try先执行 Try执行后资源被冻结但永远没人释放
幂等性 Confirm/Cancel被重复调用 资源被重复扣减或释放

这三个问题是TCC实现中最容易踩的坑,后面会详细讲解决方案。


五、Seata TCC实现

5.1 Seata架构

Seata是阿里巴巴开源的分布式事务框架,架构中有三个核心角色:

角色 全称 职责 类比
TC Transaction Coordinator 事务协调者,维护全局事务状态 银行经理
TM Transaction Manager 事务管理器,发起/提交/回滚全局事务 客户
RM Resource Manager 资源管理器,管理分支事务 柜员
复制代码
Seata架构图:

  Client(TM)                    Seata Server(TC)
  ┌──────────┐                   ┌──────────┐
  │ 订单服务 │───── 开启全局事务 ────>│          │
  │   (RM)   │<──── 返回XID ──────│  协调者   │
  │          │                     │          │
  │ 库存服务 │───── 注册分支事务 ───>│          │
  │   (RM)   │<──── 返回BranchID ──│          │
  │          │                     │          │
  │ 积分服务 │───── 注册分支事务 ───>│          │
  │   (RM)   │<──── 返回BranchID ──│          │
  └──────────┘                     └──────────┘

5.2 电商下单TCC完整实现

先定义数据库表结构(需要额外的冻结字段):

sql 复制代码
-- 库存表(增加冻结库存字段)
CREATE TABLE `stock` (
    `id` BIGINT PRIMARY KEY,
    `product_id` BIGINT NOT NULL COMMENT '商品ID',
    `total_stock` INT NOT NULL COMMENT '总库存',
    `frozen_stock` INT NOT NULL DEFAULT 0 COMMENT '冻结库存',
    `available_stock` INT NOT NULL COMMENT '可用库存 = total_stock - frozen_stock'
) COMMENT='库存表';

-- 账户表(增加冻结金额字段)
CREATE TABLE `account` (
    `id` BIGINT PRIMARY KEY,
    `user_id` BIGINT NOT NULL COMMENT '用户ID',
    `balance` DECIMAL(15,2) NOT NULL COMMENT '可用余额',
    `frozen_amount` DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '冻结金额'
) COMMENT='账户表';

-- 事务状态表(解决空回滚和悬挂问题)
CREATE TABLE `tcc_transaction_log` (
    `xid` VARCHAR(128) PRIMARY KEY COMMENT '全局事务ID',
    `action` VARCHAR(32) NOT NULL COMMENT '操作标识',
    `status` VARCHAR(16) NOT NULL COMMENT '状态:TRY/CONFIRMED/CANCELLED',
    `create_time` DATETIME NOT NULL,
    `update_time` DATETIME NOT NULL
) COMMENT='TCC事务状态日志';
库存服务TCC实现
java 复制代码
@Service
@Slf4j
public class StockTccService {

    @Autowired
    private StockMapper stockMapper;
    @Autowired
    private TccTransactionLogMapper logMapper;

    /**
     * Try阶段:冻结库存
     */
    @TwoPhaseBusinessAction(
        name = "deductStock",
        commitMethod = "confirmDeductStock",
        rollbackMethod = "cancelDeductStock"
    )
    public boolean tryDeductStock(
            @BusinessActionContextParameter(paramName = "productId") Long productId,
            @BusinessActionContextParameter(paramName = "count") Integer count) {

        String xid = RootContext.getXID();
        log.info("[Try] 冻结库存, xid={}, productId={}, count={}", xid, productId, count);

        // 防悬挂:如果Cancel已经执行过,Try直接返回false
        TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
        if (logRecord != null && "CANCELLED".equals(logRecord.getStatus())) {
            log.warn("[Try] 检测到已回滚记录,拒绝执行(防悬挂), xid={}", xid);
            return false;
        }

        // 查询库存
        Stock stock = stockMapper.selectByProductId(productId);
        if (stock == null) {
            throw new RuntimeException("商品不存在");
        }

        // 检查可用库存是否足够
        int availableStock = stock.getTotalStock() - stock.getFrozenStock();
        if (availableStock < count) {
            throw new RuntimeException("库存不足");
        }

        // 冻结库存
        int rows = stockMapper.freezeStock(productId, count);
        if (rows <= 0) {
            throw new RuntimeException("冻结库存失败");
        }

        // 记录事务状态
        logMapper.insertOrUpdate(xid, "deductStock", "TRY");

        return true;
    }

    /**
     * Confirm阶段:扣减冻结的库存
     */
    public boolean confirmDeductStock(BusinessActionContext context) {
        String xid = context.getXid();
        Long productId = (Long) context.getActionContext("productId");
        Integer count = (Integer) context.getActionContext("count");

        log.info("[Confirm] 扣减冻结库存, xid={}, productId={}, count={}", xid, productId, count);

        // 幂等校验:如果已经Confirm过,直接返回成功
        TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
        if (logRecord != null && "CONFIRMED".equals(logRecord.getStatus())) {
            log.info("[Confirm] 已确认过,幂等返回, xid={}", xid);
            return true;
        }

        // 扣减冻结库存(实际扣减total_stock,减少frozen_stock)
        stockMapper.deductFrozenStock(productId, count);

        // 更新事务状态
        logMapper.updateStatus(xid, "deductStock", "CONFIRMED");

        return true;
    }

    /**
     * Cancel阶段:释放冻结的库存
     */
    public boolean cancelDeductStock(BusinessActionContext context) {
        String xid = context.getXid();
        Long productId = (Long) context.getActionContext("productId");
        Integer count = (Integer) context.getActionContext("count");

        log.info("[Cancel] 释放冻结库存, xid={}, productId={}, count={}", xid, productId, count);

        // 防空回滚:如果Try没有执行过,直接返回成功
        TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
        if (logRecord == null) {
            log.warn("[Cancel] Try未执行,空回滚直接返回, xid={}", xid);
            return true;
        }

        // 幂等校验:如果已经Cancel过,直接返回成功
        if ("CANCELLED".equals(logRecord.getStatus())) {
            log.info("[Cancel] 已回滚过,幂等返回, xid={}", xid);
            return true;
        }

        // 释放冻结库存
        stockMapper.unfreezeStock(productId, count);

        // 更新事务状态
        logMapper.updateStatus(xid, "deductStock", "CANCELLED");

        return true;
    }
}
账户服务TCC实现
java 复制代码
@Service
@Slf4j
public class AccountTccService {

    @Autowired
    private AccountMapper accountMapper;
    @Autowired
    private TccTransactionLogMapper logMapper;

    /**
     * Try阶段:冻结金额
     */
    @TwoPhaseBusinessAction(
        name = "deductAmount",
        commitMethod = "confirmDeductAmount",
        rollbackMethod = "cancelDeductAmount"
    )
    public boolean tryDeductAmount(
            @BusinessActionContextParameter(paramName = "userId") Long userId,
            @BusinessActionContextParameter(paramName = "amount") BigDecimal amount) {

        String xid = RootContext.getXID();
        log.info("[Try] 冻结金额, xid={}, userId={}, amount={}", xid, userId, amount);

        // 防悬挂检查
        TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductAmount");
        if (logRecord != null && "CANCELLED".equals(logRecord.getStatus())) {
            log.warn("[Try] 检测到已回滚记录,拒绝执行(防悬挂), xid={}", xid);
            return false;
        }

        // 查询账户
        Account account = accountMapper.selectByUserId(userId);
        if (account == null) {
            throw new RuntimeException("账户不存在");
        }

        // 检查可用余额
        BigDecimal available = account.getBalance().subtract(account.getFrozenAmount());
        if (available.compareTo(amount) < 0) {
            throw new RuntimeException("余额不足");
        }

        // 冻结金额
        accountMapper.freezeAmount(userId, amount);

        // 记录事务状态
        logMapper.insertOrUpdate(xid, "deductAmount", "TRY");

        return true;
    }

    /**
     * Confirm阶段:扣减冻结的金额
     */
    public boolean confirmDeductAmount(BusinessActionContext context) {
        String xid = context.getXid();
        Long userId = (Long) context.getActionContext("userId");
        BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());

        log.info("[Confirm] 扣减冻结金额, xid={}, userId={}, amount={}", xid, userId, amount);

        // 幂等校验
        TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductAmount");
        if (logRecord != null && "CONFIRMED".equals(logRecord.getStatus())) {
            log.info("[Confirm] 已确认过,幂等返回, xid={}", xid);
            return true;
        }

        // 实际扣减:减少balance,减少frozen_amount
        accountMapper.deductFrozenAmount(userId, amount);

        // 更新事务状态
        logMapper.updateStatus(xid, "deductAmount", "CONFIRMED");

        return true;
    }

    /**
     * Cancel阶段:释放冻结的金额
     */
    public boolean cancelDeductAmount(BusinessActionContext context) {
        String xid = context.getXid();
        Long userId = (Long) context.getActionContext("userId");
        BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());

        log.info("[Cancel] 释放冻结金额, xid={}, userId={}, amount={}", xid, userId, amount);

        // 防空回滚
        TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductAmount");
        if (logRecord == null) {
            log.warn("[Cancel] Try未执行,空回滚直接返回, xid={}", xid);
            return true;
        }

        // 幂等校验
        if ("CANCELLED".equals(logRecord.getStatus())) {
            log.info("[Cancel] 已回滚过,幂等返回, xid={}", xid);
            return true;
        }

        // 释放冻结金额
        accountMapper.unfreezeAmount(userId, amount);

        // 更新事务状态
        logMapper.updateStatus(xid, "deductAmount", "CANCELLED");

        return true;
    }
}
订单服务(TM端,发起全局事务)
java 复制代码
@Service
@Slf4j
public class OrderService {

    @Autowired
    private StockTccService stockTccService;
    @Autowired
    private AccountTccService accountTccService;
    @Autowired
    private PointsService pointsService;
    @Autowired
    private OrderMapper orderMapper;

    /**
     * 电商下单(TCC分布式事务)
     * 使用 @GlobalTransactional 开启Seata全局事务
     */
    @GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
    public OrderResult createOrder(OrderRequest request) {
        Long userId = request.getUserId();
        Long productId = request.getProductId();
        Integer count = request.getCount();
        BigDecimal amount = request.getAmount();

        log.info("开始创建订单, userId={}, productId={}, count={}", userId, productId, count);

        // 1. Try阶段:冻结库存
        boolean stockResult = stockTccService.tryDeductStock(productId, count);
        if (!stockResult) {
            throw new RuntimeException("冻结库存失败");
        }

        // 2. Try阶段:冻结金额
        boolean accountResult = accountTccService.tryDeductAmount(userId, amount);
        if (!accountResult) {
            throw new RuntimeException("冻结金额失败");
        }

        // 3. 创建订单记录(本地事务)
        Order order = new Order();
        order.setUserId(userId);
        order.setProductId(productId);
        order.setCount(count);
        order.setAmount(amount);
        order.setStatus("CREATED");
        orderMapper.insert(order);

        // 4. 增加积分(最终一致性,发消息异步处理)
        pointsService.addPointsAsync(userId, amount);

        log.info("订单创建成功, orderId={}", order.getId());
        return OrderResult.success(order.getId());
    }
}

5.3 Seata配置

yaml 复制代码
# application.yml
seata:
  enabled: true
  application-id: order-service
  tx-service-group: my-tx-group
  service:
    vgroup-mapping:
      my-tx-group: default
  registry:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      namespace: seata
      group: SEATA_GROUP
      application: seata-server

六、TCC的三大问题与解决方案

6.1 空回滚

问题:Try阶段因为网络原因没有执行(或者超时了),但TC认为Try失败了,触发了Cancel。Cancel执行时发现Try根本没执行过,找不到要释放的资源。

解决方案:在Cancel方法中检查事务状态表,如果Try没有执行过,直接返回成功(不做任何操作)。

java 复制代码
public boolean cancelDeductStock(BusinessActionContext context) {
    String xid = context.getXid();

    // 防空回滚:Try没有执行过,直接返回成功
    TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
    if (logRecord == null) {
        log.warn("[Cancel] Try未执行,空回滚直接返回, xid={}", xid);
        return true; // 空回滚,直接成功
    }

    // 正常回滚逻辑...
}

6.2 悬挂

问题:Cancel比Try先执行了(网络延迟导致Try还没到,Cancel先到了)。Cancel执行后,Try才到达并执行成功,资源被冻结了,但再也没有Confirm或Cancel来处理它。

解决方案:在Try方法中检查事务状态表,如果Cancel已经执行过,Try直接返回false,拒绝执行。

java 复制代码
public boolean tryDeductStock(Long productId, Integer count) {
    String xid = RootContext.getXID();

    // 防悬挂:如果Cancel已经执行过,Try直接拒绝
    TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
    if (logRecord != null && "CANCELLED".equals(logRecord.getStatus())) {
        log.warn("[Try] 检测到已回滚记录,拒绝执行(防悬挂), xid={}", xid);
        return false;
    }

    // 正常Try逻辑...
}

6.3 幂等性

问题:TC在Confirm或Cancel超时后会自动重试,可能导致Confirm或Cancel被多次执行。如果不做幂等,资源会被重复扣减或释放。

解决方案:在Confirm和Cancel方法中检查事务状态表,如果已经执行过,直接返回成功。

java 复制代码
public boolean confirmDeductStock(BusinessActionContext context) {
    String xid = context.getXid();

    // 幂等校验:已经Confirm过就直接返回
    TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
    if (logRecord != null && "CONFIRMED".equals(logRecord.getStatus())) {
        log.info("[Confirm] 已确认过,幂等返回, xid={}", xid);
        return true;
    }

    // 正常Confirm逻辑...
    // 执行完后更新状态为CONFIRMED
}

6.4 三大问题总结

问题 原因 解决方案 检查时机
空回滚 Try未执行,Cancel先到 Cancel中检查Try是否执行过 Cancel方法入口
悬挂 Cancel比Try先执行 Try中检查Cancel是否已执行 Try方法入口
幂等性 Confirm/Cancel被重复调用 检查事务状态是否已处理 Confirm/Cancel方法入口

踩坑提醒:这三个问题我全部踩过!最坑的是悬挂问题,排查了三天才发现是网络延迟导致Cancel先到了。从那以后,TCC的三大问题我每次实现都会检查一遍,绝不偷懒。


七、踩坑指南

7.1 TCC对业务代码侵入性强

踩坑提醒:TCC最大的缺点就是侵入性太强。每个参与事务的业务方法都要拆成Try、Confirm、Cancel三个方法,数据库表要加冻结字段,还要维护事务状态表。我见过一个项目,为了TCC把所有表都加了冻结字段,代码量翻了一倍。

缓解方案

  • 只在核心业务(资金、库存)用TCC,非核心业务用最终一致性
  • 抽取TCC模板,减少重复代码
  • 用Seata的注解简化开发

7.2 Try阶段预留资源影响并发性能

Try阶段需要冻结资源,这意味着可用资源变少了

比如库存100件,有10个订单在Try阶段各冻结了10件,虽然还没真正扣减,但可用库存只剩0了。新来的订单全部因为"库存不足"被拒绝。

缓解方案

  • Try阶段冻结时间尽量短(Seata默认60秒超时)
  • 监控冻结资源数量,异常告警
  • 考虑用Saga模式替代(不需要预留资源)

7.3 网络超时导致的事务状态不一致

分布式环境下,网络超时是常态。可能出现:

  • Try执行成功了,但返回结果超时,TC认为Try失败,触发Cancel
  • Confirm执行成功了,但返回结果超时,TC认为Confirm失败,再次触发Confirm(需要幂等)

缓解方案

  • 所有Confirm和Cancel必须实现幂等
  • 合理设置Seata的超时时间
  • 网络抖动时做好重试机制

7.4 Seata Server的高可用部署

踩坑提醒:Seata Server单点部署的话,一旦挂了所有分布式事务都不可用。生产环境必须集群部署!

复制代码
Seata Server高可用架构:

  Client                    Seata Cluster              Registry
  ┌──────┐                 ┌──────────┐              ┌──────────┐
  │ 订单 │──┐              │ Seata-1  │──┐           │          │
  │ 服务 │  │              │ (Leader) │  │           │  Nacos   │
  └──────┘  │    注册/发现  └──────────┘  │  注册/发现 │ Registry │
  ┌──────┐  │ ──────────>  ┌──────────┐  │ ────────> │          │
  │ 库存 │──┤              │ Seata-2  │──┤           └──────────┘
  │ 服务 │  │              │(Follower) │  │
  └──────┘  │              └──────────┘  │
  ┌──────┐  │              ┌──────────┐  │
  │ 账户 │──┘              │ Seata-3  │──┘
  │ 服务 │                 │(Follower) │
  └──────┘                 └──────────┘

关键配置:

yaml 复制代码
# Seata Server配置(集群模式)
seata:
  registry:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      cluster: default
  store:
    mode: db  # 事务日志存储在数据库中(集群模式必须)
    db:
      datasource: druid
      db-type: mysql

八、问题与解答

Q1: TCC和Saga的区别?

A : 核心区别在于资源锁定方式

  • TCC:Try阶段预留(冻结)资源,Confirm阶段才实际操作。资源在Try期间被锁定,一致性更强,但并发性能受影响
  • Saga:直接执行本地事务,失败时执行补偿操作。不预留资源,并发性能好,但补偿操作可能复杂
对比维度 TCC Saga
资源预留 需要(冻结) 不需要
一致性 强一致 最终一致
并发性能 较低(资源被冻结) 较高
补偿复杂度 低(释放冻结资源) 可能很高(需要逆向操作)
适用场景 资金、库存 长流程编排

Q2: TCC的Try阶段失败怎么办?

A: Try阶段失败分为两种情况:

  1. 业务异常(如库存不足):直接抛异常,TC会触发其他已成功Try的分支执行Cancel,回滚整个事务
  2. 系统异常(如网络超时、数据库连接失败):TC会根据超时机制判断,如果超时未收到Try结果,也会触发Cancel
java 复制代码
// Try阶段抛异常,TC会自动触发Cancel
@TwoPhaseBusinessAction(name = "deductStock",
    commitMethod = "confirm", rollbackMethod = "cancel")
public boolean tryDeductStock(Long productId, Integer count) {
    if (stock < count) {
        // 业务异常,直接抛出,TC会触发Cancel
        throw new RuntimeException("库存不足");
    }
    // 冻结库存...
}

Q3: Seata的AT模式和TCC模式怎么选?

A: 说实话,能用AT就用AT,实在不行才用TCC。

对比维度 AT模式 TCC模式
代码侵入 低(自动管理) 高(手写三个方法)
一致性 最终一致 强一致
性能 高(有全局锁) 中(冻结资源)
适用场景 通用CRUD 资金、库存等需要精确控制的场景
开发成本

选择建议

  • 普通业务(订单创建、状态更新):用AT模式,开发成本低
  • 核心业务(资金转账、库存扣减):用TCC模式,一致性保证更强
  • 长流程编排:用Saga模式

九、面试高频考点汇总

考点1:TCC的三个阶段分别做什么?

答案

  • Try阶段:预留/冻结资源。不做实际的业务操作,只是把需要的资源"锁"起来。比如冻结库存、冻结金额。
  • Confirm阶段:确认操作。Try全部成功后执行,实际扣减Try阶段冻结的资源。比如扣减冻结库存、扣减冻结金额。
  • Cancel阶段:取消操作。Try有失败时执行,释放Try阶段冻结的资源。比如释放冻结库存、释放冻结金额。

考点2:TCC的空回滚、悬挂、幂等性问题怎么解决?

答案

  • 空回滚:Try未执行但Cancel先到了。解决:Cancel中检查事务状态表,如果Try没执行过,直接返回成功。
  • 悬挂:Cancel比Try先执行。解决:Try中检查事务状态表,如果Cancel已执行,Try拒绝执行。
  • 幂等性:Confirm/Cancel被重复调用。解决:Confirm/Cancel中检查事务状态,已处理过则直接返回成功。

核心手段:事务状态表,记录每个分支事务的执行状态。

考点3:Seata的AT模式和TCC模式的区别?

答案

  • AT模式:基于支持本地ACID事务的关系型数据库。Seata自动管理一阶段(提交本地事务并记录undo_log)和二阶段(异步删除undo_log或根据undo_log回滚)。对业务代码无侵入。
  • TCC模式:需要业务自定义Try、Confirm、Cancel三个方法,对业务代码侵入性强。适合不依赖数据库事务的场景,或需要精确控制资源锁定的场景。

考点4:分布式事务有哪些方案?各有什么优缺点?

答案

方案 优点 缺点
2PC 强一致性 同步阻塞,性能差
TCC 强一致,灵活 侵入性强,开发成本高
Saga 性能好,适合长流程 补偿复杂,最终一致
本地消息表 简单可靠 需要额外建表
事务消息 性能好,低侵入 依赖MQ支持

考点5:CAP定理和BASE理论的关系?

答案

CAP定理指出分布式系统不可能同时满足一致性(C)、可用性(A)和分区容错性(P),最多只能满足两个。由于P是必须的,实际选择只有CP和AP。

BASE理论是对CAP中AP方案的延伸指导:

  • BA(基本可用):允许损失部分可用性
  • S(软状态):允许数据存在中间状态
  • E(最终一致性):保证数据最终一致

BASE理论告诉我们:在AP系统中,通过接受"软状态"和"最终一致性",可以实现高可用。


十、模拟面试官提问

场景题1:设计一个电商下单的分布式事务方案

面试官:电商下单涉及订单、库存、支付、积分四个服务,你会怎么设计分布式事务方案?

参考答案

复制代码
方案设计:

1. 订单服务(TM):使用 @GlobalTransactional 发起全局事务
2. 库存服务(RM):TCC模式,Try冻结库存,Confirm扣减
3. 支付服务(RM):TCC模式,Try冻结金额,Confirm扣减
4. 积分服务(RM):最终一致性,异步发MQ消息增加积分

为什么积分用最终一致性?
- 积分晚几秒到账用户可以接受
- 不需要冻结资源,不影响并发性能
- 即使积分增加失败,可以后台补偿

关键原则:核心链路用TCC保证强一致,非核心链路用最终一致性保证性能

场景题2:TCC vs Saga vs 消息最终一致性选型

面试官:你们项目怎么选分布式事务方案?

参考答案

我的选型策略:

业务场景 方案 理由
支付、转账 TCC 涉及资金,必须强一致
库存扣减 TCC 超卖代价太大
订单创建 AT 普通CRUD,用AT降低开发成本
积分发放 消息最终一致性 允许延迟,不需要强一致
跨系统数据同步 Saga 长流程,用Saga编排

核心原则:不是所有业务都需要强一致性。根据业务的重要性和可容忍的不一致时间来选择方案。能用简单的方案就不要用复杂的。

场景题3:资金冻结场景的TCC实现

面试官:如果让你实现一个转账的TCC,你会怎么设计数据库表和代码?

参考答案

数据库设计:

sql 复制代码
-- 账户表需要冻结金额字段
ALTER TABLE account ADD COLUMN frozen_amount DECIMAL(15,2) DEFAULT 0;

-- 可用余额 = balance - frozen_amount

核心逻辑:

java 复制代码
// Try:冻结转出方的金额
UPDATE account SET frozen_amount = frozen_amount + #{amount}
WHERE user_id = #{userId} AND (balance - frozen_amount) >= #{amount};

// Confirm:实际扣减
UPDATE account SET balance = balance - #{amount}, frozen_amount = frozen_amount - #{amount}
WHERE user_id = #{userId} AND frozen_amount >= #{amount};

// Cancel:释放冻结
UPDATE account SET frozen_amount = frozen_amount - #{amount}
WHERE user_id = #{userId} AND frozen_amount >= #{amount};

关键点:所有SQL都要加条件判断WHERE frozen_amount >= #{amount}),防止并发场景下数据错乱。

场景题4:分布式事务的性能优化

面试官:TCC性能不太好,你们是怎么优化的?

参考答案

  1. 减少TCC的使用范围:只在核心业务用TCC,非核心业务用最终一致性
  2. 缩短Try阶段耗时:Try阶段的冻结操作尽量快,减少资源锁定时间
  3. 异步Confirm:Seata支持异步提交,减少全局事务的阻塞时间
  4. 批量操作合并:多个小事务合并为一个大事务,减少网络交互
  5. 读写分离:查询操作走从库,减少主库压力
  6. 合理设置超时:Seata默认超时60秒,根据业务调整,避免长时间锁定资源
yaml 复制代码
# Seata超时配置优化
seata:
  client:
    tm:
      commit-retry-count: 3        # 提交重试次数
      rollback-retry-count: 3     # 回滚重试次数
      default-global-transaction-timeout: 30000  # 全局事务超时30秒

场景题5:Seata集群部署与高可用

面试官:Seata Server怎么部署才能保证高可用?

参考答案

复制代码
高可用部署方案:

1. Seata Server集群(至少3个节点)
   - 注册中心:Nacos集群
   - 事务日志存储:MySQL主从(或Redis)
   - 部署方式:K8s Deployment(3副本)

2. 数据库高可用
   - Seata Server的事务日志表(lock_table、global_table等)放在MySQL
   - MySQL主从复制 + MHA/Orchestrator自动切换

3. 客户端配置
   - 所有服务配置相同的tx-service-group
   - 通过Nacos自动发现Seata Server集群
   - 失败自动重试其他节点

关键点:Seata Server的事务日志必须存数据库(db模式),不能存文件(file模式)。集群模式下,每个Seata Server节点都能从数据库读取事务状态,任何节点挂了都不影响。


十一、互动话题

你在项目中用过分布式事务吗?用的哪种方案?有没有遇到过空回滚或悬挂的问题?欢迎在评论区分享你的实战经验和踩坑故事!


参考资料

  1. Seata官方文档 - TCC模式 - Seata TCC模式官方说明
  2. Seata源码分析 - TCC模式实现原理 - Seata TCC源码级解析