TCC 幂等性设计
问题背景
2024年618大促,我们的库存服务经历了两次惊心动魄的故障。
第一次:TCC 事务超时后重试,但补偿操作执行了两次,导致库存被扣了两次。
第二次:更诡异——库存被扣了,但订单状态一直是"处理中",用户打电话投诉。
两次故障有一个共同根因:TCC 的幂等性设计不完整。
TCC 模式的核心问题是:Try/Confirm/Cancel 三个接口可能被调用多次(网络超时导致重试),每次调用必须产生相同的最终状态。幂等性是 TCC 正确性的基石——没有幂等性,TCC 的任何优势都不存在。
今天这篇,我们把 TCC 幂等性设计彻底讲透。
一、TCC 幂等性的核心问题
TCC 的三个接口(Try/Confirm/Cancel)都面临同一个问题:可能被重复调用。
调用链路分析:
客户端 → TCC 服务
↓ 网络超时/重试
↓ 可能多次调用
TCC 服务 → 资源服务(Try/Confirm/Cancel)
↓
最终状态必须一致
网络超时导致重试是最常见的场景:
- 客户端调用 Try,超时未收到响应
- 客户端重试,Try 被执行第二次
- Confirm 被调用时,Try 可能被执行了两次
如果不解决幂等性:
- Try 执行两次 → 库存被冻结两次?金额被预扣两次?
- Confirm 执行两次 → 库存被扣两次?金额被转出两次?
- Cancel 执行两次 → 库存被解冻两次?金额被退回两次?
【架构权衡】
幂等性是 TCC 的"隐形需求"——不在接口定义里体现,但一旦缺失就是生产故障。设计 TCC 接口时,必须假设每个接口都会被调用 N 次(N >= 1),并保证最终状态相同。
二、幂等性实现的核心:分支事务ID
幂等性的基础是分支事务ID(Branch ID / XID)。
每个 TCC 事务有一个全局唯一的全局事务ID(Transaction ID / XID),每个分支操作有一个分支事务ID(Branch ID)。
graph LR
A[全局事务 ID<br/>xid = uuid-12345] --> B[分支 1: Try stock]
A --> C[分支 2: Try account]
A --> D[分支 3: Try coupon]
B --> E[分支事务 ID<br/>branchId = stock-001]
C --> F[分支事务 ID<br/>branchId = account-002]
D --> G[分支事务 ID<br/>branchId = coupon-003]
所有幂等性设计都围绕分支事务ID展开:
- 状态记录:用分支事务ID记录 Try/Confirm/Cancel 的执行状态
- 去重判断:根据状态判断操作是否已经执行过
- 防悬挂:根据 Try 的执行状态决定是否执行 Cancel
三、TCC 三种接口的幂等设计
3.1 Try 的幂等设计
Try 接口负责预留资源(冻结库存、预扣金额)。幂等性要求:多次 Try 调用产生的预留状态相同。
方案:预留状态表
CREATE TABLE `tcc_reserve` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`branch_id` VARCHAR(64) NOT NULL COMMENT '分支事务ID',
`xid` VARCHAR(64) NOT NULL COMMENT '全局事务ID',
`resource_type` VARCHAR(32) NOT NULL COMMENT '资源类型:stock/account',
`resource_id` VARCHAR(64) NOT NULL COMMENT '资源ID:sku_id/account_id',
`reserve_amount` DECIMAL(19,4) NOT NULL COMMENT '预留金额',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-预留中 1-确认 2-取消',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_branch_id` (`branch_id`),
KEY `idx_xid` (`xid`),
KEY `idx_resource` (`resource_type`, `resource_id`)
);
// Try 接口的幂等实现
@TwoPhaseBusinessAction(name = "stockTry")
public class StockTccAction implements BusinessAction {
@Override
public boolean tryExecute(StockDTO stockDTO) {
String branchId = businessActionContext.getBranchId();
String xid = businessActionContext.getXid();
// 幂等检查:是否已经执行过 Try
TccReserve existing = tccReserveMapper.selectByBranchId(branchId);
if (existing != null) {
// 已执行过,根据状态判断
if (existing.getStatus() == ReserveStatus.CONFIRMED) {
// Try 已确认(Confirm 已执行),返回成功
return true;
}
if (existing.getStatus() == ReserveStatus.RESERVING) {
// 预留中,正常处理
}
}
// 执行预留操作
try {
// 冻结库存(预留)
stockService.freezeStock(stockDTO.getSkuId(), stockDTO.getQuantity());
} catch (InsufficientStockException e) {
// 库存不足是业务异常,不是幂等失败
return false;
}
// 记录预留状态(幂等保证)
TccReserve reserve = new TccReserve();
reserve.setBranchId(branchId);
reserve.setXid(xid);
reserve.setResourceType("stock");
reserve.setResourceId(stockDTO.getSkuId());
reserve.setReserveAmount(new BigDecimal(stockDTO.getQuantity()));
reserve.setStatus(ReserveStatus.RESERVING);
tccReserveMapper.insert(reserve);
return true;
}
@Override
public boolean confirm(StockDTO stockDTO) {
// Confirm 逻辑见下一节
}
@Override
public boolean cancel(StockDTO stockDTO) {
// Cancel 逻辑见第4节
}
}
3.2 Confirm 的幂等设计
Confirm 接口负责真正执行(扣减库存、转账)。幂等性要求:多次 Confirm 调用产生相同的最终状态(资源被扣减一次)。
// Confirm 接口的幂等实现
@Override
public boolean confirm(StockDTO stockDTO) {
String branchId = businessActionContext.getBranchId();
// 幂等检查:根据分支事务ID查询状态
TccReserve reserve = tccReserveMapper.selectByBranchId(branchId);
if (reserve == null) {
// 记录不存在:Try 未执行或已取消
// 由于幂等性设计,这种情况不应该发生
log.warn("TCC reserve not found for branchId={}, treating as idempotent success", branchId);
return true;
}
if (reserve.getStatus() == ReserveStatus.CONFIRMED) {
// 已确认,直接返回成功(幂等)
return true;
}
if (reserve.getStatus() == ReserveStatus.CANCELLED) {
// 已取消,不应该执行 Confirm
log.error("TCC confirm called on cancelled reserve, branchId={}", branchId);
return false;
}
// 执行真正的扣减
stockService.deductFrozenStock(
reserve.getResourceId(),
reserve.getReserveAmount().intValue()
);
// 更新状态为已确认
reserve.setStatus(ReserveStatus.CONFIRMED);
tccReserveMapper.updateById(reserve);
return true;
}
【架构权衡】
Confirm 幂等的核心是先查后改(Check-Before-Set):先查询当前状态,根据状态决定是否执行。这种方式在单机数据库下是原子的,但在分布式环境下需要注意:
- 查询和更新之间可能有并发修改
- 解决方案:用乐观锁(version 字段)或分布式锁
-- 乐观锁版本
UPDATE tcc_reserve
SET status = 1, version = version + 1
WHERE branch_id = ? AND status = 0 AND version = ?
-- 返回 affected_rows,如果为0说明有并发修改
3.3 Cancel 的幂等设计
Cancel 接口负责释放预留(解冻库存)。幂等性要求:多次 Cancel 调用产生相同的最终状态(资源被解冻一次)。
Cancel 的幂等性比 Confirm 更复杂,因为 Cancel 还需要处理空回滚问题(Try 未执行就执行 Cancel)。
// Cancel 接口的幂等实现
@Override
public boolean cancel(StockDTO stockDTO) {
String branchId = businessActionContext.getBranchId();
// 幂等检查
TccReserve reserve = tccReserveMapper.selectByBranchId(branchId);
if (reserve == null) {
// 记录不存在:Try 未执行(空回滚)
// 幂等处理:插入一条"空回滚记录"
log.info("Empty rollback detected for branchId={}, recording cancel", branchId);
recordEmptyCancel(branchId);
return true;
}
if (reserve.getStatus() == ReserveStatus.CANCELLED) {
// 已取消,直接返回成功(幂等)
return true;
}
if (reserve.getStatus() == ReserveStatus.CONFIRMED) {
// 已确认,不应该取消
log.error("TCC cancel called on confirmed reserve, branchId={}", branchId);
return false;
}
// 执行解冻
stockService.unfreezeStock(
reserve.getResourceId(),
reserve.getReserveAmount().intValue()
);
// 更新状态为已取消
reserve.setStatus(ReserveStatus.CANCELLED);
tccReserveMapper.updateById(reserve);
return true;
}
四、悬挂问题的幂等处理
4.1 悬挂的定义
悬挂(Hanging):Cancel 比 Try 先执行,或者 Try 执行后既没有 Confirm 也没有 Cancel,导致资源永远被锁定。
悬挂场景:
T1: Try 开始执行(freeze stock = 10)
T2: Cancel 被调用(但 Try 还没完成)
T3: Cancel 检查状态,发现没有预留记录,认为是空回滚
T4: Cancel 返回成功
T5: Try 执行完成,写入预留记录
→ 预留记录永远处于 RESERVING 状态,库存被永久冻结
4.2 悬挂检测机制
解决悬挂的核心是事务控制表:记录全局事务的状态,Confirm/Cancel 执行前检查 Try 是否已经完成。
CREATE TABLE `tcc_transaction` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`xid` VARCHAR(64) NOT NULL COMMENT '全局事务ID',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-尝试中 1-已完成 2-已取消',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_xid` (`xid`)
);
悬挂检测流程:
1. Try 执行前,在 tcc_transaction 插入一条状态为"尝试中"的记录
2. Try 执行后,更新状态为"已完成"(或"已取消")
3. Cancel 执行前,查询 tcc_transaction 的状态
- 如果状态是"尝试中":说明 Try 还没执行完,Cancel 需要等待(不能执行空回滚)
- 如果状态不存在:说明 Try 没执行过,执行空回滚
- 如果状态是"已完成":说明 Confirm 已执行,Cancel 应该拒绝
- 如果状态是"已取消":说明 Cancel 已执行,幂等返回
4.3 防悬挂的 Cancel 实现
@Override
public boolean cancel(StockDTO stockDTO) {
String branchId = businessActionContext.getBranchId();
String xid = businessActionContext.getXid();
// 第一步:检查全局事务状态(防悬挂)
TccTransaction tx = tccTransactionMapper.selectByXid(xid);
if (tx == null) {
// 全局事务不存在:这是一个孤立调用,忽略
log.warn("TCC transaction not found for xid={}, ignoring cancel", xid);
return true;
}
if (tx.getStatus() == TccStatus.CONFIRMED) {
// Try 已确认,不能 Cancel
log.error("Cannot cancel confirmed transaction, xid={}", xid);
return false;
}
if (tx.getStatus() == TccStatus.CANCELLED) {
// 已取消,幂等返回
return true;
}
// 状态是"尝试中":需要检查 Try 是否已经完成
TccReserve reserve = tccReserveMapper.selectByBranchId(branchId);
if (reserve == null) {
// Try 确实没执行过:空回滚
recordEmptyCancel(branchId, xid);
return true;
}
// Try 已执行但未确认:执行真正的 Cancel
stockService.unfreezeStock(reserve.getResourceId(), ...);
reserve.setStatus(ReserveStatus.CANCELLED);
tccReserveMapper.updateById(reserve);
// 更新全局事务状态
tccTransactionMapper.updateStatus(xid, TccStatus.CANCELLED);
return true;
}
【架构权衡】
防悬挂的关键是全局事务状态的可见性。如果全局事务状态不可见,Cancel 无法判断 Try 是否已经执行。Seata 的 TCC 模式正是通过 TC(Transaction Coordinator)来维护这个全局状态,解决了悬挂问题。
但 Seata 的方案也有代价:TC 本身是一个单点,需要高可用部署。每次 Cancel 都需要查询 TC,增加了网络开销。在超低延迟场景下,这个开销可能不可接受。
五、幂等性的终极保障:TCC 状态机
将所有幂等性逻辑封装在一个TCC 状态机中,保证每个分支事务的状态转换是严格定义的。
stateDiagram-v2
[*] --> RESERVING: Try
RESERVING --> CONFIRMED: Confirm
RESERVING --> CANCELLED: Cancel
RESERVING --> TIMEOUT: 超时未确认/取消
CONFIRMED --> CONFIRMED: 幂等(重复 Confirm)
CANCELLED --> CANCELLED: 幂等(重复 Cancel)
TIMEOUT --> CANCELLED: 超时补偿
TIMEOUT --> CONFIRMED: 超时恢复
状态转换规则:
RESERVING 可以转到 CONFIRMED 或 CANCELLED
CONFIRMED 是终态,所有操作幂等返回
CANCELLED 是终态,所有操作幂等返回
- 任何非终态都可以响应超时检测,触发补偿
// 状态机转换的原子保证
public boolean transitionState(String branchId, TccStatus from, TccStatus to) {
// 原子更新:只有当前状态是 from 时才能转到 to
int affected = tccReserveMapper.atomicTransition(branchId, from, to);
return affected == 1;
}
// 使用
if (transitionState(branchId, RESERVING, CONFIRMED)) {
// 状态转换成功,执行确认逻辑
doConfirm();
} else {
// 状态转换失败,可能是:
// 1. 已经转换过了(幂等)
// 2. 已经被取消了
return true; // 幂等返回
}
六、生产避坑
6.1 幂等表的主键冲突
如果用 branch_id 作为主键,在重复插入时会抛出 DuplicateKeyException。需要 try-catch 捕获,并判断是否为幂等场景。
try {
tccReserveMapper.insert(reserve);
} catch (DuplicateKeyException e) {
// 主键冲突:幂等返回
return true;
}
6.2 幂等检查的性能问题
每次 Try/Confirm/Cancel 都需要查询状态表,如果状态表在数据库中,高并发下可能成为瓶颈。
解决方案:
- 状态表和业务表放在同一个数据库,用本地事务保证原子性
- 状态表放在 Redis 中,减少数据库压力
- 使用 Seata/Apache DTM 等成熟框架,它们已经处理了这些问题
6.3 幂等性的边界:全局超时
即使有幂等性设计,如果事务超时后一直没有 Confirm/Cancel,资源会一直处于"预留"状态。
解决方案:
- TCC 服务定期扫描超时的预留记录,执行补偿
- 设置合理的超时时间(建议:
业务预估时间 × 2)
- 使用"伪删除"标记,保留审计记录
【架构权衡】
幂等性设计的目标不是"完全消除重复",而是"让重复执行产生相同的最终结果"。完美的幂等性需要额外的存储和计算成本,要根据业务容忍度来权衡:
- 金融支付:不允许任何重复,幂等性必须完美
- 普通电商:允许少量重复(库存多扣了 1 件,用售后处理),幂等性可以适当简化
- 日志采集:允许大量重复(消息队列本身支持重试),可以不实现幂等性
七、工程代价评估
八、落地 Checklist