TCC:资源预留、事务栅栏与空回滚悬挂全过程
TCC的Try、Confirm、Cancel都可能因超时和协调器恢复而重复调用。前置知识:分布式幂等与TCC/Saga步骤幂等。
一、学完本页要真正会什么
TCC不是把一个普通接口机械拆成三个方法。它要求业务资源本身存在“可用、预留、最终消费或释放”的状态模型,并用事务栅栏抵抗重复、乱序和超时。
学完本页,你应该能够:
- 说明Try、Confirm、Cancel分别改变什么业务事实。
- 设计库存、余额、优惠券的可用与冻结模型。
- 解释为什么“先查分支记录再冻结”存在并发竞态。
- 设计唯一事务栅栏和条件状态迁移。
- 画出Try和Cancel并发时怎样防止悬挂。
- 解释空回滚记录为什么不能省略。
- 说明Confirm/Cancel响应丢失后怎样幂等重放。
- 处理Try本地提交后协调器未收到响应的结果未知。
- 设计冻结资源超时扫描、事实核对和人工接管。
- 根据XID、branchId、bizKey、栅栏状态和资源流水排查。
二、TCC解决什么
TCC是业务层的资源预留协议:第一阶段不直接完成不可逆最终效果,而是把资源从“可用”转成“为某业务预留”;全局成功后Confirm消费预留,全局失败后Cancel释放预留。
flowchart TD
A["Try:检查并预留各参与者资源"] --> B{"所有Try是否成功"}
B -- "是" --> C["协调器持久化Commit决议"]
C --> D["重复调用Confirm直到分支收敛"]
B -- "否或全局超时" --> E["协调器持久化Rollback决议"]
E --> F["重复调用Cancel直到分支收敛"]TCC通常提供业务可控的隔离,不等于数据库XA原子提交。Try成功后其他业务看到的可用量已经减少,但全局事务尚未最终Confirm;这是一种显式中间状态。
三、资源模型:没有预留语义就不要硬套TCC
3.1 库存
total = available + frozen + sold/other states(按业务定义)- Try:
available -= n, frozen += n - Confirm:
frozen -= n, sold += n或只减少冻结并保留总库存定义 - Cancel:
frozen -= n, available += n
3.2 账户余额
bookBalance = availableBalance + frozenBalance- Try:冻结可用余额。
- Confirm:冻结余额转为已扣流水。
- Cancel:冻结余额退回可用余额。
3.3 优惠券
AVAILABLE → LOCKED(orderNo) → USED
↘ AVAILABLE(Cancel)短信已经发出、实体货物已经送达、不可撤销的第三方动作没有自然预留语义,不适合直接TCC。可以改用Saga补偿、可靠消息或最大努力通知。
四、三个标识不能混用
| 标识 | 作用 | 是否跨重试稳定 |
|---|---|---|
| XID/globalTxId | 标识一次全局事务尝试 | 同一全局事务恢复期间稳定 |
| branchId/action | 标识一个参与者分支 | 同一分支Confirm/Cancel稳定 |
| businessKey | 标识订单、支付、冻结流水等业务意图 | 客户端业务重试也应稳定 |
只按XID幂等不够:客户端超时后可能创建新XID,但业务订单仍是同一个。核心资源表还应对orderNo + skuId等业务键建立唯一约束,防止两个全局事务重复冻结同一业务。
五、库存和事务栅栏表设计
库存汇总表示当前容量:
create table t_inventory (
sku_id bigint not null,
total_count int not null,
available_count int not null,
frozen_count int not null,
sold_count int not null,
version bigint not null,
updated_at datetime not null,
primary key (sku_id)
);每次预留必须有独立业务流水,不能只改汇总计数:
create table t_tcc_reservation (
id bigint not null auto_increment,
xid varchar(128) not null,
branch_id varchar(128) not null,
action_name varchar(64) not null,
biz_key varchar(128) not null,
sku_id bigint null,
amount int null,
status varchar(32) not null,
owner_token varchar(64) null,
created_at datetime not null,
updated_at datetime not null,
primary key (id),
unique key uk_tcc_branch (xid, branch_id, action_name),
unique key uk_tcc_biz (biz_key, action_name),
key idx_tcc_status_time (status, updated_at)
);状态建议显式区分:
TRYING
TRY_SUCCEEDED
CONFIRMED
CANCELED
FAILED_MANUAL是否需要TRYING取决于实现方式,但终态和空回滚屏障必须可表达。DDL字段类型、长度和索引需按数据库、字符集和数据量评估。
六、为什么“先查记录再冻结”不安全
错误伪代码:
if (!branchRepository.exists(xid, branchId)) {
inventoryRepository.freeze(skuId, count);
branchRepository.insert(...);
}两个重复Try并发执行:
flowchart TD
A["Try-1查询:不存在"] --> C["Try-1冻结库存"]
B["Try-2查询:不存在"] --> D["Try-2也冻结库存"]
C --> E["Try-1插入栅栏成功"]
D --> F["Try-2最后插入唯一键冲突"]
F --> G["如果冻结与记录不在正确事务中,副作用已重复"]解决原则:先通过唯一键原子建立分支所有权,栅栏记录和资源变化放在同一本地事务;重复请求在唯一键处汇合,并根据已有状态返回。
七、Try完整流程
flowchart TD
A["收到XID、branchId、businessKey"] --> B["尝试插入TRYING栅栏记录"]
B --> C{"插入是否成功"}
C -- "成功" --> D["条件SQL冻结资源"]
D --> E{"影响行数是否为1"}
E -- "是" --> F["状态TRYING条件更新为TRY_SUCCEEDED"]
F --> G["同一本地事务提交"]
E -- "否" --> H["回滚栅栏和资源事务,返回业务拒绝"]
C -- "唯一键冲突" --> I["锁定并读取已有栅栏"]
I --> J{"已有状态"}
J -- "TRY_SUCCEEDED或CONFIRMED" --> K["幂等返回Try成功"]
J -- "CANCELED" --> L["拒绝迟到Try,防悬挂"]
J -- "TRYING" --> M["等待原处理完成或返回处理中"]库存条件SQL:
update t_inventory
set available_count = available_count - :amount,
frozen_count = frozen_count + :amount,
version = version + 1,
updated_at = now()
where sku_id = :skuId
and available_count >= :amount;影响行数必须为1;为0可能是库存不足、SKU不存在或并发状态已变化,要明确分类,不能仍把Try标记成功。
栅栏状态推进:
update t_tcc_reservation
set status = 'TRY_SUCCEEDED', updated_at = now()
where xid = :xid
and branch_id = :branchId
and action_name = :actionName
and status = 'TRYING';两个UPDATE和栅栏INSERT必须在同一本地事务中。
八、Confirm完整流程
flowchart TD
A["Confirm可能第一次或第N次到达"] --> B["按唯一分支锁定栅栏记录"]
B --> C{"当前状态"}
C -- "CONFIRMED" --> D["幂等返回成功"]
C -- "TRY_SUCCEEDED" --> E["条件消费冻结资源"]
E --> F["状态条件推进为CONFIRMED"]
F --> G["本地事务提交"]
C -- "CANCELED" --> H["决议冲突,告警而非反向再扣"]
C -- "不存在" --> I["协议/数据异常,不能伪成功"]库存Confirm:
update t_inventory
set frozen_count = frozen_count - :amount,
sold_count = sold_count + :amount,
version = version + 1,
updated_at = now()
where sku_id = :skuId
and frozen_count >= :amount;栅栏状态:
update t_tcc_reservation
set status = 'CONFIRMED', updated_at = now()
where xid = :xid
and branch_id = :branchId
and action_name = :actionName
and status = 'TRY_SUCCEEDED';若资源UPDATE成功但状态UPDATE失败,两者必须一起回滚,否则协调器重试Confirm会再次扣冻结量。
九、Cancel完整流程
flowchart TD
A["Cancel到达"] --> B["尝试插入CANCELED空回滚屏障"]
B --> C{"是否插入成功"}
C -- "成功" --> D["说明Try尚无记录,不释放资源,幂等返回"]
C -- "唯一键冲突" --> E["锁定已有栅栏"]
E --> F{"当前状态"}
F -- "CANCELED" --> G["幂等返回成功"]
F -- "TRY_SUCCEEDED" --> H["条件释放冻结资源"]
H --> I["状态推进为CANCELED并同事务提交"]
F -- "CONFIRMED" --> J["决议冲突,不能再释放"]
F -- "TRYING" --> K["等待Try本地事务决出或通过行锁串行化"]库存Cancel:
update t_inventory
set available_count = available_count + :amount,
frozen_count = frozen_count - :amount,
version = version + 1,
updated_at = now()
where sku_id = :skuId
and frozen_count >= :amount;空回滚时sku_id/amount可以为空,因为没有真实资源预留;它的价值是形成CANCELED屏障,阻止迟到Try。
十、幂等不是“查到记录就return true”
正确幂等要区分状态和请求内容:
- 同一分支、同一参数、已完成:复用原结果。
- 同一业务键但金额/SKU/数量不同:拒绝,不能复用。
- Confirm已成功:重复Confirm返回成功。
- Cancel已成功:重复Cancel返回成功。
- Cancel后收到Confirm:协议冲突,不能返回成功掩盖。
- Confirm后收到Cancel:协议冲突,不能释放已消费资源。
栅栏记录应保存请求摘要或关键资源参数,防止同一个branchId被错误复用于不同请求。
十一、空回滚为什么一定要留下记录
flowchart TD
A["协调器发送Try"] --> B["Try请求未到达参与者"]
B --> C["全局事务超时并决定Rollback"]
C --> D["Cancel到达但没有Try记录"]
D --> E["插入CANCELED空回滚栅栏"]
E --> F["不执行资源释放,返回Cancel成功"]如果Cancel只是“查不到就return”,迟到Try到达时仍会看到无记录并冻结资源,形成悬挂。空回滚记录既表示Cancel幂等完成,也是一道未来Try不可越过的屏障。
十二、悬挂是怎样发生的
flowchart TD
A["Try在网络中长时间延迟"] --> B["协调器超时并持久化Rollback决议"]
B --> C["Cancel先到并插入CANCELED"]
C --> D["迟到Try随后到达"]
D --> E["Try唯一键冲突并读取CANCELED"]
E --> F["拒绝冻结资源"]关键不是“Cancel调用得更快”,而是Try与Cancel对同一唯一栅栏记录竞争,并通过数据库唯一键/行锁和本地事务形成确定顺序。
十三、Try与Cancel真正并发时怎样串行化
两种顺序都必须安全:
Try先获得唯一记录
- Try插入TRYING并持有未提交唯一键/行锁。
- Cancel插入同键会等待或冲突。
- Try冻结并提交TRY_SUCCEEDED。
- Cancel继续,读取TRY_SUCCEEDED并释放资源。
Cancel先获得唯一记录
- Cancel插入CANCELED并提交。
- Try插入唯一键冲突。
- Try读取CANCELED,拒绝冻结。
这要求数据库隔离、唯一键冲突和事务边界经过真实并发测试。不能只在单线程单元测试中验证。
十四、Try提交后响应丢失怎么办
Try本地事务已提交TRY_SUCCEEDED和冻结库存
→ 响应在网络中丢失
→ 协调器看到Try超时协调器可能根据协议重试Try或决定回滚。参与者必须:
- 重试Try时按栅栏返回第一次成功结果,不能再次冻结。
- Cancel到达时按原记录释放冻结。
- 对外提供按XID/branchId/businessKey查询分支事实的能力。
调用方不能用新businessKey重试,否则唯一栅栏无法识别同一业务。
十五、协调器最终决议为什么必须持久化
所有Try成功后,协调器在调用Confirm前要保存Commit决议;一旦决议是Commit,即使协调器重启也应继续重试Confirm,不能改成Cancel。Rollback同理。
flowchart TD
A["所有Try成功"] --> B["协调器持久化COMMITTING"]
B --> C["Confirm库存成功"]
B --> D["Confirm账户响应丢失"]
D --> E["协调器重启读取COMMITTING"]
E --> F["继续幂等重试账户Confirm"]TCC把数据库锁等待换成业务冻结和二阶段重试,协调器日志和参与者栅栏共同保证恢复。
十六、TCC的一致性和隔离边界
Try后可用库存已减少,因此其他正常下单不会使用这部分资源;这提供业务层写隔离。但读侧可能看到冻结量和中间状态。
必须定义:
- 用户查询是否展示“处理中”。
- 冻结资源最长允许多久。
- 同一用户/订单能否创建多个冻结。
- 全局事务失败但Cancel暂未成功时如何限制新操作。
- 人工调整库存时是否检查未结束冻结。
TCC不是严格串行化数据库事务。两个不同业务资源之间仍可能有业务竞态,必须通过条件SQL、唯一约束和状态机维护不变量。
十七、冻结超时能不能由参与者自行释放
不能只看updated_at超过30秒就直接释放。协调器可能已经形成Commit决议,只是Confirm延迟;参与者擅自Cancel会造成全局混合结果。
安全扫描流程:
- 找到长时间TRY_SUCCEEDED记录。
- 查询协调器最终状态。
- 若明确COMMIT,幂等Confirm。
- 若明确ROLLBACK,幂等Cancel。
- 若协调器状态未知,结合业务事实、恢复日志和租约进入待确认。
- 超过自动恢复阈值,告警并人工处理。
只有业务协议明确把“预留租约到期自动失效”定义为Try语义时,才能按租约自动释放;协调器也必须理解这种资源已失效状态。
十八、Java服务骨架:状态必须驱动动作
public interface InventoryTccAction {
boolean tryReserve(String xid, String branchId,
String orderNo, Long skuId, int amount);
boolean confirm(String xid, String branchId);
boolean cancel(String xid, String branchId);
}核心实现应由本地事务包住“栅栏 + 资源”:
@Transactional(rollbackFor = Exception.class)
public boolean confirm(String xid, String branchId) {
Reservation reservation = repository.lockByBranch(xid, branchId);
if (reservation == null) {
throw new IllegalStateException("missing successful Try");
}
if (reservation.isConfirmed()) {
return true;
}
if (!reservation.isTrySucceeded()) {
throw new IllegalStateException("decision conflicts with "
+ reservation.getStatus());
}
int changed = inventoryRepository.consumeFrozen(
reservation.getSkuId(), reservation.getAmount());
if (changed != 1) {
throw new IllegalStateException("frozen resource mismatch");
}
if (repository.markConfirmed(xid, branchId) != 1) {
throw new IllegalStateException("reservation state changed");
}
return true;
}lockByBranch应在当前数据库中使用合适的锁定读或原子状态更新。具体实现依赖数据库隔离级别,不能把普通SELECT结果当并发锁。
十九、商业场景:订单、库存、余额和优惠券
flowchart TD
A["订单Try创建PENDING_CONFIRM"] --> B["库存Try冻结数量"]
B --> C["余额Try冻结金额"]
C --> D["优惠券Try锁定订单"]
D --> E{"全局决议"}
E -- "Commit" --> F["各分支Confirm消费预留"]
E -- "Rollback" --> G["各分支Cancel释放预留"]优化原则:
- Try按固定顺序调用资源,减少无效预留。
- 高失败概率、便宜检查尽量前置。
- Confirm/Cancel只做短小本地事务,不调用慢外部接口。
- 每个分支用业务流水唯一约束。
- 非核心通知和ES同步放到事务后事件,不纳入TCC。
二十、TCC、XA和Saga比较
| 维度 | TCC | XA | Saga |
|---|---|---|---|
| 第一阶段 | 业务预留并本地提交 | 资源Prepare未最终提交 | 每步业务直接本地提交 |
| 隔离 | 可用/冻结业务模型 | 数据库锁和XA语义 | 允许显式中间状态 |
| 二阶段 | Confirm/Cancel | Commit/Rollback | 失败后业务补偿 |
| 业务侵入 | 高 | 相对低 | 高 |
| 时长 | 短到中 | 必须短 | 可长 |
| 适合 | 库存、余额、券 | XA资源短事务 | 履约、审批、物流 |
| 主要风险 | 空回滚、悬挂、冻结 | 阻塞、in-doubt | 补偿失败、不可逆步骤 |
二十一、监控和对账指标
| 指标 | 说明 |
|---|---|
| try_success/failure | Try容量与业务拒绝 |
| confirm_retry/cancel_retry | 二阶段恢复健康度 |
| empty_rollback_total | 空回滚和网络乱序频率 |
| hanging_try_rejected | 被CANCELED屏障拒绝的迟到Try |
| frozen_age_p95/max | 冻结资源持续时间 |
| reservation_state_count | 各状态数量 |
| fence_conflict_total | 唯一键和状态竞态 |
| manual_intervention_total | 自动恢复失败规模 |
| summary_vs_reservation_diff | 汇总冻结量与流水聚合差异 |
对账至少验证:
inventory.frozen_count
= 所有未终态/TRY_SUCCEEDED预留流水amount之和具体等式按业务状态定义,确认和取消流水也要保留审计依据。
二十二、生产排查Runbook
22.1 冻结资源长期不释放
- 按orderNo/businessKey找到XID和branchId。
- 查栅栏状态和最后更新时间。
- 查协调器全局最终决议。
- 若Commit,检查Confirm重试、资源条件SQL和响应丢失。
- 若Rollback,检查Cancel重试和冻结量是否匹配。
- 若全局未知,查协调日志和其他分支事实,不能直接释放。
- 达到阈值后进入人工处置并记录审计。
22.2 Confirm重复扣减
检查资源修改和TRY_SUCCEEDED → CONFIRMED是否在同一本地事务;是否先执行资源UPDATE再无条件标状态;是否普通SELECT未锁定导致两个Confirm同时看到TRY_SUCCEEDED。
22.3 Cancel返回成功但资源没释放
区分空回滚和真实Try:空回滚本来就没有资源;真实TRY_SUCCEEDED则核对sku、amount、冻结汇总、资源流水和Cancel条件SQL影响行数。不能无视影响行数返回成功。
22.4 Try在Cancel后仍冻结
检查Cancel是否真的插入CANCELED屏障、Try是否先原子插入唯一栅栏、两者是否使用同一XID/branchId/actionName,以及是否有代码绕过栅栏直接改库存。
22.5 汇总冻结量和流水不一致
冻结新写入,导出库存行和所有预留流水,按状态聚合差异,定位事务边界缺口或人工改数;通过受审计修复调整,不要只把frozen_count改成0。
二十三、JDK 8 Demo:空回滚屏障阻止迟到Try
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class TccFenceDemo {
enum Status { TRY_SUCCEEDED, CONFIRMED, CANCELED }
static final class Fence {
private final Map<String, Status> states =
new ConcurrentHashMap<String, Status>();
private int frozen;
synchronized boolean cancel(String branch) {
Status state = states.get(branch);
if (state == null) {
states.put(branch, Status.CANCELED);
return true;
}
if (state == Status.CANCELED) {
return true;
}
if (state == Status.TRY_SUCCEEDED) {
frozen--;
states.put(branch, Status.CANCELED);
return true;
}
return false;
}
synchronized boolean tryReserve(String branch) {
Status state = states.get(branch);
if (state == Status.CANCELED) {
return false;
}
if (state == Status.TRY_SUCCEEDED || state == Status.CONFIRMED) {
return true;
}
frozen++;
states.put(branch, Status.TRY_SUCCEEDED);
return true;
}
}
public static void main(String[] args) {
Fence fence = new Fence();
System.out.println("emptyCancel=" + fence.cancel("B-1"));
System.out.println("lateTry=" + fence.tryReserve("B-1"));
System.out.println("frozen=" + fence.frozen);
}
}输出:
emptyCancel=true
lateTry=false
frozen=0真实系统依赖数据库唯一键、行锁和本地事务,不应使用单JVMsynchronized实现分布式TCC;Demo只解释屏障状态机。
二十四、常见错误与后果
| 错误 | 后果 | 正确方向 |
|---|---|---|
| 先exists再冻结 | 并发Try都执行副作用 | 唯一栅栏先占位,同事务改资源 |
| 空Cancel只return不留记录 | 迟到Try形成悬挂 | 插入CANCELED屏障 |
| Confirm先改资源再单独改状态 | 状态失败后重试重复消费 | 资源和栅栏同本地事务 |
| Cancel忽略SQL影响行数 | 表面成功但冻结仍存在 | 条件SQL、影响行数和异常分类 |
| 只按XID不按业务键 | 新XID重复冻结同一订单 | businessKey唯一约束 |
| 超时扫描直接释放 | 可能违背已形成Commit决议 | 先查询协调器最终状态 |
| TCC包含短信发货等不可预留动作 | Cancel无法恢复 | 改Saga、消息或后置不可逆步骤 |
| Confirm/Cancel返回伪成功 | 协调器停止重试但数据未收敛 | 协议冲突和资源不匹配必须告警 |
| 不对账冻结汇总和流水 | 静默资源泄漏 | 定期聚合核对和人工处置 |
二十五、面试标准回答
25.1 TCC是什么
TCC是业务层资源预留协议。Try校验并把资源从可用转成冻结,所有Try成功后协调器持久化Commit决议并幂等调用Confirm消费冻结;任一失败则持久化Rollback决议并幂等调用Cancel释放。它适合库存、余额、优惠券等有自然预留模型的资源。
25.2 空回滚和悬挂怎么解决
Cancel在没有Try记录时也插入CANCELED事务栅栏,表示空回滚已完成;迟到Try尝试插入同一唯一分支时发生冲突,读取到CANCELED后拒绝冻结,从而防止悬挂。Try和Cancel对同一唯一记录竞争,依靠数据库事务形成确定顺序。
25.3 为什么先查后写不够
两个并发Try可能都查询到不存在并同时冻结,唯一键冲突发生在副作用之后。应先用唯一约束原子建立分支所有权,再在同一本地事务内冻结资源和推进栅栏状态。
25.4 冻结超时为什么不能直接释放
协调器可能已形成Commit决议,只是Confirm延迟。参与者自行释放会造成混合结果。扫描任务应先查询全局决议:Commit则补Confirm,Rollback则补Cancel,未知则结合恢复日志和事实源进入待确认或人工处理。
二十六、关联知识点
本章小结
TCC的核心不是三个接口名称,而是可预留资源模型、唯一事务栅栏、条件状态迁移和可恢复最终决议。只有把Try与Cancel并发、空回滚、悬挂、响应丢失和冻结对账全部设计清楚,TCC才具备生产可用性。
