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 空回滚与悬挂处理的核心原则:
- 幂等控制:通过状态机确保每个操作只执行一次
- 空回滚防护:在 Cancel 执行前,检查预留记录是否存在
- 悬挂防护:在 Cancel 执行前,检查是否已经超时
- 合理超时:设置合适的超时时间,避免异常场景
- 日志监控:记录每个阶段的执行情况,便于排查问题
记住:TCC 模式的核心是幂等和状态管理。只有做好这两个方面,才能保证分布式事务的最终一致性。
源码获取
文章已同步至小程序博客栏目,需要源码的请关注小程序博客。
公众号:服务端技术精选
小程序码:
标题:TCC 空回滚与悬挂处理:网络抖动导致 Try 未执行直接 Cancel?幂等控制防误操作!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/05/29/1779976849196.html
公众号:服务端技术精选
评论
0 评论