分布式事务里,TCC(Try-Confirm-Cancel)模型最让人头疼的不是怎么实现三段式逻辑,而是“最终一致”这四个字在异常场景下如何真正落地。很多团队把TCC一搭,空回滚、悬挂、幂等没处理好,数据不一致的坑一个接一个。TCC的最终一致不是靠数据库自带的事务机制保证的,而是靠一套精心设计的异常恢复体系,这套体系的核心就是空回滚防护、防悬挂控制、幂等性保证,以及一个可靠的异步补偿引擎。

空回滚:没Try成功却来了Cancel,怎么防

空回滚指的是协调器发起Cancel时,参与方根本没收到过Try请求,或者Try请求执行失败了。如果参与方不识别这种情况,直接执行Cancel逻辑,可能把正常数据给回滚掉,或者产生脏数据。比如一个冻结库存的Cancel操作,如果没判断Try是否成功,上来就解冻,可能导致库存数量异常增加。

解决空回滚的核心思路是让参与方有能力识别“这个全局事务的Try阶段是否执行成功”。具体做法是在参与方维护一张事务状态表,记录每个全局事务ID对应的Try执行状态。当Cancel请求到达时,先查这张表:如果记录不存在,说明Try从未成功,直接返回成功即可,这就是空回滚防护。代码层面大致是这样的逻辑:

-- 事务状态表结构
CREATE TABLE tcc_transaction_log (
    global_tx_id VARCHAR(64) NOT NULL,
    branch_tx_id VARCHAR(64) NOT NULL,
    status TINYINT NOT NULL COMMENT '1:try成功 2:confirm成功 3:cancel成功',
    created_at DATETIME NOT NULL,
    PRIMARY KEY (global_tx_id, branch_tx_id)
);

-- Cancel接口伪代码
def cancel(global_tx_id, branch_tx_id):
    log = query_log(global_tx_id, branch_tx_id)
    if log is None:
        # 空回滚,直接返回成功
        return "SUCCESS"
    if log.status == STATUS_CANCEL:
        # 已回滚,幂等返回
        return "SUCCESS"
    # 执行实际回滚逻辑
    do_cancel_business()
    update_log_status(global_tx_id, branch_tx_id, STATUS_CANCEL)
    return "SUCCESS"

这里有个容易被忽略的细节:Try接口本身也要做幂等。如果Try请求因为网络超时被重试,参与方收到两次Try,第二次必须能识别并直接返回成功,否则可能重复执行业务逻辑。Try的幂等同样依赖事务状态表,第一次Try成功时插入记录并执行业务,第二次Try发现记录已存在则跳过业务逻辑。

防悬挂:Try迟到了怎么办

悬挂问题比空回滚更隐蔽。它的场景是这样的:协调器发起Cancel,Cancel先到达参与方并执行完毕,然后之前因为网络延迟而迟到的Try请求才到达。如果参与方不做防护,这个迟到的Try会正常执行业务预留资源,但对应的Cancel已经执行过了,这些预留资源永远不会被Confirm或Cancel,就“悬挂”在那里,造成资源泄漏。

防悬挂的关键在于Cancel执行时也要在事务状态表中留下记录。当迟到的Try到达时,先查状态表,如果发现Cancel已经执行过了,就拒绝执行Try逻辑,直接返回失败或成功。具体实现上,Cancel接口在空回滚场景下也需要插入一条状态记录,标记该事务已Cancel。这样Try到达时就能识别出来。伪代码逻辑如下:

def try(global_tx_id, branch_tx_id):
    log = query_log(global_tx_id, branch_tx_id)
    if log is not None:
        if log.status == STATUS_CANCEL:
            # Cancel已执行,拒绝迟到的Try
            raise Exception("Transaction already cancelled")
        # 已Try过,幂等返回
        return "SUCCESS"
    # 执行业务预留
    do_try_business()
    insert_log(global_tx_id, branch_tx_id, STATUS_TRY)
    return "SUCCESS"

def cancel(global_tx_id, branch_tx_id):
    log = query_log(global_tx_id, branch_tx_id)
    if log is None:
        # 空回滚:插入cancel记录,防止后续迟到Try
        insert_log(global_tx_id, branch_tx_id, STATUS_CANCEL)
        return "SUCCESS"
    if log.status == STATUS_CANCEL:
        return "SUCCESS"
    do_cancel_business()
    update_log_status(global_tx_id, branch_tx_id, STATUS_CANCEL)
    return "SUCCESS"

注意,空回滚时插入Cancel状态记录这一步至关重要,它是防悬挂的基石。很多实现只做了空回滚的直接返回,没有插记录,结果悬挂问题依然存在。

幂等性:所有接口的硬性要求

TCC的三个接口Try、Confirm、Cancel都必须实现幂等。原因很简单,协调器的重试机制会在超时、网络异常等情况下反复调用这些接口。幂等实现方式主要有两种:状态机驱动和唯一键约束。

状态机驱动就是前面用的方法,通过事务状态表记录当前处理阶段,每次请求先查状态,已处理则跳过。这种方式灵活,适合复杂业务。唯一键约束则是利用数据库的唯一索引,比如在业务表中对全局事务ID加唯一索引,重复插入时数据库报唯一键冲突,捕获异常后返回成功。这种方式简单粗暴,适合Try阶段创建记录的场景,但对Cancel和Confirm这种更新操作就不太适用。

