TCC 空回滚与悬挂处理:网络抖动导致 Try 未执行直接 Cancel?幂等控制防误操作!

做过分布式事务开发的同学肯定都遇到过这个问题:TCC 模式下的空回滚和悬挂问题。简单来说就是:Try 方法还没执行,Cancel 方法就来了;或者 Try 执行失败,但 Cancel 还是被调用了。这些异常场景处理不好,会导致数据不一致。

我之前就遇到过这样一个案例:用户下单时库存扣减服务超时,TC(事务协调器)认为 Try 失败,触发 Cancel 回滚。但由于网络抖动,库存服务实际已经扣减成功了,只是响应超时。结果 Cancel 方法又执行了一次"回滚",把已经扣减的库存又加回去了,导致库存数据错误。

今天我们就来聊聊 TCC 空回滚与悬挂的处理方案,让你的分布式事务更加健壮。

TCC 模式基础回顾

1. TCC 三阶段流程

TCC(Try-Confirm-Cancel)模式:

阶段一:Try(预留资源)
  - 锁定相关资源
  - 检查业务可行性
  - 如果不可行,直接失败,不进入后续阶段

阶段二:Confirm(确认执行)
  - 真正执行业务操作
  - 确认使用预留的资源
  - 失败会不断重试,直到成功

阶段三:Cancel(取消回滚)
  - 释放预留的资源
  - 回滚业务操作
  - 失败也会不断重试

正常流程:
Try → Confirm → 完成

异常流程:
Try → Cancel → 回滚完成

2. TCC 与 AT 模式的区别

AT 模式:
- 自动处理,无需人工干预
- 通过 undo_log 实现回滚
- 对业务代码无侵入
- 但性能较低(需要记录 undo_log)

TCC 模式:
- 手动编写 confirm 和 cancel 逻辑
- 资源锁定在 Try 阶段完成
- 对业务代码有一定侵入
- 但性能较高(无 undo_log)

空回滚问题

1. 什么是空回滚

空回滚场景:

请求到达 → Try 超时 → TC 认为 Try 失败 → 调用 Cancel
                              ↑
                          实际 Try 已经执行成功了
                          只是响应超时

结果:Cancel 执行了不应该执行的操作,导致数据不一致

示例:库存扣减
- Try:扣减库存 1 件(成功)
- 响应超时,TC 判定 Try 失败
- Cancel:加回库存 1 件(不应该执行!)
- 最终:库存多了 1 件

2. 空回滚的原因

空回滚产生的原因:

1. 网络抖动
   - Try 响应超时
   - TC 误判 Try 失败

2. 服务宕机
   - Try 执行到一半,服务重启
   - TC 收不到响应,判定失败

3. 协调器重启
   - TC 重启后,未完成的事务被重新处理
   - 但分支已经执行过了

3. 空回滚的解决方案

解决方案:幂等控制 + 状态记录

核心思想:
- 在执行操作前,先检查是否已经执行过
- 通过状态机或版本号控制

实现步骤:
1. 在 Try 阶段,设置"执行中"状态
2. 在 Confirm/Cancel 阶段,先检查状态
3. 如果状态是"已确认"或"已取消",直接返回成功
4. 如果状态是"执行中",继续执行
// 账户 TCC 接口
@LocalTCC
public interface AccountTccService {

    @TwoPhaseBusinessAction(
        name = "deductAction",
        commitMethod = "confirm",
        rollbackMethod = "cancel"
    )
    boolean tryDeduct(
        @BusinessActionContextParameter(paramName = "accountId") Long accountId,
        @BusinessActionContextParameter(paramName = "amount") BigDecimal amount
    );

    boolean confirm(BusinessActionContext context);
    
    boolean cancel(BusinessActionContext context);
}
// Try 阶段:记录状态
@Override
public boolean tryDeduct(Long accountId, BigDecimal amount) {
    // 检查状态是否为"已扣减"
    AccountStatus status = accountStatusRepository.findByAccountId(accountId);
    if (status != null && "CONFIRMED".equals(status.getStatus())) {
        // 已经执行过了,直接返回
        return true;
    }
    
    // 检查余额
    Account account = accountRepository.findByAccountId(accountId);
    if (account.getBalance().compareTo(amount) < 0) {
        throw new RuntimeException("余额不足");
    }
    
    // 记录状态为"预留中"
    saveStatus(accountId, "RESERVED", amount);
    
    return true;
}

