Skip to content

TCC:资源预留、事务栅栏与空回滚悬挂全过程

TCC的Try、Confirm、Cancel都可能因超时和协调器恢复而重复调用。前置知识:分布式幂等与TCC/Saga步骤幂等

一、学完本页要真正会什么

TCC不是把一个普通接口机械拆成三个方法。它要求业务资源本身存在“可用、预留、最终消费或释放”的状态模型,并用事务栅栏抵抗重复、乱序和超时。

学完本页,你应该能够:

  1. 说明Try、Confirm、Cancel分别改变什么业务事实。
  2. 设计库存、余额、优惠券的可用与冻结模型。
  3. 解释为什么“先查分支记录再冻结”存在并发竞态。
  4. 设计唯一事务栅栏和条件状态迁移。
  5. 画出Try和Cancel并发时怎样防止悬挂。
  6. 解释空回滚记录为什么不能省略。
  7. 说明Confirm/Cancel响应丢失后怎样幂等重放。
  8. 处理Try本地提交后协调器未收到响应的结果未知。
  9. 设计冻结资源超时扫描、事实核对和人工接管。
  10. 根据XID、branchId、bizKey、栅栏状态和资源流水排查。

二、TCC解决什么

TCC是业务层的资源预留协议:第一阶段不直接完成不可逆最终效果,而是把资源从“可用”转成“为某业务预留”;全局成功后Confirm消费预留,全局失败后Cancel释放预留。

mermaid
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 库存

text
total = available + frozen + sold/other states(按业务定义)
  • Try:available -= n, frozen += n
  • Confirm:frozen -= n, sold += n或只减少冻结并保留总库存定义
  • Cancel:frozen -= n, available += n

3.2 账户余额

text
bookBalance = availableBalance + frozenBalance
  • Try:冻结可用余额。
  • Confirm:冻结余额转为已扣流水。
  • Cancel:冻结余额退回可用余额。

3.3 优惠券

text
AVAILABLE → LOCKED(orderNo) → USED
                         ↘ AVAILABLE(Cancel)

短信已经发出、实体货物已经送达、不可撤销的第三方动作没有自然预留语义,不适合直接TCC。可以改用Saga补偿、可靠消息或最大努力通知。

四、三个标识不能混用

标识作用是否跨重试稳定
XID/globalTxId标识一次全局事务尝试同一全局事务恢复期间稳定
branchId/action标识一个参与者分支同一分支Confirm/Cancel稳定
businessKey标识订单、支付、冻结流水等业务意图客户端业务重试也应稳定

只按XID幂等不够:客户端超时后可能创建新XID,但业务订单仍是同一个。核心资源表还应对orderNo + skuId等业务键建立唯一约束,防止两个全局事务重复冻结同一业务。

五、库存和事务栅栏表设计

库存汇总表示当前容量:

sql
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)
);

每次预留必须有独立业务流水,不能只改汇总计数:

sql
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)
);

状态建议显式区分:

text
TRYING
TRY_SUCCEEDED
CONFIRMED
CANCELED
FAILED_MANUAL

是否需要TRYING取决于实现方式,但终态和空回滚屏障必须可表达。DDL字段类型、长度和索引需按数据库、字符集和数据量评估。

六、为什么“先查记录再冻结”不安全

错误伪代码:

java
if (!branchRepository.exists(xid, branchId)) {
    inventoryRepository.freeze(skuId, count);
    branchRepository.insert(...);
}

两个重复Try并发执行:

mermaid
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完整流程

mermaid
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:

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标记成功。

栅栏状态推进:

sql
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完整流程

mermaid
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:

sql
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;

栅栏状态:

sql
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完整流程

mermaid
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:

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 frozen_count >= :amount;

空回滚时sku_id/amount可以为空,因为没有真实资源预留;它的价值是形成CANCELED屏障,阻止迟到Try。

十、幂等不是“查到记录就return true”

正确幂等要区分状态和请求内容:

  • 同一分支、同一参数、已完成:复用原结果。
  • 同一业务键但金额/SKU/数量不同:拒绝,不能复用。
  • Confirm已成功:重复Confirm返回成功。
  • Cancel已成功:重复Cancel返回成功。
  • Cancel后收到Confirm:协议冲突,不能返回成功掩盖。
  • Confirm后收到Cancel:协议冲突,不能释放已消费资源。

