分布式事务TCC模式:Try-Confirm-Cancel的幂等设计与空回滚处理
分布式事务TCC模式:Try-Confirm-Cancel的幂等设计与空回滚处理
适读人群:中高级Java工程师 | 阅读时长:约20分钟 | 技术栈:Spring Boot 3.x、Seata TCC、MySQL 8.0
开篇故事
上一篇讲了 Seata AT 模式在我们系统里出现全局锁性能问题,最终对高并发场景改用了 TCC 模式。TCC 确实解决了性能问题,但引入了一批新坑。
最让我记忆深刻的是一次"幽灵事务":我们的支付服务使用 TCC 实现,某次网络超时后,TC 发起了 Cancel,Cancel 执行成功,资金被退回了。但约 30 秒后,由于重试机制,那个延迟的 Try 请求才到达服务端,Try 执行成功,把资金又冻结了一次——但 Confirm 永远不会来了,这笔资金就这样永久被冻结,用户余额凭空少了一笔。
这就是 TCC 的"悬挂"问题。排查这个问题花了将近三天,因为日志里看起来一切正常:Try 成功,Cancel 成功,看不出任何异常。最终是通过对账发现了资金差异,然后倒推才找到原因。
这次经历让我把 TCC 的三大异常——幂等、空回滚、悬挂——深刻烙在了脑子里。
一、核心问题分析
TCC 的基本思路
TCC 把一个业务操作拆分为三个阶段:
Try(预留):检查业务可行性,预留业务资源。例如:检查账户余额是否充足,如果充足则冻结对应金额(不是真正扣款,只是标记为冻结状态)。
Confirm(确认):在 Try 成功的前提下,真正执行业务操作。例如:将冻结的金额实际扣除,转移到收款方。
Cancel(取消):在 Try 成功但后续出现问题时,撤销 Try 阶段的预留。例如:解冻冻结的金额,恢复账户余额。
与 AT 模式相比,TCC 没有全局锁,没有 undo_log,对业务的控制粒度更细,性能更好,但对业务代码的侵入更大——需要实现三个接口。
TCC 的三大异常
理论上 TCC 很清晰,但在网络不可靠的分布式环境中,有三类必须处理的异常:
幂等问题:网络超时后重试,可能导致 Confirm 或 Cancel 被调用多次。如果不做幂等处理,会重复扣款或重复退款。
空回滚:Try 因为网络问题没有执行到,TC 却发来了 Cancel 请求。Cancel 必须能处理"没有对应 Try 记录"的情况,不能报错(否则 TC 会认为 Cancel 失败,不断重试)。
悬挂:Cancel 先于 Try 执行(网络延迟导致 Try 在 Cancel 之后才到达),此时 Try 执行会生成一条预留记录,但 Cancel 已经执行过了,Confirm 永远不会来,资源永久被预留。
二、原理深度解析
三大异常的时序分析
解决三大异常的统一方案
核心思路:维护一张 TCC 事务状态表,记录每个事务的状态(INIT / CONFIRMED / CANCELLED)。
- Try 阶段:插入状态为 INIT 的记录(如果已存在防悬挂标记则拒绝)。
- Confirm 阶段:将状态从 INIT 改为 CONFIRMED(幂等:如果已是 CONFIRMED 直接返回成功)。
- Cancel 阶段:将状态从 INIT 改为 CANCELLED(空回滚:如果记录不存在,插入 CANCELLED 防悬挂标记;幂等:如果已是 CANCELLED 直接返回成功)。
三、完整代码实现
状态表设计
CREATE TABLE tcc_fence_log (
xid VARCHAR(128) NOT NULL COMMENT '全局事务ID',
branch_id BIGINT NOT NULL COMMENT '分支事务ID',
action_name VARCHAR(64) NOT NULL COMMENT 'TCC接口名称',
status TINYINT NOT NULL COMMENT '0:INIT 1:CONFIRMED 2:CANCELLED 3:SUSPEND(防悬挂)',
gmt_create DATETIME(3) NOT NULL,
gmt_modified DATETIME(3) NOT NULL,
PRIMARY KEY (xid, branch_id)
) ENGINE=InnoDB;
-- 账户冻结记录(业务表,不是通用表)
CREATE TABLE account_freeze (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
xid VARCHAR(128) NOT NULL COMMENT '全局事务ID',
user_id BIGINT NOT NULL,
freeze_amount DECIMAL(12,2) NOT NULL COMMENT '冻结金额',
state TINYINT NOT NULL COMMENT '0:冻结中 1:已确认 2:已取消',
create_time DATETIME NOT NULL,
UNIQUE KEY uk_xid (xid)
) ENGINE=InnoDB;TCC 账户服务实现
@LocalTCC
public interface AccountTccService {
/**
* Try 阶段:冻结资金
*/
@TwoPhaseBusinessAction(name = "AccountTccService",
commitMethod = "confirm",
rollbackMethod = "cancel")
boolean tryFreeze(BusinessActionContext context,
@BusinessActionContextParameter(paramName = "userId") Long userId,
@BusinessActionContextParameter(paramName = "amount") BigDecimal amount);
/**
* Confirm 阶段:真正扣款
*/
boolean confirm(BusinessActionContext context);
/**
* Cancel 阶段:解冻资金
*/
boolean cancel(BusinessActionContext context);
}@Service
@Slf4j
public class AccountTccServiceImpl implements AccountTccService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
/**
* Try:冻结金额
* 解决悬挂:检查是否已有 Cancel 记录
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean tryFreeze(BusinessActionContext context,
Long userId, BigDecimal amount) {
String xid = context.getXid();
log.info("TCC Try 开始,xid={}, userId={}, amount={}", xid, userId, amount);
// 防悬挂:检查是否已经有 Cancel 操作
AccountFreeze existingFreeze = freezeMapper.selectByXid(xid);
if (existingFreeze != null && existingFreeze.getState() == FreezeState.CANCELLED) {
log.warn("TCC 悬挂检测:Cancel 已执行,拒绝 Try,xid={}", xid);
return false;
}
// 幂等:如果 Try 已执行过,直接返回
if (existingFreeze != null && existingFreeze.getState() == FreezeState.FREEZE) {
log.info("TCC Try 已执行(幂等),xid={}", xid);
return true;
}
// 检查余额
Account account = accountMapper.selectByUserId(userId);
if (account == null) {
throw new BusinessException("账户不存在: " + userId);
}
if (account.getBalance().compareTo(amount) < 0) {
return false;
}
// 冻结金额(不真正扣除,只是标记)
int rows = accountMapper.freeze(userId, amount);
if (rows == 0) {
return false;
}
// 记录冻结信息
AccountFreeze freeze = AccountFreeze.builder()
.xid(xid)
.userId(userId)
.freezeAmount(amount)
.state(FreezeState.FREEZE)
.createTime(LocalDateTime.now())
.build();
freezeMapper.insert(freeze);
return true;
}
/**
* Confirm:真正扣款
* 幂等:如果已确认,直接返回 true
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean confirm(BusinessActionContext context) {
String xid = context.getXid();
log.info("TCC Confirm 开始,xid={}", xid);
AccountFreeze freeze = freezeMapper.selectByXid(xid);
// 空确认(Try 未成功,不应该来 Confirm,但做防御处理)
if (freeze == null) {
log.warn("TCC Confirm 空确认,xid={}", xid);
return true;
}
// 幂等:已确认,直接返回
if (freeze.getState() == FreezeState.CONFIRMED) {
log.info("TCC Confirm 已执行(幂等),xid={}", xid);
return true;
}
// 从冻结余额转为实际扣除
accountMapper.deductFrozen(freeze.getUserId(), freeze.getFreezeAmount());
// 更新冻结记录状态
freezeMapper.updateState(xid, FreezeState.CONFIRMED);
return true;
}
/**
* Cancel:解冻资金
* 空回滚:Try 未执行,直接插入 CANCELLED 防悬挂标记
* 幂等:已取消,直接返回 true
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean cancel(BusinessActionContext context) {
String xid = context.getXid();
log.info("TCC Cancel 开始,xid={}", xid);
AccountFreeze freeze = freezeMapper.selectByXid(xid);
// 空回滚:Try 未执行,插入防悬挂标记后返回
if (freeze == null) {
log.info("TCC 空回滚,插入防悬挂标记,xid={}", xid);
AccountFreeze suspendRecord = AccountFreeze.builder()
.xid(xid)
.userId(0L) // 占位
.freezeAmount(BigDecimal.ZERO)
.state(FreezeState.CANCELLED)
.createTime(LocalDateTime.now())
.build();
freezeMapper.insertOrIgnore(suspendRecord);
return true;
}
// 幂等:已取消,直接返回
if (freeze.getState() == FreezeState.CANCELLED) {
log.info("TCC Cancel 已执行(幂等),xid={}", xid);
return true;
}
// 解冻金额(归还到可用余额)
accountMapper.unfreeze(freeze.getUserId(), freeze.getFreezeAmount());
// 更新冻结记录状态
freezeMapper.updateState(xid, FreezeState.CANCELLED);
return true;
}
}Mapper 层
@Mapper
public interface AccountMapper {
@Select("SELECT * FROM account WHERE user_id = #{userId} FOR UPDATE")
Account selectByUserId(Long userId);
@Update("UPDATE account SET balance = balance - #{amount}, " +
"frozen_amount = frozen_amount + #{amount} " +
"WHERE user_id = #{userId} AND balance >= #{amount}")
int freeze(@Param("userId") Long userId, @Param("amount") BigDecimal amount);
@Update("UPDATE account SET frozen_amount = frozen_amount - #{amount} " +
"WHERE user_id = #{userId}")
int deductFrozen(@Param("userId") Long userId, @Param("amount") BigDecimal amount);
@Update("UPDATE account SET balance = balance + #{amount}, " +
"frozen_amount = frozen_amount - #{amount} " +
"WHERE user_id = #{userId}")
int unfreeze(@Param("userId") Long userId, @Param("amount") BigDecimal amount);
}发起方(TM)
@Service
@Slf4j
public class PaymentService {
@Autowired
private AccountTccService accountTccService;
@Autowired
private MerchantTccService merchantTccService;
@GlobalTransactional(name = "payment-tx", rollbackFor = Exception.class)
public boolean pay(Long fromUserId, Long toMerchantId, BigDecimal amount) {
// Try:冻结用户金额
boolean tryResult = accountTccService.tryFreeze(null, fromUserId, amount);
if (!tryResult) {
throw new BusinessException("用户余额不足");
}
// Try:预增商户待结算金额
boolean merchantTryResult = merchantTccService.tryCredit(null, toMerchantId, amount);
if (!merchantTryResult) {
throw new BusinessException("商户账户异常");
}
// 两个 Try 都成功,TC 会自动调用 Confirm
return true;
}
}四、生产调优与配置
TCC 事务超时设置
TCC 的全局事务超时一定要合理设置。如果设太短,网络抖动就会触发 Cancel;设太长,资源被预留太久,影响其他业务。
@GlobalTransactional(
name = "payment-tx",
rollbackFor = Exception.class,
timeoutMills = 10000 // 10秒,根据业务链路最大耗时设置
)超时事务的自动处理
TC Server 会对超时的全局事务发起 Cancel。生产建议开启 Seata 的任务恢复机制,防止 TC Server 重启后,挂起的事务无法被处理:
# seata-server 配置
server.recovery.committingRetryPeriod=1000
server.recovery.asynCommittingRetryPeriod=1000
server.recovery.rollbackingRetryPeriod=1000
server.recovery.timeoutRetryPeriod=1000五、踩坑实录
坑一:开篇故事的悬挂问题复盘
根本原因是 Try 阶段网络超时,TC 超时后触发了 Cancel,Cancel 此时找不到 Try 记录(空回滚),执行成功。30 秒后网络恢复,那个迟到的 Try 请求到达服务端,因为没有检查是否已有 Cancel 记录,直接插入了 FREEZE 状态,资金被永久冻结。
加了防悬挂检查之后(Try 阶段检查是否已有 CANCELLED 记录),这个问题就不再出现了。但教训是:TCC 的三大异常必须在最初设计时就考虑,而不是等出了问题再补。
坑二:并发 Cancel 导致的双倍解冻
有一次压测中,Cancel 接口被并发调用了两次(TC 重试)。第一次 Cancel 执行成功,解冻了金额,数据库里 frozen_amount 从 100 变成了 0,balance 从 900 变成了 1000。第二次 Cancel 因为幂等检查没做好,又执行了一次解冻,frozen_amount 变成了 -100,balance 变成了 1100——用户账户凭空多出了 100 元。
修复方案:Cancel 阶段先用 SELECT ... FOR UPDATE 锁行,然后检查 frozen_amount 是否 >= freeze_amount,不满足则说明已经解冻过了,直接返回。
坑三:TCC 接口的本地事务边界
有一个同事在 Cancel 方法里调用了多个 Mapper 操作,但忘记加 @Transactional,导致部分操作成功、部分失败时,数据处于中间状态。TCC 的三个阶段方法必须都加 @Transactional,确保每个阶段内部是原子的。
六、总结
TCC 模式的核心设计原则:
一、三大异常必须全部处理:幂等(重复调用)、空回滚(Try 未执行的 Cancel)、悬挂(Cancel 后的 Try),缺一不可。
二、状态机设计要严谨:冻结记录的状态转换是 INIT -> CONFIRMED 或 INIT -> CANCELLED,任何不符合状态转换规则的操作直接幂等返回,不执行业务逻辑。
三、数据库唯一键是最后防线:freeze_log 表的 xid 唯一键,可以防止 Try 被并发执行两次(并发时只有一个能插入成功)。
TCC 对业务侵入大,但性能好(无全局锁),隔离性控制更灵活,适合对性能和隔离性都有要求的核心链路。