// Cancel 阶段:检查状态
@Override
public boolean cancel(BusinessActionContext context) {
    Long accountId = (Long) context.getActionContext("accountId");
    BigDecimal amount = (BigDecimal) context.getActionContext("amount");
    
    AccountStatus status = accountStatusRepository.findByAccountId(accountId);
    
    // 空回滚防护:检查状态
    if (status == null) {
        // 没有预留记录,说明 Try 未执行,直接返回成功
        log.info("空回滚防护:accountId={} 无预留记录,跳过", accountId);
        return true;
    }
    
    if ("CONFIRMED".equals(status.getStatus())) {
        // 已经确认了,不需要取消
        log.info("空回滚防护:accountId={} 已确认,跳过", accountId);
        return true;
    }
    
    if ("CANCELLED".equals(status.getStatus())) {
        // 已经取消了
        log.info("空回滚防护:accountId={} 已取消,跳过", accountId);
        return true;
    }
    
    // 执行回滚
    Account account = accountRepository.findByAccountId(accountId);
    account.setBalance(account.getBalance().add(amount));
    accountRepository.save(account);
    
    // 更新状态
    saveStatus(accountId, "CANCELLED", BigDecimal.ZERO);
    
    return true;
}

悬挂问题

1. 什么是悬挂

悬挂场景:

请求到达 → Try 超时 → TC 认为 Try 失败 → 调用 Cancel
                                          ↓
                        网络恢复,Try 响应到达
                        Try 实际执行成功

结果:Try 和 Cancel 都执行了,但顺序错误
- Try 预留了资源
- Cancel 释放了资源
- 但 Try 是后执行的,所以资源被错误释放

示例:库存扣减
- Try 超时(开始执行)
- Cancel 执行(释放了之前的预留)
- Try 完成(又扣减了库存)
- 最终:库存少了 1 件

2. 悬挂的原因

悬挂产生的原因:

1. 异步响应延迟
   - Try 超时,触发 Cancel
   - Cancel 执行后,Try 响应才到达

2. 重试机制
   - Try 被重复调用
   - Cancel 也被重复调用
   - 执行顺序混乱

3. 悬挂的解决方案

解决方案:超时检测 + 状态机

核心思想:
- 设置合理的超时时间
- 在 Cancel 执行前,检查是否已经过了预留时间窗口
- 如果超时就跳过 Cancel

实现步骤:
1. 在 Try 阶段,记录预留开始时间
2. 设置预留超时时间(比如 30 秒)
3. 在 Cancel 阶段,检查当前时间与预留开始时间的差值
4. 如果超过超时时间,说明 Try 已经执行完成,跳过 Cancel
// 取消阶段:超时检测
@Override
public boolean cancel(BusinessActionContext context) {
    Long accountId = (Long) context.getActionContext("accountId");
    BigDecimal amount = (BigDecimal) context.getActionContext("amount");
    long actionBeginTime = context.getActionContext("actionBeginTime");
    
    // 悬挂防护:检查超时
    long currentTime = System.currentTimeMillis();
    long elapsedTime = currentTime - actionBeginTime;
    long timeout = 30000; // 30 秒超时
    
    if (elapsedTime > timeout) {
        // 超过超时时间,说明 Try 已经执行完成,跳过 Cancel
        log.info("悬挂防护:accountId={} 已超时{}ms,跳过Cancel", accountId, elapsedTime);
        return true;
    }
    
    AccountStatus status = accountStatusRepository.findByAccountId(accountId);
    
    // 空回滚防护
    if (status == null || "CONFIRMED".equals(status.getStatus())) {
        return true;
    }
    
    // 执行回滚
    Account account = accountRepository.findByAccountId(accountId);
    account.setBalance(account.getBalance().add(amount));
    accountRepository.save(account);
    
    saveStatus(accountId, "CANCELLED", BigDecimal.ZERO);
    
    return true;
}

完整解决方案架构

1. 核心设计思想

整体架构:

