☰
手写TCC框架:分布式事务核心实现与空回滚悬挂避坑指南
2026/10/18 6:20:54 网站建设 项目流程

一提到“分布式事务--手写TCC”,估计有人会先问:都什么年代了,还自己造轮子?老老实实接个现成的开源中间件不好吗。但你要是问一个真正把分布式事务吃过透的人,大概率他会跟你说:手写一遍,你才算真正上了这个台阶。这篇博客我想分享的,就是我从“纸上谈兵”到亲手撸一个最小化TCC框架的完整过程,包括设计思路、核心代码实现、调用链路、以及空回滚、悬挂这些绕不开的大坑。内容适合正在做微服务拆分、维护跨库跨服务核心链路的后端开发,也适合面试前想彻底搞懂TCC原理的进阶选手。哪怕你不打算自己写框架,看完这篇再回看各种开源方案的源码,也会有完全不一样的体感。

1. 为什么选择手写TCC而不是直接用现成框架

1.1 先弄清楚分布式事务到底在解决什么问题

分布式事务之所以难,本质是因为在微服务架构下,“一次业务操作”被拆成了多个服务上的多次本地操作,而每个服务都有自己的数据库,没有全局事务管理器能直接保证跨库原子性。用户在下单转账这种场景里,系统A扣钱成功、系统B加积分失败,钱没了积分还没到账,这就是典型的数据不一致事故。

常见的解决方案不止一种,但要做出选择,得先搞清楚每种方案的代价。我画过一张对比表,基本把思路讲清楚了:

方案核心思路优点缺点适合场景
两阶段提交/XA数据库层面先Prepare再Commit强一致、实现简单持有数据库锁时间长,协调者单点,阻塞式通信同构数据库、低并发内部系统
本地消息表本地事务写消息表,由异步任务转发并让下游消费可靠性不错、实现直观需要改业务表,消息流转链路长对最终一致性容忍度较高的场景
Saga正向流程+反向补偿,编排整个业务流程灵活、适合长流程中间状态对外可见,补偿逻辑同样要手写流程步骤多、链路长的业务
TCC业务接口拆成Try、Confirm、Cancel三个阶段一阶段不持有全局锁,支持异步化,性能较好业务侵入大,每个参与者都要实现三个方法高并发核心链路,转账、库存、积分等

从表格能看出来,TCC不是最简单的方案,但它把灵活性留给了业务,靠代码来约束一致性,所以特别适合那种“中间件性能不够、XA锁太重、业务又对实时性有要求”的场景。

1.2 手写TCC的真正价值是什么

很多团队引入开源分布式事务中间件之后,第一周很舒服,一旦线上出了“莫名其妙的不一致”,排查时全是被框架封装的细节。这时候你会发现自己对Try、Confirm、Cancel的生灭时机、分支事务注册顺序、异常恢复策略其实一无所知。

手写TCC的价值恰恰在这里。它逼着你去想:

  • 全局事务ID怎么生成怎么传递?
  • 分支事务在什么时机注册?
  • Confirm阶段如果局部失败了,该重试还是该回滚?
  • Cancel阶段跑了但Try其实没跑过,怎么办?

这些细节在开源框架里被封装成一个个配置项,你很难感受到它们的重量。自己动手写一遍,遇到这些问题的瞬间,你就理解了框架作者为什么那么设计。这种理解,是读十遍源码都比不上的。

另外一个很现实的理由是适配成本。我遇到不少老系统,技术栈五花八门,想整体接入一套分布式事务框架,改造成本极高。但业务核心链路就那么几条,你完全可以用一个轻量级TCC模块,在这几条链路上做精细控制,侵入小、可迁移,也比硬上重量级中间件稳得多。

2. TCC核心概念与整体设计思路

2.1 Try、Confirm、Cancel三个阶段的语义拆解

TCC这个名字取自Try、Confirm、Cancel三个单词的首字母。三个阶段不能只记名字,要理解每个阶段的责任边界。

Try阶段做的是资源预占和业务校验。拿转账举例子,转出方账户要在Try里把金额从“可用余额”挪到“冻结余额”,这个动作本身是一个本地事务,但它并没有真正完成扣款,只是把资源锁住,确保后面Confirm的时候不会余额不足。转入方如果也是TCC参与者,同样要在Try里预增积分或者预占入账额度。