实际项目中,推荐混合使用。Try阶段如果涉及资源冻结,通常会在业务表插入一条冻结记录,这时用唯一键约束做幂等非常自然。Confirm和Cancel则用状态机,因为它们是对已有记录的更新操作。不管哪种方式,幂等逻辑必须和业务逻辑放在同一个本地事务里执行,否则会出现状态表更新了但业务没执行完的中间态。

异步补偿引擎:最终一致的最后一道防线

即使空回滚、防悬挂、幂等都做对了,TCC仍然可能因为Confirm阶段失败而无法达到最终一致。协调器在Try全部成功后开始Confirm,如果某个参与方Confirm失败,协调器会重试,但重试有上限,超过上限后需要人工介入或异步补偿。

异步补偿引擎的设计思路是:协调器维护一张全局事务主表,记录每个全局事务的当前状态(Try成功、Confirm中、Confirm完成、Cancel中、Cancel完成等)。一个后台Job定时扫描这张表,找出长时间处于中间状态的事务,比如超过5分钟还在Confirm中的事务,然后调用参与方的查询接口确认当前状态,再决定是继续重试Confirm还是发起Cancel。这个Job就是补偿引擎的核心。

补偿引擎的实现有几个关键点。第一,扫描频率要合理,太频繁给数据库和参与方造成压力,太低则不一致窗口过长。通常30秒到1分钟扫描一次比较合适。第二,需要设置事务超时时间,超过这个时间的事务直接标记为失败并触发Cancel,避免资源长期占用。第三,补偿引擎本身要支持分布式部署,避免单点故障,可以用分布式锁或者分片扫描来保证同一时刻只有一个实例处理同一批事务。

具体到代码层面,补偿引擎的扫描逻辑大致如下:

def compensate_job():
    # 获取分布式锁,防止多实例重复处理
    lock = acquire_distributed_lock("tcc_compensate_lock", ttl=60)
    if not lock:
        return
    
    # 查询超时的中间状态事务
    timeout_txs = query_timeout_transactions(
        timeout_seconds=300,  # 5分钟超时
        status_list=[STATUS_TRY_SUCCESS, STATUS_CONFIRMING]
    )
    
    for tx in timeout_txs:
        try:
            # 调用参与方查询接口确认状态
            branch_status = query_branch_status(tx.branch_tx_id)
            if branch_status == STATUS_CONFIRM:
                # 参与方已Confirm,更新主表状态
                update_global_tx_status(tx.global_tx_id, STATUS_CONFIRM_DONE)
            elif branch_status == STATUS_TRY:
                # 参与方仍处于Try成功状态,重试Confirm
                retry_confirm(tx.branch_tx_id)
            else:
                # 状态异常,触发告警
                send_alert(tx.global_tx_id, branch_status)
        except Exception as e:
            log_error(tx.global_tx_id, e)
            # 重试次数超过阈值则升级告警
            if tx.retry_count > MAX_RETRY:
                send_critical_alert(tx.global_tx_id)
            else:
                increment_retry_count(tx.global_tx_id)

补偿引擎不能替代协调器的正常重试逻辑,它是兜底机制。协调器在发起Confirm或Cancel时,应该先进行几次同步重试,都失败后再交给补偿引擎异步处理。这样大部分临时性故障(如网络抖动)能在秒级恢复,只有少数顽固故障才走到补偿流程。

本地事务与远程调用的边界处理

TCC实现中一个容易被忽视的问题是本地事务和远程调用的边界。Try接口内部通常包含本地数据库操作和远程RPC调用,这两者不在同一个事务范围内。如果先执行本地数据库操作成功,再发起远程调用失败,回滚本地事务就成了问题。

正确的做法是尽量把远程调用放在本地事务之外,或者采用“先本地后远程”的顺序,并配合补偿逻辑。更稳妥的方案是在Try阶段只做本地资源预留,不发起远程调用。Confirm阶段再执行实际的远程调用,因为Confirm阶段协调器已经确认所有参与方Try都成功了,此时发起远程调用即使失败也可以不断重试,最终保证一致。

如果业务确实需要在Try阶段做远程调用,那就必须引入“反查”机制。协调器在Confirm之前先调用参与方的反查接口,确认Try阶段的远程调用是否真正成功,再决定后续流程。这增加了复杂度,一般不推荐。

生产环境落地要点

把TCC最终一致方案落到生产环境,还有几个工程层面的要点必须注意。第一,事务状态表和业务表必须放在同一个数据库实例里,保证本地事务能同时覆盖两者,否则状态记录和业务操作的一致性无法保证。第二,事务状态表的数据量会持续增长,需要定期归档清理已完成的事务记录,保留周期根据业务需求设定,一般7到30天足够。第三,所有接口的超时时间要合理设置,Try接口建议设置较短的超时(如3秒),Confirm和Cancel可以稍长(如10秒),因为Confirm和Cancel是后台重试驱动的,用户不直接等待。

监控和告警也是必不可少的一环。需要监控的指标包括:事务超时未完成的数量、补偿引擎处理成功/失败的数量、空回滚和悬挂拦截的次数。这些指标能直观反映TCC系统的健康度。当空回滚频繁出现时,说明协调器的Try请求成功率偏低,可能是网络或参与方性能问题。悬挂频繁出现则说明网络延迟严重,需要排查网络链路。

TCC的最终一致不是靠某一项技术单点突破的,而是空回滚防护、防悬挂控制、幂等保证、异步补偿引擎这几个机制协同作用的结果。任何一个环节有疏漏,最终一致就变成最终不一致。理解了这些机制的内在逻辑和边界条件,才能真正把TCC用对、用好。