┌─────────────────────────────────────────────────────────────────┐
│                     TCC 空回滚与悬挂防护架构                      │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────┐    ┌────────────────┐    ┌────────────────┐  │
│  │  Try     │───→│  状态记录       │───→│  预留资源       │  │
│  │  阶段    │    │  (RESERVED)     │    │                │  │
│  └──────────┘    └────────────────┘    └────────────────┘  │
│                                                              │
│  ┌──────────┐    ┌────────────────┐    ┌────────────────┐  │
│  │ Confirm  │───→│  状态检查       │───→│  确认使用资源   │  │
│  │  阶段    │    │  (幂等控制)     │    │                │  │
│  └──────────┘    └────────────────┘    └────────────────┘  │
│                                                              │
│  ┌──────────┐    ┌────────────────┐    ┌────────────────┐  │
│  │ Cancel   │───→│  状态+超时检查 │───→│  释放资源       │  │
│  │  阶段    │    │  (空回滚+悬挂) │    │                │  │
│  └──────────┘    └────────────────┘    └────────────────┘  │
│                                                              │
└─────────────────────────────────────────────────────────────────────────────────┘

2. 状态机设计

账户状态机:

        ┌─────────────────────────────────────────┐
        │                                         │
        ▼                                         │
   ┌─────────┐      try       ┌───────────┐      │
   │  INIT   │──────────────→│  RESERVED │      │
   └─────────┘                └───────────┘      │
        ▲                           │             │
        │                           │             │
        │ cancel                    │ confirm      │
        │                           │             │
        │                           ▼             │
        │                    ┌───────────┐        │
        └────────────────────│ CONFIRMED │        │
                             └───────────┘        │
                                                     │
        ┌─────────────────────────────────────────┘
        │
        │ cancel (from RESERVED)
        │
        ▼
   ┌───────────┐
   │ CANCELLED │
   └───────────┘

状态转换规则:

1. INIT → RESERVED:Try 执行成功
2. RESERVED → CONFIRMED:Confirm 执行成功
3. RESERVED → CANCELLED:Cancel 执行成功
4. INIT → CANCELLED:空回滚(Try 未执行)

3. 幂等控制实现

// 幂等控制注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Tcc idempotent {
    String actionName();
}

// 幂等拦截器
class TccIdempotentInterceptor implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        TccIdempotent annotation = invocation.getMethod()
                .getAnnotation(TccIdempotent.class);

        String actionName = annotation.actionName();
        BusinessActionContext context = getActionContext();

        // 检查是否已经执行过
        if (hasExecuted(actionName, context)) {
            log.info("幂等防护:action={} 已执行,跳过", actionName);
            return true;
        }

        // 标记为已执行
        markExecuted(actionName, context);

        return invocation.proceed();
    }
}

最佳实践

1. 合理设置超时时间

超时时间配置原则:

1. Try 超时时间
   - 建议:5-10 秒
   - 太短:正常业务可能被误判
   - 太长:悬挂问题影响时间更长

2. Cancel 超时时间
   - 建议:10-30 秒
   - 需要保证 Cancel 有足够时间执行

3. 全局事务超时
   - 建议:60-120 秒
   - 包括所有分支事务的执行时间

2. 日志与监控

需要记录的日志:

1. Try 阶段:
   - actionName, accountId, amount
   - 执行结果
   - 开始时间、结束时间

2. Confirm/Cancel 阶段:
   - actionName, accountId, amount
   - 是否空回滚
   - 是否悬挂
   - 执行结果

监控指标:
- 空回滚次数
- 悬挂次数
- Try/Confirm/Cancel 执行时间
- 成功率

3. 异常处理

异常处理策略:

1. Try 失败
   - 直接抛出异常
   - TC 不会调用 Confirm/Cancel

2. Confirm 失败
   - 重试,直到成功
   - 设置最大重试次数

3. Cancel 失败
   - 重试,直到成功
   - 重试间隔递增

总结

TCC 空回滚与悬挂处理的核心原则:

  1. 幂等控制:通过状态机确保每个操作只执行一次
  2. 空回滚防护:在 Cancel 执行前,检查预留记录是否存在
  3. 悬挂防护:在 Cancel 执行前,检查是否已经超时
  4. 合理超时:设置合适的超时时间,避免异常场景
  5. 日志监控:记录每个阶段的执行情况,便于排查问题

记住:TCC 模式的核心是幂等和状态管理。只有做好这两个方面,才能保证分布式事务的最终一致性。


源码获取

文章已同步至小程序博客栏目,需要源码的请关注小程序博客。

公众号:服务端技术精选

小程序码:


标题:TCC 空回滚与悬挂处理:网络抖动导致 Try 未执行直接 Cancel?幂等控制防误操作!
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/05/29/1779976849196.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消