分布式系统里最让人头疼的,从来不是把服务拆开,而是拆开之后那笔跨服务的账怎么算清楚。订单扣了库存没扣、库存扣了余额没扣、余额扣了积分没加——这类问题在单体应用里一个本地事务就搞定的事,到了微服务架构下就变成了一个需要精心设计的工程问题。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不要求底层存储支持XA,MySQL、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表,字段包括全局事务ID(xid)、分支ID(branch_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超时,判定失败发起Cancel,Cancel执行成功释放了资源;但那个超时的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_amount>=amount |
| 事务卡住不结束 | 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住它的复杂度。