资讯动态

TCC分布式事务实战:从原理到落地的幂等、防悬挂与空回滚

发布时间:2026/10/27 7:23:49 来源:尧图企业网站定制
分布式系统里最让人头疼的从来不是把服务拆开而是拆开之后那笔跨服务的账怎么算清楚。订单扣了库存没扣、库存扣了余额没扣、余额扣了积分没加——这类问题在单体应用里一个本地事务就搞定的事到了微服务架构下就变成了一个需要精心设计的工程问题。TCC补偿模式就是在这个背景下被反复拿出来讨论的一套解决方案全称是Try-Confirm-Cancel也就是尝试、确认、取消三个阶段。它属于柔性事务的一种核心思路是预留资源、确认落地、失败回滚用业务层面的补偿逻辑来替代数据库层面的回滚。这套方案能解决跨服务调用中数据最终一致性的问题适合有一定分布式系统经验、正在为跨库跨服务事务发愁的后端开发者参考。我做过几个交易链路和资金链路的项目TCC踩过的坑不算少今天就把这套方案的来龙去脉、落地细节和那些文档里不会写的经验一次性讲透。1. 为什么偏偏要选TCC这套方案1.1 从一次跨服务扣款事故说起早些年我参与过一个积分兑换商品的项目用户用积分换实物流程是扣积分、减库存、生成订单三个动作分属三个服务。最初图省事用的是先扣积分扣成功了再调库存库存成功了再建订单哪一步失败就反向调接口把前面的操作撤回来。这套逻辑听起来没毛病实际上线后第一个大促就出了事扣积分成功了调库存服务超时程序判定失败去回滚积分结果库存服务其实已经扣减成功只是响应慢回滚积分之后库存白白少了一件账对不上。这就是典型的业务补偿逻辑写在了调用方、且没有做防悬挂和幂等导致的灾难。那次事故之后我们复盘问题的根子在于把补偿逻辑散落在业务流程里既没有统一的协调者也没有对超时这种中间状态做明确处理。TCC模式恰恰就是针对这个痛点设计的它把每个参与方的操作拆成Try预留、Confirm确认、Cancel取消三个明确的阶段由事务协调者统一驱动每个阶段都有清晰的语义边界。这就是我后来在类似场景里坚定选择TCC的原因——不是因为它是银弹而是因为它把不确定的中间态这件事摆到了台面上逼着你去正视它。1.2 TCC与本地事务、两阶段提交的本质区别很多人刚接触TCC会把它和数据库的两阶段提交2PC搞混其实两者解决的是不同层面的问题。本地事务靠数据库的ACID保证范围仅限单个数据库连接2PC是数据库层面的分布式事务协议依赖数据库对XA协议的支持协调者和参与者之间是强一致的阻塞式协议性能开销大而且一旦协调者宕机参与者会长时间锁资源。TCC则是业务层面的补偿协议它不依赖数据库的XA能力而是要求业务代码自己实现三个阶段的逻辑把锁资源这件事从数据库层上移到了业务层。这个区别带来的直接好处是TCC的资源锁定粒度由业务自己控制可以只锁住真正需要的那部分资源比如只冻结账户里的一部分余额而不是锁整张表同时TCC不要求底层存储支持XAMySQL、Redis、甚至第三方接口都能参与进来。代价也很明显——业务代码的复杂度上去了每个参与方都要写三套逻辑而且必须自己保证幂等、防悬挂、空回滚这些边界情况。下面这张表是我总结的几种方案对比选型时可以直接对照方案一致性强度性能开销业务侵入适用场景本地事务强一致低无单库单服务2PC/XA强一致高阻塞低同构数据库、短事务TCC最终一致中高三套逻辑跨服务、跨存储、资金类消息最终一致最终一致低中异步解耦、可容忍延迟Saga最终一致低中补偿逻辑长流程、多步骤编排从表里能看出来TCC的定位很清晰当你需要跨多个异构存储、对一致性有较高要求、又无法接受2PC的阻塞开销时TCC是相对合适的选择。尤其是资金、库存这类宁可慢一点也不能算错账的场景TCC的预留机制能让你在Confirm之前始终握有反悔的余地。1.3 三个阶段的语义边界到底怎么划Try阶段做的是检查并预留注意是预留不是真正扣减。比如账户余额100元要转出30元Try阶段做的是把30元从可用余额挪到冻结余额可用余额变70、冻结余额变30钱还在账上只是被锁住了。Confirm阶段做的是确认使用预留资源把冻结的30元真正扣掉冻结余额归零。Cancel阶段做的是释放预留资源把冻结的30元挪回可用余额。这三个阶段的语义边界必须划得非常清楚我见过太多团队在这里翻车。最常见的错误是把Try写成真正的扣减然后Confirm什么都不做Cancel再反向加回来——这本质上退化成了业务补偿而不是TCC一旦Cancel失败或者重复执行就会出大问题。正确的做法是Try只做资源预留Confirm和Cancel都必须是幂等的、可重复执行的因为协调者可能因为网络抖动重试任意一个阶段。提示Try阶段预留的资源一定要有明确的冻结状态字段来承载不能靠一个布尔标记糊弄过去。冻结金额、冻结库存这类字段是后续对账和排查问题的关键依据设计表结构时就要预留好。2. 核心机制拆解幂等、防悬挂与空回滚2.1 幂等设计为什么每个阶段都要能重复执行分布式环境下网络超时是常态协调者调用参与者超时后无法确定对方是否执行成功只能重试。这就意味着Try、Confirm、Cancel三个接口都可能被调用多次如果接口不幂等重复扣款、重复加库存这类事故就会发生。幂等的实现思路通常是引入一张事务记录表用全局事务ID加分支事务ID作为唯一键每次执行前先查记录已执行过就直接返回成功。具体到代码层面我一般会在参与者服务里建一张tcc_transaction_log表字段包括全局事务IDxid、分支IDbranch_id、事务状态TRY/CONFIRM/CANCEL、执行结果。Try阶段插入一条TRY记录Confirm时先查有没有对应的CONFIRM记录没有才执行并插入有就直接返回。这里有个细节要注意查记录和执行业务操作必须在同一个本地事务里否则并发重试时两个请求可能都查不到记录然后都执行幂等就失效了。用数据库唯一索引兜底是最稳妥的做法插入冲突就说明已经执行过。CREATE TABLE tcc_transaction_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, xid VARCHAR(64) NOT NULL COMMENT 全局事务ID, branch_id VARCHAR(64) NOT NULL COMMENT 分支事务ID, action VARCHAR(16) NOT NULL COMMENT TRY/CONFIRM/CANCEL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0进行中 1成功 2失败, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_xid_branch_action (xid, branch_id, action) );上面这张表的唯一索引uk_xid_branch_action就是幂等的最后一道防线。业务代码里先INSERT这条记录插入成功才继续执行业务逻辑插入冲突直接返回成功。这种先占坑再干活的模式比先查再干更可靠因为它把并发控制交给了数据库的唯一约束避免了查询和写入之间的竞态窗口。2.2 防悬挂Cancel先于Try到达怎么办防悬挂是个容易被忽略但后果很严重的问题。场景是这样的协调者调用Try超时判定失败发起CancelCancel执行成功释放了资源但那个超时的Try请求其实还在网络上飘着过一会儿才到达参与者并执行成功结果就是资源被预留了却永远等不到Confirm一直挂着不释放。这就是悬挂——一个本该被取消的Try在Cancel之后才生效。解决思路是让Cancel也留痕Try执行前先检查有没有对应的Cancel记录如果有就说明这个分支已经被取消了Try直接拒绝执行。具体实现上Cancel执行时往事务记录表插入一条CANCEL记录Try执行时先查这张表里有没有同xid、同branch_id的CANCEL记录有就返回失败。这里同样要注意查询和执行的原子性通常配合行锁或者唯一索引来做。注意防悬挂的检查必须放在Try的业务逻辑之前而不是之后。我见过有团队把检查写在扣减库存之后结果库存已经扣了才发现该拒绝补救起来非常麻烦。2.3 空回滚Try没执行Cancel却来了空回滚指的是Try根本没执行比如Try请求在网络里丢了或者参与者宕机没收到但协调者因为超时发起了Cancel。这时候Cancel去释放一个根本不存在的预留资源如果不做处理可能会出现凭空加钱的严重问题——比如Cancel逻辑是把冻结金额加回可用余额但Try压根没冻结过这一加就多出钱来了。空回滚的判断依据同样是事务记录表Cancel执行前先查有没有对应的TRY记录没有就说明Try没执行过Cancel直接返回成功但不做任何业务操作。这里的关键是返回成功而不是返回失败因为对协调者来说Cancel的目的就是让这个分支回到未执行状态既然本来就没执行那目的已经达到了返回成功才能让整个事务正常结束。把幂等、防悬挂、空回滚三件事放在一起看你会发现它们都依赖同一张事务记录表只是查询和判断的时机不同。我在实际项目里会把这三个判断封装成一个统一的拦截器或者切面在Try、Confirm、Cancel三个方法执行前统一处理业务代码里只关心真正的业务逻辑边界判断交给框架层。这样既减少了重复代码也避免了某个参与者漏写判断导致的不一致。3. 从零落地一套TCC完整实操流程3.1 场景设定与技术选型假设我们要做一个转账场景A账户向B账户转100元A、B分属两个不同的账户服务模拟跨库场景。Try阶段A账户冻结100元、B账户预增100元记在预增字段里但不可用Confirm阶段A账户扣掉冻结的100元、B账户把预增转为可用Cancel阶段A账户解冻、B账户撤销预增。技术选型上协调者我一般用开源的Seata的TCC模式它提供了全局事务ID的生成、分支注册、重试驱动这些基础设施省得自己造轮子。参与者就是两个账户服务各自实现三个接口。存储用MySQL事务记录表按前面说的设计。为什么不自己写协调者因为协调者要处理全局事务的状态机、超时重试、宕机恢复这些复杂逻辑自己写容易在异常分支上出漏洞用成熟框架的TCC模式能少踩很多坑。当然如果你团队有精力自己实现一套轻量的协调者也不是不行核心就是一张全局事务表和一张分支事务表加一个定时补偿任务。3.2 Try阶段资源预留的字段设计与代码实现账户表的设计是Try阶段的基础我一般会在余额字段之外加两个字段frozen_amount冻结金额和available_amount可用金额其中available_amount balance - frozen_amount。Try冻结时执行UPDATE account SET frozen_amount frozen_amount 100 WHERE account_id A AND balance - frozen_amount 100这条SQL的WHERE条件保证了可用余额充足才会冻结天然防了超冻。public boolean tryFreeze(String xid, String accountId, BigDecimal amount) { // 幂等先占坑 if (!insertLog(xid, accountId, TRY)) { return true; // 已执行过直接成功 } // 防悬挂检查是否有CANCEL记录 if (existsCancelLog(xid, accountId)) { return false; } // 业务冻结金额 int rows jdbcTemplate.update( UPDATE account SET frozen_amount frozen_amount ? WHERE account_id ? AND balance - frozen_amount ?, amount, accountId, amount); if (rows 0) { throw new InsufficientBalanceException(); } return true; }这段代码里几个点值得展开说。第一insertLog用的是前面说的唯一索引占坑法返回false说明这条TRY记录已存在直接当成功处理。第二防悬挂检查放在业务操作之前一旦发现有CANCEL记录立即拒绝。第三扣减条件写在SQL的WHERE里而不是先查后改避免了并发下的超冻问题这是数据库层面乐观锁的经典用法。B账户的Try是预增逻辑类似只是把金额加到frozen_amount的语义换成预增待确认字段。3.3 Confirm与Cancel确认与回滚的对称实现Confirm阶段A账户执行UPDATE account SET balance balance - 100, frozen_amount frozen_amount - 100 WHERE account_id A AND frozen_amount 100把冻结的钱真正扣掉。B账户执行UPDATE account SET balance balance 100, frozen_amount frozen_amount - 100把预增转为正式余额。Cancel阶段A账户执行UPDATE account SET frozen_amount frozen_amount - 100 WHERE account_id A AND frozen_amount 100解冻B账户撤销预增。Confirm和Cancel的代码结构高度对称都要先做幂等判断查有没有对应的CONFIRM/CANCEL记录Confirm还要额外做空回滚判断查有没有TRY记录没有就说明Try没执行Confirm直接返回成功。这里有个实操细节Confirm和Cancel的业务SQL都要带上frozen_amount amount这样的条件防止冻结金额被扣成负数虽然理论上幂等和防悬挂做好了不会出现但多一道防线总没错。public boolean confirm(String xid, String accountId, BigDecimal amount) { if (!insertLog(xid, accountId, CONFIRM)) { return true; // 幂等 } if (!existsTryLog(xid, accountId)) { return true; // 空回滚Try没执行Confirm直接成功 } jdbcTemplate.update( UPDATE account SET balance balance - ?, frozen_amount frozen_amount - ? WHERE account_id ? AND frozen_amount ?, amount, amount, accountId, amount); return true; }Cancel的实现和Confirm几乎一样只是SQL换成解冻。写这两段代码时我最大的体会是一定要把幂等、防悬挂、空回滚的判断和业务SQL放在同一个本地事务里用Transactional包起来否则判断和操作之间出现异常就会破坏一致性。Seata的TCC模式对参与者方法有要求Try、Confirm、Cancel三个方法都要返回boolean或者抛异常框架根据返回值决定是否重试所以异常处理也要规范业务异常和系统异常要区分开。3.4 协调者配置与全局事务发起用Seata的话协调者TC是独立部署的服务端参与者通过引入seata客户端依赖、配置注册中心和TC地址来接入。全局事务的发起方在业务方法上加GlobalTransactional注解框架会自动生成xid、注册分支、驱动Try-Confirm-Cancel流程。配置上要注意seata.tcc相关的超时时间和重试次数默认值往往偏保守资金类场景我一般把Confirm和Cancel的重试次数调高一些确保最终能成功。seata: enabled: true application-id: account-service tx-service-group: my_tx_group service: vgroup-mapping: my_tx_group: default client: rm: report-success-enable: false report-retry-count: 5发起方的代码大致是这样在转账的入口方法上加GlobalTransactional方法体里依次调用A服务的tryFreeze和B服务的tryIncrease两个Try都成功则框架自动调Confirm任一失败则自动调Cancel。这里有个坑要提醒Try方法里不要做太重的操作因为Try可能被重试重操作会放大性能开销另外Try里调用的下游接口也要考虑超时别让一个慢接口拖垮整个全局事务。4. 踩坑实录那些文档里不会写的问题4.1 超时重试引发的重复Confirm有一次线上出现账户余额被多扣的情况排查下来是Confirm被重复执行了。按理说幂等做好了不该重复扣问题出在我们的事务记录表用了分库分表xid branch_id action的唯一索引在分片后失效了——不同分片上的记录互相查不到幂等判断形同虚设。这个坑很隐蔽因为单库测试时一切正常上了分片才暴露。解决办法是把事务记录表独立出来不分片或者用Redis做一层幂等去重。我们最后选了独立库因为事务记录表的数据量其实不大单独放一个库反而便于统一清理和排查。这件事给我的教训是幂等依赖的唯一约束一定要确认它在你的存储架构下真的生效分库分表、读写分离这些架构决策都可能让唯一索引失效。4.2 Try成功但Confirm一直失败的死循环另一个典型问题是Confirm阶段因为某个数据问题一直失败协调者不断重试全局事务卡在中间状态。比如B账户在Confirm时发现账户被冻结了业务上不允许入账Confirm抛异常重试N次后放弃但此时A账户的Try已经冻结了资金整个事务处于不一致状态。这类问题的处理思路是Confirm和Cancel要尽最大努力成功业务上要允许强制确认。比如账户被冻结不允许入账这种情况Confirm时可以先入到过渡账户等账户解冻再处理而不是直接失败。另外协调者要有告警机制重试超过阈值就报警人工介入处理不能让它无限重试下去。我在项目里会配一个定时任务扫描长时间处于中间状态的事务超过一定时间就推送到告警群。4.3 常见问题速查表问题现象可能原因排查方向解决手段余额被多扣Confirm重复执行查事务记录表唯一索引是否生效独立事务记录库或Redis去重资源一直挂着不释放Try成功但Confirm丢失查协调者日志和分支状态定时补偿任务扫描中间态凭空多出余额空回滚未判断查Cancel前是否校验TRY记录补上空回滚判断冻结金额变负数并发下Try重复执行查Try幂等和SQL条件SQL加frozen_amountamount事务卡住不结束Confirm持续失败查Confirm失败原因强制确认告警人工介入这张表是我这几年排查TCC问题的一个浓缩实际遇到问题时按现象→原因→排查→解决的顺序走大部分情况能快速定位。特别提醒一句TCC的问题往往不是单点问题而是幂等、防悬挂、空回滚三个机制里某一个没做到位引发的连锁反应排查时要把三个机制都过一遍。4.4 几个容易被忽视的实操心得第一Try阶段的资源预留要有超时自动释放的兜底。虽然理论上Cancel会释放但万一协调者彻底挂了预留的资源不能永远锁着。我一般会加一个定时任务扫描超过一定时间比如24小时还没Confirm的TRY记录自动触发Cancel。第二事务记录表要定期归档清理不然数据量涨起来查询会变慢影响幂等判断的性能。第三压测时一定要模拟网络超时和参与者宕机这两个场景是TCC问题的高发区正常流程压测根本测不出来。还有一点关于日志TCC链路的日志一定要带上xid从发起方到每个参与者都用同一个xid串起来出问题时才能快速还原整个调用链。我们用的是MDC把xid放进日志上下文配合日志采集平台按xid聚合排查效率提升非常明显。这个习惯看起来是小细节但在处理线上事务不一致问题时往往是救命的关键。5. 适用边界与替代方案的选择TCC不是万能的用错了地方反而增加复杂度。它适合的是业务上能明确拆出预留、确认、取消三个动作的场景比如资金冻结、库存锁定、优惠券预占。如果你的业务动作本身没法预留比如发短信、调第三方支付那TCC就不合适得考虑消息最终一致或者Saga。另外TCC对业务侵入大每个参与者都要改代码如果参与方很多、且大部分是遗留系统改不动那落地成本会很高这时候可以评估一下是否用基于消息的最终一致方案更划算。我个人的选型经验是核心资金链路、对一致性要求高、参与方数量可控三到五个的场景优先TCC边缘业务、参与方多、能容忍较长延迟的场景用消息或Saga。选型时不要只盯着一致性强度要把业务侵入成本、团队维护能力、故障排查难度都算进去综合权衡才是负责任的做法。毕竟一套方案能不能长期稳定运行很大程度上取决于团队能不能hold住它的复杂度。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