Confirm阶段做的是正式提交。到了这个阶段,意味着协调者已经确认所有分支的Try都成功了,所以Confirm不能做任何校验,不能抛业务异常,必须直接完成资源确认。更直白地说,Confirm阶段只做“把冻结改成扣款”这种动作,保证能在本地事务里成功完成。

Cancel阶段做的是资源释放。一旦任何一个分支Try失败,或者Confirm阶段出现无法恢复的异常,协调者就需要对已经Try成功的分支执行Cancel,把冻结余额解冻回去,或者把预增积分扣掉。Cancel同样要求幂等和可靠。

用生活场景类比,TCC很像餐厅订座:Try是你打电话预定时餐厅帮你锁住桌位并确认人数,Confirm是当天到店确认就餐并下单,Cancel是提前取消预定释放桌位。注意订座这个动作本身不产生消费,只有到店确认才真正产生订单——这就是TCC和普通本地事务最大的不同:一阶段不真正落账,而是预占。

2.2 状态机设计与事务上下文传递方案

分布式事务框架的核心,其实是一个跨服务流转的状态机。我设计的最小状态集是这样:

  • TRYING:正在执行Try阶段,分支逐步注册。
  • CONFIRMING:所有Try成功,开始对每个分支执行Confirm。
  • CANCELLING:某个Try失败,开始对已成功分支执行Cancel。
  • CONFIRMED:全部Confirm完成,事务正常结束。
  • CANCELLED:全部Cancel完成,事务回滚结束。

状态流转只有三条路径:

  1. TRYING -> CONFIRMING -> CONFIRMED
  2. TRYING -> CANCELLING -> CANCELLED
  3. TRYING -> CANCELLING(Try阶段直接异常触发回滚)

这个状态机的关键是它必须在协调者节点持久化,不能只放在内存里。因为分布式环境下,协调者进程随时可能崩溃,如果没有持久化日志,重启后根本不知道这个全局事务该提交还是该回滚。这也是生产级框架一定会带一张事务日志表的原因。

事务ID的传递同样重要。全局事务ID要能够从发起方一直透传到所有参与方,同时每个分支需要独立的分支ID。最简单的方式是用ThreadLocal保存当前全局事务ID,在调用远端服务时把它放在RPC请求头里,下游服务再解析出来重新绑定到自己的ThreadLocal。如果是异步线程池场景,还得替换成支持父子线程传递的InheritableThreadLocal包装,或者用专门的TransmittableThreadLocal工具,否则事务ID一到异步线程就丢了。

2.3 核心接口设计与模块划分

手写TCC不需要一上来就搞花里胡哨的注解扫描,先把核心接口定义清楚比什么都重要。我的方案里有三个最基础的类:

public interface TccAction<T> { boolean try(T context); boolean confirm(T context); boolean cancel(T context); } public class TccTransaction { private String txId; private String status; // TRYING / CONFIRMING / CANCELLING / CONFIRMED / CANCELLED private List<TccBranch> branches = new ArrayList<>(); } public class TccBranch { private String branchId; private TccAction<?> action; private Object context; private boolean tried; // 标记Try是否真的执行成功 }

接口用泛型接收上下文对象,这样不同业务的参与者可以各自定义自己的入参结构。TccTransaction保存全局事务ID和状态,TccBranch代表一个分支事务,其中tried标记非常关键,Cancel阶段到底要不要执行业务Cancel,就看这个标记——它直接关系到后面要讲的空回滚问题。

模块划分上,至少要有三块:

