一提到“分布式事务--手写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完成,事务回滚结束。
状态流转只有三条路径:
- TRYING -> CONFIRMING -> CONFIRMED
- TRYING -> CANCELLING -> CANCELLED
- 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_id | varchar | 全局事务ID |
| branch_id | varchar | 分支事务ID |
| action_name | varchar | 参与者标识 |
| status | varchar | TRYING/CONFIRMED/CANCELLED/SUSPENDED |
| retry_count | int | 已重试次数 |
| try_time | datetime | Try执行时间 |
| confirm_time | datetime | Confirm执行时间 |
| cancel_time | datetime | Cancel执行时间 |
| extra_json | text | 上下文快照 |
这张表就是整个分布式事务的“账本”。恢复任务扫描超时的未完成事务时,就靠这张表决定该重试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,还是直接引入框架,我建议你先用本文这套最小模型跑通一个业务场景,哪怕只做转账加积分这两个服务,也足以让你建立对分布式事务的完整直觉。后面再去看那些成熟的开源框架,你会发现自己突然能看懂它们大部分设计决策背后的原因了。