TCC事务深入解析和应用

彻底搞懂 TCC:微服务分布式事务的“特种部队”

在分布式事务的江湖中,如果说 Seata 的 AT 模式是“自动步枪”(简单好用但有性能损耗),那么 TCC 模式就是“狙击枪”(操作复杂但精准高效)。

很多开发者听说过 TCC,但往往止步于“Try-Confirm-Cancel”的概念。今天,我们将深入底层,结合 Spring Cloud 和 Seata,手把手教你如何落地一个生产级的 TCC 方案,并解决最让人头疼的“悬挂”和“空回滚”问题。

为什么我们需要 TCC?

在微服务架构中,AT 模式(基于数据库本地锁)虽然开发简单,但在高并发、热点账户(如秒杀扣库存)场景下,全局锁会导致性能瓶颈。

TCC(Try-Confirm-Cancel)应运而生。它不依赖数据库的全局锁,而是将锁的粒度交给业务代码控制

  • 性能更高:不长时间占用数据库连接。
  • 控制更细:你可以自定义锁的粒度(例如:冻结余额字段,而不是锁住整行记录)。

TCC 的“三板斧”原理

TCC 将一个业务操作拆分为三个阶段:

  1. Try (资源预留)
    • 做业务检查(一致性)。
    • 预留业务资源(准隔离性)。
    • 例子:把 100 元从“余额”移动到“冻结金额”。
  2. Confirm (业务确认)
    • 不做任何检查,利用 Try 阶段预留的资源执行真正业务。
    • 要求幂等。
    • 例子:把“冻结金额”扣除。
  3. Cancel (业务取消)
    • 释放 Try 阶段预留的资源。
    • 要求幂等。
    • 例子:把“冻结金额”退回到“余额”。

实战:基于 Seata 的 TCC 模式实现

假设我们有一个库存服务,需要实现 TCC 模式。

1. 接口定义 (@TwoPhaseBusinessAction)

在 TCC 模式下,我们需要在接口上使用 Seata 的注解来定义这三个方法。

import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;

@LocalTCC
public interface InventoryTccService {

    /**
     * Try 阶段:预留库存
     * @param productId 商品ID
     * @param count 扣减数量
     * @return 成功与否
     */
    @TwoPhaseBusinessAction(name = "prepareInventory", commitMethod = "commit", rollbackMethod = "rollback")
    boolean prepare(BusinessActionContext actionContext, 
                    @BusinessActionContextParameter(paramName = "productId") String productId,
                    @BusinessActionContextParameter(paramName = "count") int count);

    /**
     * Confirm 阶段:提交
     */
    boolean commit(BusinessActionContext actionContext);

    /**
     * Cancel 阶段:回滚
     */
    boolean rollback(BusinessActionContext actionContext);
}

2. 业务实现(关键逻辑)

这里是 TCC 的精髓所在:资源隔离。我们需要在数据库表中增加一个 frozen_stock(冻结库存)字段。

@Service
@Slf4j
public class InventoryTccServiceImpl implements InventoryTccService {

    @Autowired
    private InventoryMapper inventoryMapper;

    // --- Try 阶段 ---
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean prepare(BusinessActionContext actionContext, String productId, int count) {
        // 1. 检查库存是否充足
        Inventory inventory = inventoryMapper.selectById(productId);
        if (inventory.getStock() < count) {
            throw new RuntimeException("库存不足");
        }
        
        // 2. 预留资源(stock - count, frozen + count)
        // 关键:必须防止“悬挂”(后面会讲)
        inventoryMapper.freezeStock(productId, count);
        
        log.info("Try阶段:冻结库存成功,xid: {}", actionContext.getXid());
        return true;
    }

    // --- Confirm 阶段 ---
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean commit(BusinessActionContext actionContext) {
        // 获取 Try 阶段传入的参数
        String productId = (String) actionContext.getActionContext("productId");
        int count = (int) actionContext.getActionContext("count");

        // 3. 正式扣减(frozen - count)
        // 关键:幂等性控制
        int updateCount = inventoryMapper.commitStock(productId, count);
        
        log.info("Confirm阶段:扣减库存完成");
        return true;
    }