  • 协调器(TransactionManager):负责创建事务、收集分支、决定提交还是回滚。
  • 分支执行器:包装每个TccAction,执行try/confirm/cancel并记录日志。
  • 事务上下文管理:负责ThreadLocal的存取以及RPC透传。

这三个模块职责清晰,后续扩展也不容易写乱。

3. 核心代码实现与调用链路解析

3.1 协调器:事务注册与提交回滚的骨架

先看协调器,这是整个手写TCC的心脏。我在设计时保留了一个教学版的核心流程,看起来简单,但每一行都对应一个重要的设计决策。

public class TccTransactionManager { private static final ThreadLocal<TccTransaction> CURRENT = new ThreadLocal<>(); private static final Map<String, TccTransaction> TRANSACTIONS = new ConcurrentHashMap<>(); public TccTransaction begin() { String txId = generateTxId(); TccTransaction tx = new TccTransaction(txId); TRANSACTIONS.put(txId, tx); CURRENT.set(tx); return tx; } public TccTransaction join(String txId) { TccTransaction tx = TRANSACTIONS.get(txId); if (tx == null) { throw new IllegalStateException("transaction not found: " + txId); } CURRENT.set(tx); return tx; } public void register(TccAction<?> action, Object context) { TccTransaction tx = getCurrent(); TccBranch branch = new TccBranch(generateBranchId(tx.getTxId()), action, context); tx.addBranch(branch); action.try(context); branch.setTried(true); } public void commit() { TccTransaction tx = getCurrent(); tx.setStatus("CONFIRMING"); for (TccBranch branch : tx.getBranches()) { branch.getAction().confirm(branch.getContext()); } tx.setStatus("CONFIRMED"); CURRENT.remove(); } public void rollback() { TccTransaction tx = getCurrent(); tx.setStatus("CANCELLING"); for (TccBranch branch : tx.getBranches()) { if (branch.isTried()) { branch.getAction().cancel(branch.getContext()); } } tx.setStatus("CANCELLED"); CURRENT.remove(); } }

这段代码有几个地方值得展开说。

register方法里,我是在分支注册的同时就执行了Try,然后立刻把tried标记置为true。这个顺序非常重要:如果先执行Try、后注册分支,中间一旦发生异常,协调者手里根本没有这个分支的记录,回滚时就会漏掉它。很多出问题的“手写TCC”都栽在这里。

commit阶段对每个分支执行Confirm,这里必须考虑局部失败的情况。教学版里确认失败就抛异常,但在生产实现里,这里要进入“重试队列”,记录哪个分支失败了,稍后由恢复任务反复重试。因为Confirm阶段原则上不校验业务,所以失败原因多是网络抖动、数据库瞬时不可用这类基础设施问题,重试几次通常就能成功。

3.2 业务参与者:以转账场景为例写一套完整动作

有了协调器骨架,还得有一组实际业务动作来串链路。我用了最经典的转账场景拆解:账户扣减已经接进来,再往里面加一个积分服务,这两个分布在不同库里的服务通过同一个TCC协调器保持一致性。

账户扣款参与者:

@Service public class AccountDeductAction implements TccAction<AccountContext> { @Override public boolean try(AccountContext ctx) { // 预占:可用余额减少,冻结余额增加 return accountMapper.freezeAmount(ctx.getAccountId(), ctx.getAmount()); } @Override public boolean confirm(AccountContext ctx) { // 正式扣减:把冻结余额清零 return accountMapper.confirmFreeze(ctx.getAccountId(), ctx.getAmount()); } @Override public boolean cancel(AccountContext ctx) { // 释放:冻结余额减少,可用余额恢复 return accountMapper.unfreezeAmount(ctx.getAccountId(), ctx.getAmount()); } }

积分增加参与者:

@Service public class PointAddAction implements TccAction<PointContext> { @Override public boolean try(PointContext ctx) { // 预占积分:先在积分冻结字段里增加 return pointMapper.preAddPoints(ctx.getUserId(), ctx.getPoints()); } @Override public boolean confirm(PointContext ctx) { // 确认积分:把冻结积分转成正式积分 return pointMapper.confirmAddPoints(ctx.getUserId(), ctx.getPoints()); } @Override public boolean cancel(PointContext ctx) { // 释放积分:把冻结积分恢复原状 return pointMapper.cancelPreAddPoints(ctx.getUserId(), ctx.getPoints()); } }

这里有个关键约束:每个参与者方法内部必须依赖本地数据库事务保证原子性,比如freezeAmount这个方法要用@Transactional包围。因为TCC的每个阶段是一次独立的本地操作,如果这个方法内部做了一半失败,整个冻结动作就会处于“改了可用余额但没改冻结余额”的中间态,这是绝对不允许的。所以TCC参与者的每一个动作,都必须是一个完整的本地事务。

3.3 调用链路的正确打开方式

协调器、组件都有了,现在还差一个把它们串起来的外层。这里是最容易写错的地方,很多人会直接在Controller里堆逻辑,结果把“协调”和“业务”混在一起,越写越乱。

推荐的做法是一个独立的转账服务,负责编排整个过程:

@Service public class TransferService { @PostMapping("/transfer") // 简化示意,方法内部处理一次完整转账 public String transfer(TransferRequest req) { TccTransaction tx = tccManager.begin(); try { // 第一阶段:所有分支都执行Try AccountContext accountCtx = new AccountContext(req.getFromAccountId(), req.getAmount()); PointContext pointCtx = new PointContext(req.getToUserId(), req.getAmount()); boolean accountTried = tccManager.register(accountDeductAction, accountCtx); boolean pointTried = tccManager.register(pointAddAction, pointCtx); // 第二阶段:全部Try成功才Confirm,否则回滚 if (accountTried && pointTried) { tccManager.commit(); return "success"; } else { tccManager.rollback(); return "failed"; } } catch (Exception e) { tccManager.rollback(); throw new BizException("transfer failed", e); } } }

这个流程在正常情况下很顺畅:先Try全部成功,然后Commit。一旦某个Try抛出异常,进入catch分支执行rollback,已注册且tried标记为true的分支会被逐一Cancel。

但这里还藏着一个容易被忽略的点:如果第二个参与者pointTried返回false,而第一个参与者accountTried已经成功了,rollback时就会遍历所有分支,对pointAddAction执行Cancel。可是这个分支的Try根本没执行,或者执行到一半失败了,直接Cancel就会产生所谓空回滚。这就要用到协调器代码里branch.isTried()的判断逻辑了。这个判断看起来简单,但在生产环境里落地时,tried状态必须持久化到分支事务表,否则协调器一旦在Try和Cancel之间崩溃,重启后就无法准确判断该不该执行Cancel了。

4. 那些让人头疼的异常场景:空回滚、悬挂与幂等

4.1 空回滚:Cancel跑了但业务并没有被Try过

空回滚是我在实际中遇到最多的问题,也是新手最容易忽略的坑。简单来说,TCC的协调器发现某个分支失败,于是对所有已注册分支执行Cancel。但这些分支里,有的可能Try成功了,有的可能在Try阶段就失败了,有的甚至因为网络原因Try请求根本没到达对端。Cancel动作只应该作用于那些Try真正成功的分支,否则就会出现“本就没有冻结资金,却在Cancel里执行解冻”这种异常操作。

落地时的解决方案可以很简单:在Cancel开始前查一次分支状态。

// 参与方在执行cancel之前 BranchStatus status = branchStatusDao.getStatus(txId, branchId); if (status == null || status == NOT_TRIED) { // 空回滚,直接标记CANCELLED,不执行任何业务回滚 branchStatusDao.mark(txId, branchId, CANCELLED); return true; }

更严谨的做法是在业务表里也记录Try执行痕迹。比如账户表有一个frozen_amount字段,Cancel时先查这个字段是不是真的大于零,等于零就直接返回成功。用数据库状态加状态表双重判断,基本能把空回滚风险降到最低。

4.2 悬挂:Try迟到,资源被永久锁死

悬挂问题和空回滚是一对孪生兄弟,但表现完全不同。场景是这样的:协调者调服务A的Try超时了,于是判定A分支失败,触发了对A的Cancel;然而A的Try请求只是慢,并没有真的失败,它最终还是在Cancel之后到达并成功执行了。结果就是这个分支已经被标记为“已回滚”,但业务资源又被Try给锁住了。

等到这轮事务结束,A的冻结资金没人给它解冻,这就是悬挂:一个挂起的事务分支,资源成了无主状态。

实战里有两种处理方案,可以叠加使用:

一种是状态标记法:在Try到达并准备执行前,先去分支事务表查一下这个分支是否存在Cancel或Confirm记录。只要存在,说明这个分支已经被判定回滚或提交了,当前这个迟到的Try就不该再执行业务,直接把它标记为“悬挂忽略”。

另一种是数据库唯一约束兜底:在冻结记录表里用tx_id + branch_id做唯一索引。即使Try最终执行了并尝试写入冻结流水,也会因为唯一键冲突而失败,从物理层面阻止悬挂产生的数据问题。

我在实际项目中是两种方案都上了,状态标记解决“明明知道不该Try”的智能判断,唯一约束解决Race Condition下的最后防线。别觉得这是过度设计,我在一个并发压测场景里真实遇到过这两个方案缺一个都会出问题。

4.3 幂等控制与重试机制

TCC进入生产环境后,任何一个远程调用都可能出现超时重试。协调者发现Confirm调用超时,可能会重试一次;而第一次调用其实已经在服务端执行成功了。这样一来,同样的Confirm动作被执行了两次。

解决幂等问题,最直接的方法是状态字段加条件更新。拿解冻来说:

UPDATE account SET frozen_amount = frozen_amount - #{amount}, status = 'CONFIRMED' WHERE account_id = #{accountId} AND frozen_amount >= #{amount} AND status = 'FROZEN'

如果这条SQL的影响行数为0,说明这个分支已经被Confirm过了,直接返回成功即可。用条件更新把幂等判断和业务更新放在同一个SQL里,就避免了“先查再改”的非原子窗口期。

重试机制本身也要有策略。我的经验是:

