如果你在面试Java岗的时候被问过TCC事务大概率会遇到这样一个问题TCC的悬挂和空回滚你是怎么处理的不少人的反应是Try、Confirm、Cancel三个词背得滚瓜烂熟但一落到实现层就卡壳了。这确实不是个冷门刁钻的问题而是TCC落地时真实会碰到的边界情况。我这两年面过不少人也在实际项目里跟这套状态机打过长时间交道说实话线上遇到的分布式事务幺蛾子十个里有七个都出在这几个边界问题上。先说结论TCC这套东西概念半天就能讲完真正让你在线上踩坑的几乎集中在悬挂、空回滚、幂等这三件事上。谁能把这几个问题讲明白谁就是真的理解TCC而不是只会背概念。1. TCC事务的核心模型与考点本质1.1 TCC三阶段的职责边界TCC的全称是Try-Confirm-Cancel核心思路是把一个分布式事务拆成三个阶段每个阶段都有明确的业务职责。Try阶段做资源预留。比如说扣款场景不是直接扣掉账户余额而是把一部分钱冻结起来标记为预留状态。这么做的目的是先把资源占住防止别的事务把这笔钱花掉了。Confirm阶段做真正的业务提交。当所有参与者都完成了Try协调器决定提交事务这时每个服务把Try阶段预留的资源正式生效。比如冻结的余额变成真实扣款。Confirm要做幂等万一第一次请求超时被重发了重复调用不能二次扣款。Cancel阶段做补偿回滚。当任何一个参与者Try失败或者协调器超时判定需要回滚就调用所有参与者的Cancel把Try阶段预留的资源释放掉。Cancel同样要做幂等重复回滚不能把已经释放的资源再释放一遍。这套模型本质上是一种补偿型分布式事务方案。每个参与方都把自己的一致性逻辑暴露成三个方法由事务协调器统一调度。它跟2PC最大的区别在于2PC是协调器说了算参与者没有自己的业务判断TCC的每个阶段都是业务代码参与者有充分的控制权。所以TCC的灵活性和性能都比2PC好但代价是业务侵入性强三套逻辑的健壮性也考验工程能力。1.2 为什么面试官死磕悬挂和空回滚原因是这两个问题在真实网络上几乎必然会发生不是小概率事件。网络是没有全局时钟的请求也不保证先发先到。一个Try请求和一个Cancel请求可能走了不同的网络路径经过不同的网关和负载均衡节点处理时长差别也很大。协调器可能还有多个线程并发调度多个分支发出顺序和到达顺序天然不一致。在分布式系统里乱序是常态不乱序才是运气好。面试官拿这个问题出来看的是两件事第一你有没有真的理解网络不确定性对分布式事务的影响第二你有没有想过在实现层面怎么防御这些边界情况。只背概念的人到这里就露馅了。2. 悬挂问题成因、推演与防悬挂方案2.1 悬挂产生的底层原因悬挂的本质是Try请求的处理顺序晚于Cancel请求导致资源预留发生在一个已经确定要回滚的事务里没人会再给这个预留收尾。这里有个容易被忽略的点处理顺序和发送顺序是两码事。协调器可能是先发了Try再发Cancel但因为网络抖动Try在中间某段链路被阻塞了Cancel反而先到达了目标服务。服务端处理完Cancel已经进入回滚完成状态这时候迟到的Try才被处理。Try执行时发现没有预留记录就正常做了资源预留。问题是Cancel已经不会再来了资源被永久占用。这个资源可以是库存、余额额度也可以是任何需要被释放的业务资源。2.2 一次典型的乱序推演我用一个具体的场景把悬挂过程完整推演一遍。假设有三个服务参与一个全局事务协调器发起事务后依次调用了三个服务的Try。服务A和B的Try都成功了服务C的Try一直没有返回响应直到超时。协调器判定事务需要回滚启动Cancel流程。服务A的Cancel正常执行服务B的Cancel正常执行。服务C的情况比较特殊协调器重试了一次Cancel这次成功了。Cancel执行完服务C的事务状态标记为已回滚。但是服务C第一次Try请求实际上并没有丢失它只是被网络卡了很久。在Cancel执行完之后的某个时刻这个Try请求终于到达了服务C。服务C的处理逻辑是查事务记录发现没有预留记录于是正常预留。预留完返回成功给协调器。但协调器早就结束了这个事务根本不会再关心服务C这次的返回。服务C预留的资源永远没有释放。这才是悬挂最阴险的地方每个节点单独看都处理得没问题但全局资源已经被吃掉了。2.3 防悬挂的核心策略事务记录先行防悬挂的标准做法是在Try阶段开始前先把事务记录写入本地事务表并且通过查询状态来决定是否继续执行Try。核心判断逻辑很简单Try执行前先查记录如果当前事务已经存在Cancel状态的记录说明Cancel先到了Try就不应该再执行资源预留直接返回成功即可。为什么返回成功而不是返回失败这是一个细节。如果Try返回失败协调器可能会认为这个分支的操作有异常从而触发额外的重试或告警。直接返回成功表示这个分支我处理过了协调器不会再对它做任何操作后续Confirm/Cancel也不会再触发到这个服务逻辑上更加干净。防悬挂的关键是让Cancel先写入的记录能够挡住后到的Try。所以Cancel的空回滚记录是防悬挂判断的基础两者必须一起设计。提示防悬挂需要一个独立的事务记录表来持久化执行状态只靠业务表做不了这个判断。表里至少要能存储全局事务ID、分支事务ID、状态和时间戳。不做防悬挂的后果很直接数据库里积累了永远无法释放的预留资源。我见过有团队上线第二天库存就被吃掉查了整整一个下午最后定位到就是一个迟到的Try。3. 空回滚问题成因与防空回滚的实现思路3.1 空回滚的本质空回滚的定义是Try阶段没有执行成功或者根本没有执行但Cancel阶段被调用了。最常见的场景协调器发完Try后一直等不到响应超时了判定这个分支失败了于是发起Cancel。但Try请求实际上没有到达参与者或者到达了但处理时抛了异常总之没有预留成功。Cancel到达之后发现没有预留记录可以回滚这就是空回滚。如果Cancel此时什么都不做直接返回成功表面看也没问题——本来就没有要回滚的东西。但这样做埋了个雷空回滚如果没有留下任何痕迹后续迟到的Try就无法判断Cancel是否已经来过就会正常执行预留造成悬挂。所以空回滚的正确做法不是静默返回而是把这次回滚是空的这个事实记录下来。具体来说Cancel在发现没有Try记录时主动插入一条事务记录状态标记为CANCELED同时打上空回滚标志。这条记录就是防悬挂的哨兵挡住任何可能迟到的Try。3.2 防空回滚的落地方案实现防空回滚有几个关键点必须注意。第一Cancel执行前必须查询事务记录。不能假设Try一定执行过也不能假设Try一定没执行过。一切以本地记录为准。第二查询结果为空时不能直接返回成功。要插入一条CANCELED状态的记录并标记为空回滚。这条记录作为幂等依据和防悬挂依据。第三插入操作要考虑并发。两条Cancel同时到达都发现没有Try记录都去插入就会产生重复记录。必须靠唯一索引兜底。业务上会把空回滚记录和真实回滚记录做区分用一个字段比如rollback_type0表示真实回滚1表示空回滚。这样做的价值在于运维阶段对账和排查时可以直接筛选出空回滚记录快速识别出哪些事务连Try都没成功过。空回滚和防悬挂是同一套设计里的两个环节。空回滚记录为防悬挂提供了判断依据防悬挂又是空回滚记录的价值所在。两者拆开看都有道理合在一起才是完整方案。4. 状态机与事务日志的工程实现4.1 事务记录表设计与状态流转落地实现的第一步是设计一张持久化的事务记录表。字段设计大致如下字段名类型说明tx_idvarchar(64)全局事务ID与branch_id组成唯一索引branch_idvarchar(64)分支事务ID标识同一个全局事务里的不同分支statustinyint0-初始 1-TRYING 2-TRY_SUCCESS 3-CANCELING 4-CANCELED 5-CONFIRMING 6-CONFIRMEDreserve_datavarchar(512)Try阶段预留的资源信息JSON序列化rollback_typetinyint0-真实回滚 1-空回滚create_timedatetime创建时间update_timedatetime更新时间唯一约束是(tx_id, branch_id)。只要是同一个全局事务的同一个分支重复到达的操作都会被唯一约束挡住。这个索引同时是幂等控制的最终兜底。对应的建表SQL大概是这样的CREATE TABLE tcc_transaction_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, tx_id VARCHAR(64) NOT NULL COMMENT 全局事务ID, branch_id VARCHAR(64) NOT NULL COMMENT 分支事务ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-初始 1-TRYING 2-TRY_SUCCESS 3-CANCELING 4-CANCELED 5-CONFIRMING 6-CONFIRMED, reserve_data VARCHAR(512) DEFAULT NULL COMMENT Try阶段预留资源数据, rollback_type TINYINT NOT NULL DEFAULT 0 COMMENT 0-真实回滚 1-空回滚, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_tx_branch (tx_id, branch_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTTCC事务记录表;状态流转必须定义清楚。我常用的合法流转规则是这样的当前状态允许的操作无记录Try、Cancel空回滚TRYING / TRY_SUCCESSConfirm、CancelCANCELING / CANCELED幂等重试CancelCONFIRMING / CONFIRMED幂等重试Confirm核心思想就一条Cancel一旦写入并成功状态就不能再退回到TryConfirm一旦开始也不能再走Cancel。状态机保证了一个事务最终只能朝着一个终点收敛。4.2 防悬挂与防空回滚的Java实现先定义状态枚举public enum TccStatus { INIT(0), TRYING(1), TRY_SUCCESS(2), CANCELING(3), CANCELED(4), CONFIRMING(5), CONFIRMED(6); private final int code; TccStatus(int code) { this.code code; } public int getCode() { return code; } }定义事务记录实体类public class TccTransactionRecord { private String txId; private String branchId; private Integer status; private String reserveData; private Integer rollbackType; private Date createTime; private Date updateTime; // getter/setter 省略 }然后是最关键的Try阶段实现核心是防悬挂检查Service public class TccTryService { Autowired private TccTransactionRecordMapper recordMapper; Transactional public boolean tryPhase(String txId, String branchId, ReserveRequest request) { // 第一步查询已有记录 TccTransactionRecord existing recordMapper.selectByTxIdAndBranchId(txId, branchId); // 第二步防悬挂检查 // 存在记录且状态已经是CANCELING或CANCELED说明Cancel先到了 // 此时不执行预留直接返回成功 if (existing ! null isCanceled(existing.getStatus())) { log.warn(tryPhase skipped, cancel already executed. txId{}, branchId{}, txId, branchId); return true; } // 第三步首次到达插入TRYING记录 if (existing null) { TccTransactionRecord record new TccTransactionRecord(); record.setTxId(txId); record.setBranchId(branchId); record.setStatus(TccStatus.TRYING.getCode()); record.setRollbackType(0); recordMapper.insert(record); } // 第四步执行真正的资源预留业务 boolean reserveSuccess doReserve(request); if (reserveSuccess) { recordMapper.updateStatus(txId, branchId, TccStatus.TRY_SUCCESS.getCode()); return true; } else { // 预留失败更新状态为CANCELING让协调器尽快触发Cancel recordMapper.updateStatus(txId, branchId, TccStatus.CANCELING.getCode()); return false; } } private boolean doReserve(ReserveRequest request) { // 实际扣减可用余额、冻结库存等操作 return true; } private boolean isCanceled(Integer status) { return status ! null status TccStatus.CANCELING.getCode(); } }这里有个细节值得注意isCanceled的判断用 CANCELING而不是 CANCELED。因为Cancel可能已经执行到一半了状态是CANCELING这时候如果Try判断只认终态CANCELED就会漏掉这个场景照样执行预留照样造成悬挂。条件放宽一点更安全。然后是Cancel阶段防空回滚是核心逻辑Service public class TccCancelService { Autowired private TccTransactionRecordMapper recordMapper; Transactional public boolean cancelPhase(String txId, String branchId) { // 第一步查询已有记录 TccTransactionRecord existing recordMapper.selectByTxIdAndBranchId(txId, branchId); // 第二步没有记录空回滚场景Try从未执行 if (existing null) { // 插入一条CANCELED状态的空回滚记录 // 作用一防止后续重复Cancel无法辨识 // 作用二作为防悬挂哨兵挡住迟到的Try TccTransactionRecord emptyRecord new TccTransactionRecord(); emptyRecord.setTxId(txId); emptyRecord.setBranchId(branchId); emptyRecord.setStatus(TccStatus.CANCELED.getCode()); emptyRecord.setRollbackType(1); // 空回滚标记 try { recordMapper.insert(emptyRecord); } catch (DuplicateKeyException e) { // 并发场景下另一个Cancel已经插入成功了幂等返回 log.warn(empty rollback record already exists, txId{}, branchId{}, txId, branchId); } return true; } // 第三步已经有记录判断是否已回滚 if (isCanceled(existing.getStatus())) { // 已经处于Cancel状态说明Cancel被重复调用幂等返回 log.info(cancelPhase skipped, already canceled. txId{}, branchId{}, txId, branchId); return true; } // 第四步正常回滚释放预留的资源 boolean rollbackSuccess doRollback(existing.getReserveData()); if (rollbackSuccess) { recordMapper.updateStatus(txId, branchId, TccStatus.CANCELED.getCode()); return true; } return false; } private boolean doRollback(String reserveData) { // 实际释放库存、解冻余额等操作 return true; } private boolean isCanceled(Integer status) { return status ! null status TccStatus.CANCELING.getCode(); } }这套代码的核心逻辑总结下来就三句话Try之前先查记录有Cancel状态就直接跳过。 Cancel之前先查记录没记录就插一条空回滚记录来占坑。 所有插入操作都靠唯一索引兜底保证并发时的幂等。4.3 幂等控制TCC的隐藏基石很多人会把幂等和防悬挂、防空回滚混在一起其实它们是解决问题的不同层面。幂等解决的是重复执行无害化。同一个Confirm被调用了两遍第二遍不能报错也不能重复提交。同一个Cancel被调了三遍第二三遍应该直接返回成功。防悬挂解决的是乱序请求的副作用消除。Try后到不能执行预留。防空回滚解决的是空操作的语义完整性。Cancel没有东西可回滚时不能静默返回必须把事实记录成占位记录。三者互相配合。你不做幂等Cancel状态的重复到达就可能走到真实回滚逻辑里把已经回滚的资源又回滚一遍。你不做防悬挂幂等做得再好也没用因为预留本身就不该发生。你不做防空回滚防悬挂的判断依据就缺失了Try无法通过记录识别出Cancel已经来过。面试的时候能把这三者的关系和分工讲清楚就已经超过大多数只知道名字的候选人了。4.4 不同分布式事务方案的对照与选型面试中经常会跟着一句追问为什么不用2PC或者本地消息表非要用TCC以我自己的选型经验来看几个常见方案各有适用场景方案一致性强度可用性实现复杂度典型适用场景2PC/XA强一致低协调器故障影响全局中短事务、参与方少、强一致要求高TCC最终一致较高参与方独立高业务侵入性强跨服务扣库存、资金预扣、多资源预占本地消息表最终一致较高中异步单边对账、状态最终同步可靠消息投递最终一致高中异步削峰、业务解耦、允许延迟TCC最大的价值在于它能在不牺牲太多可用性的前提下保证一个分布式事务的最终一致性。资源锁定窗口可控参与方各自独立演进性能比2PC好得多。代价是业务侵入性强每个参与方都要实现三套逻辑再加上防悬挂、防空回滚和幂等这些边界处理整体工作量不小。选TCC的前提是你的业务确实需要多方预扣资源确实需要比消息表更强的实时一致性。如果只是异步通知类场景用轻量的方案更合适。5. 面试现场的回答思路与实战复盘5.1 一套耐打的回答结构面试官问TCC的悬挂问题怎么解决时我建议用三步结构来答。第一步一句话说清楚悬挂是什么网络乱序导致Try晚于Cancel到达预留了永远没人释放的资源。第二步讲解决方案的核心逻辑基于事务记录表做状态机Try执行前先查状态发现Cancel已执行就直接跳过。第三步扩展视野把防空回滚、幂等、状态机设计一起带出来说明这是一个完整的可靠三角不是单点补丁。这样回答下来面试官听到的是一整套思维模型而不是一个孤立的知识点。5.2 我在项目中踩过的三个坑第一次实现TCC的时候我犯了个典型错误只在Cancel阶段记录了回滚状态但没处理Try阶段查不到Cancel记录的情况。想的是Cancel都记录了Try到了自然能查到结果漏掉了最关键的环节——Cancel在查询Try记录为空时直接返回成功没有插入空回滚记录。迟到的Try照样预留资源照样没人释放。排查这个坑特别费劲。线上数据对账差了一堆预留资源日志里看不到任何异常因为每个阶段的调用都返回了成功。后来把空回滚记录补上悬挂问题立刻消失。那次之后我深刻理解了一件事防悬挂和防空回滚必须绑定在一起设计。没有防空回滚做支撑的防悬挂等于没有地基的楼。第二个坑是并发下的重复插入。两个Cancel几乎同时到达都发现Try记录不存在都去插入空回滚记录。没有唯一索引时就会出现两条重复记录。代码层面先判断再插入是有时间差的不能保证并发安全。最后是在数据库加了(tx_id, branch_id)唯一约束用异常捕获来幂等。第三个坑是状态判断的边界。一开始用status CANCELED判断结果漏掉了CANCELING状态下的Try到达。Cancel在执行业务回滚的时候另一个线程可能正在处理迟到的Try如果判断条件只认终态Try就会漏过去。改成 CANCELING之后逻辑才严密。5.3 一个关于TCC面试题的延伸思考最后聊一个大家常问的TCC的这些边界问题有没有可能彻底避免我的看法是网络乱序在分布式系统里是物理定律没法消除。悬挂和空回滚一定会发生你只能通过设计去消化它们让它们不产生副作用。事务记录表加状态机是目前工程上最成熟也最可控的方案。还有个可以提的点现在很多分布式事务框架已经把防悬挂和空回滚做成内置能力了。但如果面试官问的是底层原理你还是得把状态机和记录表这套逻辑讲清楚。框架内置不代表你可以不懂反而说明这部分设计是通用的、必须理解的。我个人的经验是解决一个分布式事务问题真正花时间的地方从来不是实现三个接口而是把边界情况梳理清楚把状态机设计到位。三个接口加起来就几十行代码但状态记录表、防悬挂、防空回滚、幂等控制加在一起才是能上线的TCC方案。面试里把这个过程讲出来比背任何标准答案都更有说服力。
阅读完成 · 觉得有帮助?