TCC分布式事务实战:空回滚、悬挂与幂等设计详解
分布式系统里最让人头疼的场景之一,就是一笔业务横跨多个服务、每个服务各自管着自己的数据库,结果中间某个环节挂了,前面已经落库的数据怎么办。本地事务那套 ACID 到了这里直接失效,数据库帮不了你,只能靠业务层面自己兜底。TCC 补偿模式就是在这个背景下被反复拿出来讨论的一套解法,它不依赖数据库的全局锁,也不要求所有参与者支持 XA,而是把"提交"这件事拆成"预留资源—确认—撤销"三段,用业务代码把一致性责任扛起来。这篇内容适合已经了解分布式事务基本概念、正在为跨服务数据一致性发愁的后端开发看,也适合想搞清楚 TCC 到底难在哪、值不值得用的技术负责人参考。我会从它解决的问题讲起,把 Try-Confirm-Cancel 三个阶段的职责边界、空回滚与悬挂这两个经典坑、幂等设计的落地方式、以及和生产环境打交道时那些文档里不会写的细节,尽量掰开揉碎说清楚。
1. TCC 到底在补偿什么:从一次跨服务扣款说起
很多人第一次接触 TCC,是被"补偿"两个字带偏的,以为它就是"失败了再反向操作一把"。这个理解只对了一半,真正让 TCC 站得住脚的,是它把资源的占用和资源的最终归属拆成了两个时间点。要讲清楚这件事,得先看一个具体场景。
1.1 一个订单场景暴露的本地事务边界
假设一个下单链路涉及三个服务:订单服务创建订单、库存服务扣减库存、账户服务扣减余额。如果这三个操作在同一个数据库里,一个本地事务包住就完事了,任何一步失败整体回滚。但现实是它们分属三个独立部署的服务,各自连着自己的库,本地事务的边界到单个服务为止,跨不过去。
这时候最朴素的做法是"依次调用,失败就反向调用补偿接口"。听起来简单,问题在于:如果库存扣减成功了,账户扣减却超时了,你根本不知道账户那边到底扣没扣——超时不等于失败,可能只是响应慢,实际已经落库。你贸然去补偿库存,结果账户那边其实也成功了,数据就错乱了。这就是分布式场景下最典型的"状态不确定"问题,也是所有分布式事务方案要正面硬刚的核心矛盾。
TCC 的思路不是去消除这种不确定性,而是承认它存在,然后用一套约定把不确定性关进笼子里。它要求每个参与方都实现三个接口:Try 阶段做资源检查和预留,Confirm 阶段做真正的业务确认,Cancel 阶段做预留资源的释放。关键在于,Try 阶段执行完,资源就已经被"锁定"了,别的请求抢不走,但还没有真正生效;只有 Confirm 之后才算数。
1.2 Try-Confirm-Cancel 三段的职责划分
把三个阶段拆开看,各自的职责其实非常清晰,但很多人落地时容易混淆,导致后面一堆问题。
| 阶段 | 核心动作 | 资源状态 | 失败后的处理 |
|---|---|---|---|
| Try | 检查并预留资源 | 冻结/预占,未生效 | 由协调者发起 Cancel |
| Confirm | 确认使用预留资源 | 正式生效 | 必须成功,靠重试保证 |
| Cancel | 释放预留资源 | 回到初始态 | 必须成功,靠重试保证 |
Try 阶段是整个 TCC 的地基。它要做两件事:一是业务层面的合法性校验(比如库存够不够、余额足不足),二是把资源"冻"起来。以库存为例,不是直接减库存,而是把可用库存转到冻结库存,可用库存减少、冻结库存增加,总库存不变。这一步做完,即使后面别的请求进来,也抢不走这部分资源,因为可用库存已经不够了。
Confirm 阶段是把冻结的资源真正消耗掉,冻结库存减少,相当于完成出库。这一步理论上不会失败,因为资源在 Try 阶段已经预留好了,但工程上必须假设它会失败——网络抖动、服务重启都可能让 Confirm 调用失败,所以 Confirm 必须支持重试,且重试要幂等。
Cancel 阶段是把冻结的资源还回去,冻结库存转回可用库存。它同样必须支持重试和幂等。这里有个容易被忽略的点:Cancel 和 Confirm 是互斥的,一笔事务要么最终 Confirm,要么最终 Cancel,不会两个都执行,协调者要保证这个语义。
提示:Try 阶段预留资源时,一定要用"冻结"这种中间状态,而不是直接扣减。直接扣减的话,一旦后续要 Cancel,你没法区分"这笔扣减是本次事务的"还是"别的正常业务的",补偿就无从下手。
1.3 为什么不能简单用"反向操作"代替预留
有人会问,我 Try 阶段直接扣库存,Cancel 阶段加回来不就行了,何必搞个冻结状态。这个想法在小流量、低并发下能跑通,但一旦并发上来就会出问题。
设想库存只剩 1 件,两个请求同时进来。如果 Try 直接扣减,第一个请求扣成功库存变 0,第二个请求校验发现库存不足直接失败,看起来没问题。但如果第一个请求后续 Cancel 了,库存加回 1,这时候第二个请求早就失败了,用户看到的是"没货",实际上货又回来了,体验和数据都对不上。更麻烦的是,如果 Cancel 加回来的时候,正好有第三个请求在扣减,加和减的顺序一乱,库存可能变成负数或者凭空多出来。
用冻结状态就没这个问题:Try 阶段把可用库存转到冻结,可用库存变 0,第二个请求看到可用不足直接失败,这是符合预期的;第一个请求 Cancel 时把冻结转回可用,库存恢复,逻辑自洽。冻结状态本质上是一个"隔离带",把未决事务的影响限制在冻结池里,不影响可用资源的判断。这也是 TCC 相比"裸补偿"更严谨的地方。
2. 空回滚与悬挂:TCC 落地绕不开的两个坑
TCC 的理论模型很干净,但一上生产就会遇到两个经典异常:空回滚和悬挂。这两个词听着玄乎,本质都是网络不可靠导致的时序错乱。搞不清楚它们,你的 Cancel 逻辑迟早会把数据搞坏。
2.1 空回滚是怎么产生的
空回滚指的是:Cancel 执行了,但对应的 Try 根本没执行成功。听起来矛盾——没 Try 成功怎么会触发 Cancel?但在分布式调用里这太常见了。
典型链路是这样的:协调者调用参与方的 Try 接口,这个请求因为网络问题超时了,协调者判定 Try 失败,于是发起 Cancel。但实际上,那个 Try 请求可能压根没到达参与方(网络丢包),也可能到达了但参与方处理到一半挂了。无论哪种情况,参与方这边都没有完成资源预留,可 Cancel 请求却来了。如果 Cancel 逻辑不假思索地执行"冻结转可用",就会把本来不属于这笔事务的资源给释放了,凭空多出库存,数据直接错乱。
所以 Cancel 接口必须能识别"我到底有没有做过 Try"。做法是在 Try 阶段落一条事务记录,Cancel 进来先查这条记录:如果记录不存在或者状态是"未预留",说明 Try 没成功,直接返回成功即可,不做任何资源操作。这条记录就是防空回滚的关键,业内常说的"事务控制表"就是干这个的。
2.2 悬挂:Cancel 比 Try 先到
悬挂比空回滚更隐蔽。它的场景是:Try 请求因为网络延迟,迟迟没到参与方;协调者等不及超时了,先发了 Cancel;Cancel 执行完(此时因为没 Try 记录,走了空回滚逻辑直接返回成功);结果那个迟到的 Try 请求这才到达参与方,参与方一看没有事务记录,当成新事务处理,把资源给预留了。
这下坏了:这笔事务的 Cancel 已经执行过了,不会再来了,可 Try 却预留了资源,这部分资源永远冻结着,没人释放,成了"悬挂"的僵尸资源。时间一长,冻结池越积越多,可用资源越来越少,系统慢慢就废了。
防悬挂的核心是让 Try 能识别"这笔事务是不是已经被 Cancel 过了"。常见做法是在 Cancel 时也落一条记录(标记为"已回滚"),Try 进来先查:如果发现这笔事务已经有回滚记录,说明 Cancel 先到了,Try 直接拒绝执行,不再预留资源。这样迟到的 Try 就被挡在门外,不会产生悬挂。
2.3 用一张状态表同时挡住两个坑
空回滚和悬挂看似是两个问题,其实可以用同一张事务控制表解决,关键是把事务的每个阶段状态都记下来。我一般会设计成这样的结构:
CREATE TABLE tcc_transaction ( tx_id VARCHAR(64) PRIMARY KEY, -- 全局事务ID branch_id VARCHAR(64) NOT NULL, -- 分支事务ID status TINYINT NOT NULL, -- 1:TRY成功 2:CONFIRM成功 3:CANCEL成功 4:已回滚(防悬挂) create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_branch (branch_id) );Try 进来先按 branch_id 查:有记录且状态是"已回滚",说明 Cancel 先到,直接拒绝(防悬挂);没记录,插入一条 TRY 成功记录并预留资源。Cancel 进来先查:没记录,插入一条"已回滚"记录并直接返回成功(防空回滚,同时这条记录还能挡住后续迟到的 Try);有 TRY 记录,执行资源释放并更新为 CANCEL 成功。
这里有个细节值得说:插入"已回滚"记录和判断"是否已回滚"必须是原子的,否则并发下两个 Cancel 同时进来都判断没记录,会重复插入。用 branch_id 做唯一索引,靠数据库的唯一约束兜底是最省事的做法,插入冲突就说明已经处理过了,直接返回成功即可。
注意:状态表里的 branch_id 唯一约束是防并发的最后一道防线,别省。我见过为了"性能"去掉唯一索引、改用先查后插的实现,高并发下重复 Cancel 直接把冻结库存释放了两次,账对不上查了半天。
3. 幂等、重试与超时:让 Confirm 和 Cancel 真正可靠
TCC 的协调者本质上是个"死磕到底"的角色:Try 成功了就一定要 Confirm 成功,Try 失败了就一定要 Cancel 成功,中间不管遇到什么异常都靠重试硬扛。这就要求 Confirm 和 Cancel 接口必须能承受反复调用,也就是幂等。这块做不好,重试越多错得越离谱。
3.1 幂等不是加个唯一索引就完事
很多人对幂等的理解停留在"数据库加唯一索引,重复插入报错就忽略"。这在简单场景下够用,但 TCC 的 Confirm/Cancel 幂等要考虑的更多。
以 Confirm 为例,它的语义是"把冻结资源转为正式消耗"。如果重复执行,冻结库存会被减两次,直接减成负数。所以 Confirm 必须先判断"这笔事务的 Confirm 是不是已经做过了"。判断依据还是那张状态表:Confirm 进来先查状态,如果已经是 CONFIRM 成功,直接返回成功,不做任何资源操作;如果是 TRY 成功,执行资源扣减并更新状态;如果状态是 CANCEL 成功,说明事务已经回滚了,这时候再来 Confirm 属于异常,应该拒绝并告警。
Cancel 同理:已经是 CANCEL 成功就直接返回;是 TRY 成功就释放资源并更新;是 CONFIRM 成功则拒绝。这里的状态判断和资源操作必须在同一个本地事务里完成,否则判断完还没更新状态就挂了,下次重试又会重复操作。用本地事务包住"查状态—改资源—改状态"三步,是保证幂等的基本功。
3.2 重试策略:别用固定间隔死磕
Confirm 和 Cancel 的重试不是简单加个 for 循环就行。固定间隔重试在服务短暂抖动时可能连续失败,等抖动过去重试次数已经耗尽了;而间隔太长又会让事务长时间处于中间态,资源一直被占着。
我一般用指数退避加最大次数限制:第一次失败等 1 秒,第二次 2 秒,第三次 4 秒,依次翻倍,封顶到比如 5 分钟一次,总重试次数设个上限(比如 15 次,覆盖大约几小时)。超过上限还没成功,就把这笔事务标记为"待人工介入",落一条告警,让运维或开发去查。千万别无限重试,否则一个坏掉的分支会拖垮整个协调者的线程池。
重试还有个坑:重试的请求必须带同样的全局事务 ID 和分支 ID,不能每次重试生成新 ID,否则参与方那边根本认不出这是同一笔事务,幂等判断全失效。协调者要把事务上下文持久化下来,重启后还能接着重试,这也是为什么协调者自己也需要一张事务表。
3.3 超时时间怎么定才不坑自己
Try 的超时时间设置是个技术活。设太短,正常慢请求被误判失败,触发不必要的 Cancel,白白浪费资源预留;设太长,真出问题时协调者要等很久才发现,事务中间态持续时间过长,冻结资源迟迟不释放。
我的经验是:Try 超时 = 参与方 Try 接口 P99 耗时 × 2 到 3 倍。比如库存服务 Try 的 P99 是 50ms,那超时设 150ms 左右比较合理,既给了正常波动空间,又不会等太久。同时参与方内部也要设自己的超时,比如 Try 里查库存、更新冻结库存这些操作各自设超时,避免单个慢查询把整个 Try 拖死。
Confirm 和 Cancel 的超时可以设得比 Try 长一些,因为它们不涉及复杂的业务校验,主要是资源操作,但也不能无限长。另外协调者调用参与方的超时,和参与方内部操作的超时,要分层设置,外层比内层大,否则外层超时了内层还在跑,容易产生"协调者以为失败、参与方其实成功"的错位。
4. 从代码看 TCC:Try、Confirm、Cancel 的落地写法
光讲理论容易飘,落到代码上才知道哪里会卡。下面用库存服务为例,把三个接口的实现骨架和关键判断写出来,重点看状态流转和幂等处理。
4.1 Try 接口:先查状态再预留
Try 的核心是"防悬挂 + 预留资源",顺序不能乱。先查事务记录,确认不是已回滚状态,再执行预留。
public boolean tryReserve(String txId, String branchId, Long skuId, int count) { // 1. 防悬挂:先查是否已有回滚记录 TccRecord record = tccMapper.selectByBranchId(branchId); if (record != null && record.getStatus() == STATUS_ROLLBACKED) { // Cancel 先到了,拒绝预留 return false; } if (record != null && record.getStatus() == STATUS_TRY_SUCCESS) { // 重复 Try,直接返回成功(幂等) return true; } // 2. 本地事务:插入 TRY 记录 + 冻结库存 return transactionTemplate.execute(status -> { tccMapper.insertTryRecord(txId, branchId); int affected = stockMapper.freezeStock(skuId, count); if (affected == 0) { // 可用库存不足,回滚本地事务 status.setRollbackOnly(); return false; } return true; }); }这里freezeStock的 SQL 要带条件,类似UPDATE stock SET available = available - #{count}, frozen = frozen + #{count} WHERE sku_id = #{skuId} AND available >= #{count},靠available >= count这个条件保证不会超卖,affected 为 0 就说明库存不够。把判断和更新放在一条 SQL 里,避免"先查后改"的并发窗口。
4.2 Confirm 接口:状态机驱动,拒绝非法流转
Confirm 只认 TRY 成功这个前置状态,其他状态一律按幂等或异常处理。
public boolean confirm(String txId, String branchId, Long skuId, int count) { TccRecord record = tccMapper.selectByBranchId(branchId); if (record == null) { // 没有 Try 记录却来 Confirm,属于异常,告警 alertService.warn("confirm without try", branchId); return false; } if (record.getStatus() == STATUS_CONFIRM_SUCCESS) { return true; // 幂等 } if (record.getStatus() != STATUS_TRY_SUCCESS) { // 已回滚或其他状态,拒绝 return false; } return transactionTemplate.execute(status -> { // 冻结库存转出库,冻结减少,总库存实际减少 stockMapper.confirmStock(skuId, count); tccMapper.updateStatus(branchId, STATUS_CONFIRM_SUCCESS); return true; }); }confirmStock的 SQL 是UPDATE stock SET frozen = frozen - #{count} WHERE sku_id = #{skuId} AND frozen >= #{count},同样带条件防止冻结被减成负数。状态更新和资源操作在同一个本地事务里,保证要么都成功要么都失败。
4.3 Cancel 接口:空回滚判断是第一步
Cancel 要先处理"没 Try 记录"的情况,这是防空回滚的关键。
public boolean cancel(String txId, String branchId, Long skuId, int count) { TccRecord record = tccMapper.selectByBranchId(branchId); if (record == null) { // 空回滚:Try 没成功,插入回滚记录挡住后续迟到的 Try try { tccMapper.insertRollbackRecord(txId, branchId); } catch (DuplicateKeyException e) { // 并发下已被别的 Cancel 插入,忽略 } return true; } if (record.getStatus() == STATUS_CANCEL_SUCCESS) { return true; // 幂等 } if (record.getStatus() != STATUS_TRY_SUCCESS) { return false; } return transactionTemplate.execute(status -> { stockMapper.cancelStock(skuId, count); // 冻结转回可用 tccMapper.updateStatus(branchId, STATUS_CANCEL_SUCCESS); return true; }); }注意空回滚分支里插入回滚记录用了 try-catch 捕获唯一键冲突,这是并发 Cancel 同时进来的兜底。cancelStock的 SQL 是UPDATE stock SET available = available + #{count}, frozen = frozen - #{count} WHERE sku_id = #{skuId} AND frozen >= #{count},冻结不足说明数据已经不一致了,affected 为 0 要告警。
4.4 三个接口的公共约束
把上面三段代码的共同点拎出来,其实是几条硬约束:所有状态判断和资源操作必须在同一个本地事务里;所有资源更新的 SQL 都要带条件(available >= count或frozen >= count)防止减成负数;所有接口都要先查状态表再决定动作;所有异常分支都要有告警,别静默吞掉。这几条看着琐碎,但少一条就可能在某个边界场景下把数据搞坏。
5. 协调者视角:全局事务怎么串起来
参与方把三个接口实现好只是半件事,另一半在协调者身上。协调者负责发起 Try、根据结果决定 Confirm 还是 Cancel、以及在这些动作失败时重试。它的可靠性直接决定整个 TCC 能不能跑稳。
5.1 协调者的状态机设计
协调者自己也要维护全局事务的状态,一般有这几个:TRYING(正在执行 Try)、CONFIRMING(正在 Confirm)、CANCELING(正在 Cancel)、FINISHED(已完成)。发起一笔事务时先落一条 TRYING 记录,然后依次调用各参与方的 Try;全部成功就转 CONFIRMING 并逐个 Confirm,任一失败就转 CANCELING 并逐个 Cancel。
这里的关键是:协调者的状态变更也要持久化,且和调用参与方的动作要有明确的先后。常见做法是"先落状态再调用",比如决定要 Cancel 了,先把全局状态改成 CANCELING 落库,再去调各参与方的 Cancel。这样即使协调者中途挂了,重启后扫到 CANCELING 状态的事务,能接着把没 Cancel 完的分支补上。如果反过来"先调用再落状态",挂了之后根本不知道这笔事务进行到哪了。
5.2 分支事务的补偿顺序
一笔全局事务有多个分支时,Cancel 的顺序其实有讲究。理论上 TCC 的 Cancel 是各自独立的,顺序不影响正确性,因为每个分支只释放自己的预留资源。但工程上我倾向于按 Try 的逆序 Cancel,原因是业务上往往有依赖关系——比如先扣库存再扣余额,Cancel 时先还余额再还库存,更符合业务直觉,出问题时排查也顺。
Confirm 的顺序则没那么讲究,因为 Confirm 是"确认消耗",各分支互不影响,并行调用都行。不过并行 Confirm 要注意控制并发度,别一下子把下游打满,我一般用个小线程池限制同时 Confirm 的分支数。
5.3 协调者高可用不能只靠单机
协调者如果是单点,它挂了整个 TCC 就停摆。生产上协调者一般要做成集群,但集群带来一个新问题:同一笔事务不能被两个协调者同时处理,否则重复 Confirm/Cancel。解决办法是给全局事务加锁,比如用数据库的行锁或者分布式锁,处理某笔事务前先抢锁,抢到才处理。
另一个思路是把协调者的重试逻辑做成"扫描 + 认领"模式:后台线程定期扫状态表里处于中间态(TRYING/CONFIRMING/CANCELING)且超过一定时间没更新的事务,认领后继续推进。认领用乐观锁(带版本号更新)保证只有一个线程能拿到。这种模式天然支持多实例部署,挂了一个还有别的接着扫。
6. TCC 的适用边界:什么场景该用,什么场景别硬上
TCC 不是银弹,它对业务的侵入性很强——每个参与方都要改造成三个接口,还要维护状态表、处理幂等和异常。用错场景,改造成本和维护负担会压垮团队。
6.1 适合 TCC 的三类场景
第一类是资金类业务,比如支付、转账、账户扣款。这类业务对一致性要求极高,且天然适合"冻结—确认"模型:付款方先冻结金额,收款方确认后再真正划转,中间任何一步失败都能把冻结金额解冻,资金不会凭空消失。银行和支付机构大量用这个模式,不是没道理的。
第二类是库存类业务,尤其是秒杀、抢购这种高并发场景。Try 阶段冻结库存能有效防止超卖,Confirm 阶段完成出库,Cancel 阶段释放冻结。相比直接扣减再补偿,冻结模型在并发下更稳。
第三类是资源预订类,比如机票座位、酒店房间、优惠券锁定。这类业务的共性是"资源有限且需要临时占用",和 TCC 的预留语义高度契合。
6.2 不适合硬上 TCC 的情况
如果业务本身对一致性要求没那么高,比如发个通知、记个日志,用 TCC 就是杀鸡用牛刀,消息队列的最终一致性方案更轻量。如果参与方是第三方系统、你改不了它的接口,那也没法做 TCC,只能靠对账或者补偿任务兜底。
还有一种情况要特别小心:参与方的业务逻辑本身不支持"预留"语义。比如某个操作一旦执行就不可逆(发了短信、调了外部接口),你没法在 Try 阶段"预留"它,这种就不适合 TCC。硬要套,只能在 Try 阶段做校验、Confirm 阶段才真正执行,但这样 Try 就没起到预留作用,Confirm 失败时也没法补偿,等于退化成两阶段提交的简化版,可靠性打折扣。
6.3 和 Saga、本地消息表的取舍
TCC 常被拿来和 Saga、本地消息表比较。Saga 是长事务拆分,每个步骤都有对应的补偿操作,适合流程长、步骤多的业务,但它的补偿是"反向操作",不像 TCC 有预留隔离,中间态可能被其他请求看到。本地消息表靠消息队列保证最终一致,实现简单,但只保证"最终",中间可能长时间不一致,且不适合强一致的资源扣减。
我的取舍逻辑是:需要强一致且资源可预留,选 TCC;流程长、步骤多、能接受最终一致,选 Saga;只是异步通知、对实时性要求不高,选本地消息表。没有哪个方案通吃,关键是看业务对一致性的真实要求和参与方的改造能力。
7. 生产环境里那些文档不会写的细节
理论讲完,最后聊几个真正踩过才知道的坑。这些细节在方案文档里基本看不到,但每一条都可能在某个深夜把你叫起来。
7.1 冻结资源的对账不能省
TCC 跑久了,冻结池里难免有对不上的账——可能是某次 Cancel 没执行完、可能是人工误操作、也可能是代码 bug。所以一定要有对账机制:定期把冻结库存和状态表里处于 TRY 成功但长时间未 Confirm/Cancel 的记录做比对,发现异常就告警。
对账的粒度我一般按天做全量、按小时做增量。全量对账扫所有冻结记录,增量对账只看最近一小时状态有变化的。对不上的记录先别急着自动修复,人工确认后再处理,自动修复一旦逻辑有误,可能把小问题放大成大事故。
7.2 状态表的清理策略
状态表会随着事务量增长越来越大的,不清理迟早拖慢查询。但清理有讲究:不能删还在中间态的记录,否则重试时找不到状态,幂等就失效了。我的做法是只清理处于终态(CONFIRM 成功、CANCEL 成功、已回滚)且超过一定时间(比如 7 天)的记录,中间态的一律保留。
清理用分批删除,别一次性 delete 太多导致锁表。可以按 create_time 范围分批,每批几千条,删完 sleep 一下再删下一批。另外清理前最好先归档到历史表,万一后面要查历史事务还有据可查。
7.3 监控指标要盯住这几个
TCC 的监控不能只看接口成功率,有几个指标必须单独盯:中间态事务的数量和持续时间(TRYING/CONFIRMING/CANCELING 状态的事务如果持续增长,说明有分支卡住了)、Confirm/Cancel 的重试次数分布(重试次数异常高说明下游不稳)、空回滚和悬挂的触发次数(这两个指标一旦非零就要查原因)、冻结资源的增长趋势(持续增长说明有资源没释放)。
这些指标我一般配成告警,中间态事务超过阈值、空回滚次数突增、冻结资源超过总量的一定比例,都直接推告警。别等用户投诉了才发现问题,那时候数据可能已经错乱了。
7.4 灰度上线和回滚预案
TCC 改造涉及核心链路,千万别一把梭全量上线。我的做法是先选一个非核心的、流量小的业务做试点,跑一两周观察对账和监控都正常,再逐步扩大范围。灰度期间要保留旧逻辑的开关,出问题能快速切回去。
回滚预案也要提前想好:如果 TCC 上线后发现严重问题,怎么回退?已经产生的冻结资源怎么处理?这些都要在上线前演练一遍。我见过没做回滚预案的团队,上线出问题后手忙脚乱,最后只能停服人工修数据,代价极大。
我个人在实际操作中的体会是,TCC 的难点从来不在理论,而在那些边角场景的工程处理——空回滚、悬挂、幂等、重试、对账,每一个单独看都不复杂,但组合起来就是一张需要仔细编织的网。把这张网织密了,TCC 才能在生产环境里真正稳得住;织不密,它带来的麻烦可能比它解决的问题还多。所以每次决定用 TCC 之前,我都会先问自己一句:这个业务的参与方,真的都能改造成支持预留语义吗?想清楚这个问题,再动手不迟。