  • Try阶段不设置过多重试,因为它做的是预占,重复Try可能产生重复冻结。
  • Confirm和Cancel阶段必须支持自动重试,因为这两个阶段本身就是为最终一致性服务的。
  • 所有重试都要记录在分支日志表中,留下完整的attempt链路,方便排查。

4.4 状态评分:从异常场景反推框架设计

如果把上面三个问题放到一起看,能明显感觉到一个成熟TCC框架的核心不是那些花哨的注解,而是一张足够严谨的分支事务表。我在生产设计里,分支事务表最少包含这些字段:

字段类型说明
tx_idvarchar全局事务ID
branch_idvarchar分支事务ID
action_namevarchar参与者标识
statusvarcharTRYING/CONFIRMED/CANCELLED/SUSPENDED
retry_countint已重试次数
try_timedatetimeTry执行时间
confirm_timedatetimeConfirm执行时间
cancel_timedatetimeCancel执行时间
extra_jsontext上下文快照

这张表就是整个分布式事务的“账本”。恢复任务扫描超时的未完成事务时,就靠这张表决定该重试Confirm还是执行Cancel。教学Demo里可以忽略它,但生产项目里没有这张表的手写TCC,等于裸奔。

5. 从能跑到能上线:手写TCC的生产补全计划

5.1 协调者高可用与恢复机制

手写版协调器如果只是放在单机内存里,在线下环境跑一跑没问题,一上生产就是定时炸弹。分布式事务协调者本身必须高可用,至少要做到两点。

一是事务日志持久化到数据库。协调者每创建一个新事务,就要先把txId、状态、分支列表等关键信息写入事务日志表,然后才执行Try。这样即使协调者进程崩溃,重启后也能从日志表找到所有未完成事务,继续推进恢复。

二是部署多副本,通过选主机制保证同一时刻只有一个协调者对外服务,避免两个协调者同时处理同一个全局事务。这个选主机制可以直接复用你们团队已有的分布式锁组件,不必自己重新造。

有了持久化和选主,恢复流程才站得住脚。恢复任务定时扫描事务日志表,把所有长期停留在TRYING或者CONFIRMING状态的事务捞出来,逐个按照状态机规则推进。这个流程有点像一个定时闹钟,到点就把那些“悬在半空”的事务重新拉起来。

5.2 什么时候不该用TCC

我还想聊一点容易被忽略的反向思考。手写TCC虽然有掌控感,但它不是万能的。如果你们业务对一致性容忍度本来就高,比如“用户发帖后点赞数晚几分钟才显示”,那TCC就是杀鸡用牛刀,本地消息表或者MQ最终一致性方案更轻量,开发维护成本都更低。

反过来,如果业务要求用户转账后余额和积分必须立刻一致,且这两个操作都涉及高并发核心路径,那TCC就是合理选择。判断标准不是“分布式事务看起来高级”,而是“这个业务能不能容忍中间状态”。

5.3 从Demo到生产还需要补的细节清单

按照我自己的实战经验,从教学版到生产可用,至少要补齐下面这些能力:

  • 分支事务表的DML要全部带上幂等条件。
  • 所有Confirm和Cancel动作必须在事务日志表里留下attempt记录。
  • 恢复任务要支持配置重试次数上限,超过上限自动进人工处理队列。
  • Try阶段返回false时必须返回明确的失败原因码,方便定位是业务校验失败还是资源不足。
  • 全局事务ID要有统一的格式规范,最好包含时间戳、机器标识和序列号,便于排障时全局检索。
  • 参与者的三个方法不能共用同一个本地事务,每个阶段都必须是独立的数据库事务边界。

这些细节单独看都不复杂,但组合在一起,才是一个能经受住线上真实流量考验的框架底座。

我在实际操作中最大的体会是:纸上画状态机永远是最简单的,真到了改写代码、模拟网络抖动、看着分支事务表里的脏数据去反推问题的时候,才算是把这套机制吃透了。如果你正在考虑是自己手写一个TCC,还是直接引入框架,我建议你先用本文这套最小模型跑通一个业务场景,哪怕只做转账加积分这两个服务,也足以让你建立对分布式事务的完整直觉。后面再去看那些成熟的开源框架,你会发现自己突然能看懂它们大部分设计决策背后的原因了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询