栅栏记录应保存请求摘要或关键资源参数,防止同一个branchId被错误复用于不同请求。

十一、空回滚为什么一定要留下记录

mermaid
flowchart TD
    A["协调器发送Try"] --> B["Try请求未到达参与者"]
    B --> C["全局事务超时并决定Rollback"]
    C --> D["Cancel到达但没有Try记录"]
    D --> E["插入CANCELED空回滚栅栏"]
    E --> F["不执行资源释放,返回Cancel成功"]

如果Cancel只是“查不到就return”,迟到Try到达时仍会看到无记录并冻结资源,形成悬挂。空回滚记录既表示Cancel幂等完成,也是一道未来Try不可越过的屏障。

十二、悬挂是怎样发生的

mermaid
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先获得唯一记录

  1. Try插入TRYING并持有未提交唯一键/行锁。
  2. Cancel插入同键会等待或冲突。
  3. Try冻结并提交TRY_SUCCEEDED。
  4. Cancel继续,读取TRY_SUCCEEDED并释放资源。

Cancel先获得唯一记录

  1. Cancel插入CANCELED并提交。
  2. Try插入唯一键冲突。
  3. Try读取CANCELED,拒绝冻结。

这要求数据库隔离、唯一键冲突和事务边界经过真实并发测试。不能只在单线程单元测试中验证。

十四、Try提交后响应丢失怎么办

text
Try本地事务已提交TRY_SUCCEEDED和冻结库存
→ 响应在网络中丢失
→ 协调器看到Try超时

协调器可能根据协议重试Try或决定回滚。参与者必须:

  • 重试Try时按栅栏返回第一次成功结果,不能再次冻结。
  • Cancel到达时按原记录释放冻结。
  • 对外提供按XID/branchId/businessKey查询分支事实的能力。

调用方不能用新businessKey重试,否则唯一栅栏无法识别同一业务。

十五、协调器最终决议为什么必须持久化

所有Try成功后,协调器在调用Confirm前要保存Commit决议;一旦决议是Commit,即使协调器重启也应继续重试Confirm,不能改成Cancel。Rollback同理。

mermaid
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会造成全局混合结果。

安全扫描流程:

  1. 找到长时间TRY_SUCCEEDED记录。
  2. 查询协调器最终状态。
  3. 若明确COMMIT,幂等Confirm。
  4. 若明确ROLLBACK,幂等Cancel。
  5. 若协调器状态未知,结合业务事实、恢复日志和租约进入待确认。
  6. 超过自动恢复阈值,告警并人工处理。

只有业务协议明确把“预留租约到期自动失效”定义为Try语义时,才能按租约自动释放;协调器也必须理解这种资源已失效状态。

十八、Java服务骨架:状态必须驱动动作

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);
}

核心实现应由本地事务包住“栅栏 + 资源”:

java
@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结果当并发锁。

十九、商业场景:订单、库存、余额和优惠券

mermaid
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比较

维度TCCXASaga
第一阶段业务预留并本地提交资源Prepare未最终提交每步业务直接本地提交
隔离可用/冻结业务模型数据库锁和XA语义允许显式中间状态
二阶段Confirm/CancelCommit/Rollback失败后业务补偿
业务侵入相对低
时长短到中必须短可长
适合库存、余额、券XA资源短事务履约、审批、物流
主要风险空回滚、悬挂、冻结阻塞、in-doubt补偿失败、不可逆步骤

二十一、监控和对账指标

指标说明
try_success/failureTry容量与业务拒绝
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汇总冻结量与流水聚合差异

对账至少验证:

text
inventory.frozen_count
= 所有未终态/TRY_SUCCEEDED预留流水amount之和

具体等式按业务状态定义,确认和取消流水也要保留审计依据。

二十二、生产排查Runbook

22.1 冻结资源长期不释放

  1. 按orderNo/businessKey找到XID和branchId。
  2. 查栅栏状态和最后更新时间。
  3. 查协调器全局最终决议。
  4. 若Commit,检查Confirm重试、资源条件SQL和响应丢失。
  5. 若Rollback,检查Cancel重试和冻结量是否匹配。
  6. 若全局未知,查协调日志和其他分支事实,不能直接释放。
  7. 达到阈值后进入人工处置并记录审计。

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

java
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);
    }
}

输出:

text
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才具备生产可用性。