    // --- Cancel 阶段 ---
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean rollback(BusinessActionContext actionContext) {
        String productId = (String) actionContext.getActionContext("productId");
        int count = (int) actionContext.getActionContext("count");

        // 4. 解冻资源(stock + count, frozen - count)
        // 关键:防空回滚、幂等性
        inventoryMapper.unfreezeStock(productId, count);
        
        log.info("Cancel阶段:解冻库存完成");
        return true;
    }
}

3. 发起全局事务

在订单服务(调用方)中,依然使用 @GlobalTransactional,这与 AT 模式一致,Seata 会自动感知下游是 TCC 资源。

@GlobalTransactional
public void createOrder(Order order) {
    // 1. 调用库存 TCC 的 Try 方法
    inventoryTccService.prepare(null, order.getProductId(), order.getCount());
    
    // 2. 创建订单
    orderRepository.save(order);
    
    // 如果这里报错,Seata 会自动调用 inventoryTccService.rollback()
}

进阶:TCC 的“三大坑”与解决方案

这部分是你在生产环境必须解决的问题,否则数据必乱。

1. 幂等性(Idempotency)

  • 问题:网络抖动导致 Seata 重复调用 Confirm 或 Cancel。
  • 解决:在 Confirm/Cancel 阶段,通过数据库唯一索引(如 tcc_log 表,以 xid + branchId 为主键)记录执行状态。执行前先查询,如果已执行则直接返回成功。

2. 空回滚(Empty Rollback)

  • 问题:Try 阶段因为网络拥堵没请求到 TCC 服务,或者 Try 阶段直接抛异常了(还没落地数据)。此时 Seata 全局事务回滚,触发 Cancel。如果 Cancel 直接执行“解冻库存”,因为 Try 没执行,库存没冻结,导致库存凭空增加。
  • 解决:Cancel 方法执行时,必须识别出 Try 是否执行过。如果 Try 没执行,Cancel 直接返回成功(不做操作)。

3. 悬挂(Hanging)

  • 问题:网络拥堵,Try 请求超时。Seata 判定失败,触发 Cancel。Cancel 先执行完了。结果,那条迟到的 Try 请求到了!此时 Try 执行冻结库存,但因为全局事务已经结束,这笔冻结的库存永远不会被 Confirm 或 Cancel,被称为“悬挂”。
  • 解决:Try 方法执行时,需要检查当前事务 ID 是否已经执行过 Cancel。如果已执行过 Cancel,Try 必须拒绝执行。

终极方案:事务控制表

为了解决上述三个问题,通常需要一张辅助表 local_tcc_log

  • Try 逻辑:插入一条记录(状态:TRYING)。如果插入失败(因为 Cancel 已经插入了记录),则抛异常(防悬挂)。
  • Confirm 逻辑:更新记录状态为 CONFIRMED。如果记录不存在,说明 Try 没成功(理论上不可能进入 Confirm)。
  • Cancel 逻辑:插入或更新记录(状态:CANCELLED)。如果记录不存在,说明 Try 没到,插入一条空回滚记录(防空回滚)。

总结:何时选择 TCC?

TCC 不是银弹,它的开发成本比 AT 模式高出 3 倍以上(每个接口写三个方法,还要处理异常)。

选用建议

  1. 首选 AT 模式:绝大多数中后台业务,并发不超过 1000 QPS,优先用 Seata AT,开发效率第一。
  2. 必选 TCC 的场景
    • 核心资金流:涉及金钱往来,必须精准控制状态,不希望依赖数据库全局锁。
    • 高性能并发:如秒杀库存扣减,需要极致的数据库性能。
    • 无数据库事务:被调用的服务(如 Redis、第三方 API)不支持 JDBC 事务,只能通过代码逻辑回滚。

TCC 模式通过业务层面的精细化控制,实现了分布式环境下的数据一致性。虽然实现复杂,但它是构建高可靠、高性能微服务系统的必备技能。

posted on 2024-03-08 01:22  滚动的蛋  阅读(544)  评论(0)    收藏  举报

导航