TCC分布式事务原子性实现原理与落地避坑指南
分布式事务里最容易被讲得云山雾罩的一个点,就是 TCC 的原子性到底从哪来。很多人第一次接触 TCC,脑子里冒出来的疑问都差不多:Try、Confirm、Cancel 明明是三次独立的远程调用,中间还可能隔着网络抖动、服务重启、超时重试,凭什么说它整体是原子的?更让人困惑的是,Cancel 本身也可能失败,那失败之后又靠什么兜底?我当年第一次在项目里落地 TCC 的时候,就因为在 Confirm 阶段没做好幂等,硬生生把一个订单状态改成了"已支付但库存没扣"的诡异中间态,排查了大半天才定位到是重复回调惹的祸。
这篇内容就围绕"TCC 如何实现指令执行的原子性"这个核心问题展开,把 Try-Confirm-Cancel 三段式背后的原子性来源、每个阶段的设计约束、异常场景的兜底逻辑、以及真正落地时必须踩过的坑,一条一条拆开讲清楚。适合已经了解分布式事务基本概念、准备在业务里落地 TCC 的中高级开发者,也适合被"空回滚""悬挂""幂等"这几个词折磨过、想彻底搞明白原理的同学。读完之后你应该能自己判断:什么业务适合上 TCC,Try 阶段该预留什么,Confirm 和 Cancel 为什么必须幂等,以及事务协调器在整个链路里到底扮演什么角色。
1. TCC 原子性的本质:不是数据库给的,是协议设计出来的
1.1 先搞清楚"原子性"在分布式语境下到底指什么
在单机数据库里谈原子性,大家心里都有底:一条 update 要么生效要么不生效,靠的是数据库的 redo/undo 日志和锁机制。但到了分布式场景,原子性的含义变了——它不再要求"物理上同时发生",而是要求"最终对外呈现的状态是一致的"。换句话说,一组跨服务的操作,要么全部按预期完成,要么全部回退到初始状态,中间那些短暂的、不对外暴露的中间态是允许存在的,但绝不能让外部观察到"一半成功一半失败"的终态。
TCC 的原子性正是建立在这个定义之上的。它不追求让 Try、Confirm、Cancel 三个动作在物理时间上原子执行,而是通过一套协议约束,保证业务最终只会落到"全部 Confirm"或"全部 Cancel"两个合法终态之一。理解这一点非常关键,因为很多人误以为 TCC 的原子性是框架"保证"出来的,实际上框架只负责调度和重试,真正的原子性是由业务代码在 Try 阶段的资源预留、Confirm/Cancel 的幂等设计共同"构造"出来的。
我习惯用一个生活化的类比来解释:你去酒店办入住,前台先给你"预留"一间房(Try,把房间从可售池挪到你的名下,但还没正式办理入住),你确认住下就正式登记(Confirm,房间状态变成已入住),你临时不住了就取消预留(Cancel,房间退回可售池)。整个过程中,房间不会出现"既被预留又被别人订走"的情况,这就是 Try 阶段资源预留带来的隔离性;而无论你最终住还是不住,房间状态都会收敛到一个确定结果,这就是原子性的体现。
1.2 两阶段提交的困境,正是 TCC 要绕开的坑
要理解 TCC 为什么这么设计,得先看看它的前辈——两阶段提交(2PC)卡在哪。2PC 的思路很直接:协调者先问所有参与者"能不能提交"(Prepare 阶段),大家都说能,再统一发"提交"指令(Commit 阶段)。问题在于,Prepare 阶段参与者要持有锁和资源直到 Commit 完成,这个持锁窗口可能很长,一旦协调者宕机或者网络分区,参与者就会一直卡在"已 Prepare 但等不到 Commit"的状态,资源被长期占用,系统吞吐直接崩掉。
TCC 的高明之处在于,它把"资源锁定"这件事从框架层下放到了业务层,并且用"预留"代替"锁定"。Try 阶段不是简单地加数据库行锁,而是业务自己定义一种"软预留"——比如把库存从"可用"扣到"冻结",把账户余额从"可用"划到"冻结"。这种预留不依赖数据库长事务,锁的粒度由业务自己控制,持锁时间也大幅缩短。Confirm 阶段只是把"冻结"转成"实扣",Cancel 阶段把"冻结"退回"可用",两个补偿动作都是本地短事务,执行快、释放快。
注意:TCC 用"业务预留"换来了性能,但代价是把复杂度转移给了业务开发。2PC 是框架帮你扛,TCC 是你自己扛。这也是为什么 TCC 不适合无脑套用,业务简单、一致性要求不极端的场景,用本地消息表或者 Saga 往往更省心。
1.3 三段式各自的职责边界,划不清就等着出事故
Try、Confirm、Cancel 三个阶段的职责必须划得清清楚楚,这是 TCC 原子性能成立的前提。Try 阶段只做一件事:检查和预留资源。检查是看业务前置条件是否满足(库存够不够、账户状态对不对),预留是把资源标记成"待确认"状态。Try 阶段绝对不能做真正的业务扣减,否则 Cancel 就没法干净地回退。
Confirm 阶段做真正的业务提交,把 Try 预留的资源转成实际消耗。这里有个铁律:Confirm 必须能无条件成功。也就是说,只要 Try 成功了,Confirm 就不应该再因为业务原因失败(比如库存不足),因为资源在 Try 阶段已经预留好了。如果 Confirm 还会因为业务校验失败,说明 Try 阶段的检查没做到位。
Cancel 阶段负责释放 Try 预留的资源,把"冻结"退回"可用"。Cancel 同样要能无条件成功,而且必须能处理"Try 根本没执行成功"的情况(空回滚),以及"Cancel 比 Try 先到"的情况(悬挂)。
| 阶段 | 核心职责 | 能否失败 | 关键约束 |
|---|---|---|---|
| Try | 检查并预留资源 | 可以失败(业务拒绝) | 只预留不实扣,预留要可识别 |
| Confirm | 提交预留资源 | 不应因业务失败 | 必须幂等,可重复执行 |
| Cancel | 释放预留资源 | 不应因业务失败 | 必须幂等,需处理空回滚和悬挂 |
这张表我建议每个准备上 TCC 的人都贴在工位上。我见过太多事故,根源就是有人把真正的扣减逻辑写进了 Try,结果 Cancel 的时候发现资源已经被消耗,根本退不回去;也有人 Confirm 里还带着"库存是否充足"的校验,一旦预留期间库存被别的流程动了,Confirm 就失败,整个事务卡死。
2. 原子性的三大支柱:幂等、空回滚、防悬挂
2.1 幂等:为什么 Confirm 和 Cancel 必须能重复执行
分布式环境下,网络超时是最常见的"薛定谔的调用"——你发出去了,但不知道对方收没收到。事务协调器遇到超时只能重试,而重试就意味着 Confirm 或 Cancel 可能被执行多次。如果这两个动作不幂等,重复执行就会造成数据错乱:Confirm 执行两次,库存扣两次;Cancel 执行两次,冻结退两次,账直接对不上。
幂等的实现思路,核心是给每次事务操作一个全局唯一的标识(通常是事务 ID + 分支 ID),然后在执行 Confirm/Cancel 前先查一下这个操作是否已经执行过。具体落地有两种常见做法:一种是在业务表里加一个"事务流水表",记录每个分支的执行状态,执行前先查流水;另一种是直接利用业务状态机,比如库存冻结记录有个 status 字段,Confirm 时判断 status 是否已经是"已确认",是就直接返回成功。
-- 事务流水表设计示例 CREATE TABLE tcc_transaction_log ( tx_id 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 COMMENT '0-执行中 1-成功 2-失败', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, PRIMARY KEY (tx_id, branch_id, action) );提示:幂等判断和执行更新必须在同一个本地事务里完成,否则"查了没执行"和"真正执行"之间又会出现并发窗口。用数据库唯一索引兜底是最稳的,插入流水冲突就说明已经执行过。
我踩过的一个坑是:早期图省事,幂等判断用的是"先 select 再 update"的分离逻辑,结果在高并发重试下,两个线程同时 select 到"未执行",然后都去 update,库存被扣了两次。后来改成"insert 流水 + 唯一索引冲突即视为已执行",问题才彻底解决。这个教训告诉我,幂等不能靠应用层的"查一下",得靠存储层的原子约束。
2.2 空回滚:Cancel 来了但 Try 根本没成功怎么办
空回滚是 TCC 里特别容易被忽略的场景。典型情况是这样的:协调器发起 Try 调用,但因为网络问题,Try 请求还没到达参与者,或者参与者处理到一半宕机了,协调器判定 Try 失败,于是发起 Cancel。这时候参与者收到 Cancel,但它压根没执行过 Try,没有预留任何资源,如果 Cancel 逻辑无脑去"释放预留",就会释放一个不存在的资源,轻则报错,重则把别人的资源给退了。
空回滚的处理原则是:Cancel 执行前先判断 Try 是否真的执行过。判断依据就是前面说的分支事务记录——如果流水表里查不到这个分支的 Try 记录,说明 Try 没成功,Cancel 直接返回成功即可,不做任何资源操作。这里有个细节要注意:判断"Try 是否执行过"和"Cancel 是否执行过"要分开,因为 Cancel 本身也可能被重复调用。
public boolean cancel(BusinessActionContext context) { String txId = context.getXid(); String branchId = context.getBranchId(); // 先查 Try 是否执行过 if (!transactionLog.exists(txId, branchId, "TRY")) { // Try 没执行,空回滚,直接返回成功 // 但仍要记录 Cancel 已执行,防止后续悬挂的 Try 到来 transactionLog.record(txId, branchId, "CANCEL"); return true; } // Try 执行过,正常释放预留资源 return doCancel(context); }注意上面代码里那个"仍要记录 Cancel 已执行"的动作,这是防悬挂的关键,下一节会详细讲。空回滚如果只是简单返回成功而不留痕,就会给悬挂问题埋下伏笔。
2.3 防悬挂:Cancel 先到、Try 后到的乱序怎么破
悬挂问题比空回滚更隐蔽。它的场景是:Try 请求因为网络延迟,在协调器判定超时并发出 Cancel 之后才到达参与者。如果参与者老老实实执行了这个迟到的 Try,就会预留出一批资源,但这批资源对应的全局事务早就被 Cancel 了,Confirm 永远不会来,资源就永久冻结在那里,成了"悬挂"的脏数据。
防悬挂的核心思路是:Try 执行前先检查这个分支的 Cancel 是否已经执行过。如果 Cancel 已经执行(流水表里有 CANCEL 记录),说明这个 Try 是迟到的,必须拒绝执行,直接返回失败。这就要求 Cancel 在执行时,即使遇到空回滚,也要留下 CANCEL 记录——这正是上一节强调"空回滚也要留痕"的原因。
public boolean tryAction(BusinessActionContext context) { String txId = context.getXid(); String branchId = context.getBranchId(); // 检查 Cancel 是否已执行,防止悬挂 if (transactionLog.exists(txId, branchId, "CANCEL")) { // Cancel 已执行,拒绝这个迟到的 Try return false; } // 正常执行资源预留 return doTry(context); }把幂等、空回滚、防悬挂这三件事串起来看,你会发现它们其实是一套组合拳:幂等保证重复调用不出错,空回滚保证 Try 没成功时 Cancel 不误伤,防悬挂保证迟到的 Try 不产生脏数据。三者共同构成了 TCC 原子性的"防御体系",缺了任何一个,原子性都会在某个异常路径上被打破。
| 问题 | 触发场景 | 解决手段 | 依赖的记录 |
|---|---|---|---|
| 重复执行 | 网络超时重试 | 幂等控制 | TRY/CONFIRM/CANCEL 执行记录 |
| 空回滚 | Try 未达但 Cancel 到达 | 判断 Try 是否存在 | TRY 执行记录 |
| 悬挂 | Cancel 先于 Try 到达 | Try 前检查 Cancel | CANCEL 执行记录 |
3. 事务协调器:原子性的调度中枢
3.1 协调器到底管什么,不管什么
很多人对事务协调器的角色有误解,以为它像 2PC 的协调者一样"保证"原子性。实际上 TCC 协调器的职责要克制得多:它负责记录全局事务和分支事务的状态、按顺序驱动 Try/Confirm/Cancel 的调用、在失败时按策略重试、以及在异常时做补偿决策。它不参与业务逻辑,也不持有业务资源,更不会替业务保证幂等——幂等是业务自己必须实现的。
协调器的工作流程大致是:开启全局事务时生成全局事务 ID,每个分支注册时分配分支 ID;Try 阶段收集所有分支的执行结果,全部成功则进入 Confirm 阶段,任一失败则进入 Cancel 阶段;Confirm/Cancel 阶段对每个分支发起调用,失败的进入重试队列,直到成功或达到重试上限后转人工处理。
这个"重试直到成功"的机制,是 TCC 原子性的重要保障。因为 Confirm 和 Cancel 被设计成"不应因业务失败",所以理论上它们最终一定能成功,协调器的重试就是在等待这个"最终"。但要注意,重试是有代价的——如果某个分支的 Confirm 因为代码 bug 永远失败,协调器会一直重试,这时候必须有告警和人工介入机制,否则事务会无限期卡住。
3.2 为什么协调器也要做高可用和持久化
协调器本身如果挂了,整个事务的推进就断了。所以生产环境的协调器必须做高可用部署,而且全局事务和分支事务的状态必须持久化到数据库,不能只放内存。原因很简单:协调器重启后要能恢复未完成的事务,继续驱动 Confirm/Cancel,如果状态只在内存里,一重启全丢,那些已经 Try 成功但还没 Confirm 的事务就成了孤儿。
持久化的粒度也有讲究。全局事务状态(进行中、已提交、已回滚)和分支事务状态(Try 成功/失败、Confirm 成功/失败、Cancel 成功/失败)都要落库,而且每次状态变更都要更新。这里有个性能权衡:状态更新越频繁,协调器的写压力越大,但恢复时越精确。我的经验是,分支级别的状态必须实时落库,全局状态可以稍微粗一点,因为恢复时主要靠分支状态来驱动补偿。
注意:协调器的高可用不是简单加个从库就完事,要考虑主备切换时未完成事务的接管。切换过程中如果有事务正在 Confirm,新主能不能正确识别并继续推进,这需要在设计时就想清楚状态机的幂等性。
3.3 超时与重试策略:原子性的时间维度
TCC 的原子性不仅在空间维度上(跨服务状态一致),还在时间维度上(最终收敛)。超时和重试策略就是管时间维度的。Try 阶段要有超时,超时视为失败,触发 Cancel;Confirm/Cancel 阶段也要有超时,超时触发重试。重试策略通常是退避重试,间隔逐渐拉长,避免在对方服务故障时疯狂打请求。
重试次数不能无限,得设上限。达到上限后,事务进入"待人工处理"状态,同时触发告警。这里有个实操经验:重试上限不要设得太小,因为很多失败是瞬时的(网络抖动、对方服务短暂不可用),退避重试几次往往就好了;但也不能太大,否则一个真正的代码 bug 会让协调器重试到天荒地老,白白消耗资源。我一般设 5 到 10 次退避重试,间隔从秒级到分钟级递增。
超时时间的设置也有讲究。Try 的超时要大于业务正常处理时间,否则正常请求会被误判失败;Confirm/Cancel 的超时可以短一些,因为它们应该是快速操作。但要注意,超时判定失败后触发的 Cancel,本身也可能因为网络问题超时,所以 Cancel 的重试要更宽容。
4. 落地实操:从零搭一个能跑通的 TCC 分支
4.1 业务改造:以库存扣减为例设计预留模型
光讲原理不够,得看真实业务怎么改。拿最经典的库存扣减举例。传统做法是一条update stock set count = count - 1 where id = ? and count >= 1,但在 TCC 里,这条语句要拆成三段。
Try 阶段做预留:update stock set frozen = frozen + 1, available = available - 1 where id = ? and available >= 1。这里把库存拆成"可用"和"冻结"两个字段,Try 把可用挪到冻结,相当于占住这批货。Confirm 阶段做实扣:update stock set frozen = frozen - 1 where id = ?,冻结的货真正消耗掉。Cancel 阶段做释放:update stock set frozen = frozen - 1, available = available + 1 where id = ?,冻结的货退回可用。
-- 库存表结构 CREATE TABLE stock ( id BIGINT PRIMARY KEY, available INT NOT NULL COMMENT '可用库存', frozen INT NOT NULL COMMENT '冻结库存', version INT NOT NULL DEFAULT 0 ); -- Try: 预留 UPDATE stock SET available = available - #{num}, frozen = frozen + #{num} WHERE id = #{id} AND available >= #{num}; -- Confirm: 实扣 UPDATE stock SET frozen = frozen - #{num} WHERE id = #{id} AND frozen >= #{num}; -- Cancel: 释放 UPDATE stock SET frozen = frozen - #{num}, available = available + #{num} WHERE id = #{id} AND frozen >= #{num};这个模型的关键在于"可用"和"冻结"的分离。Try 只动这两个字段,不真正减少总库存;Confirm 减少冻结;Cancel 把冻结退回可用。整个过程中,总库存(available + frozen)在 Try 和 Cancel 时保持不变,只有 Confirm 时才真正减少,这就保证了资源不会凭空消失或凭空出现。
4.2 幂等表与状态机的具体实现
前面讲了幂等要靠流水表,这里给出具体实现。流水表以 (tx_id, branch_id, action) 为唯一键,每次执行 Confirm/Cancel 前先尝试插入一条记录,插入成功说明是首次执行,继续走业务逻辑;插入冲突说明已执行过,直接返回成功。
public boolean confirm(BusinessActionContext context) { String txId = context.getXid(); String branchId = context.getBranchId(); try { // 尝试插入流水,唯一索引冲突则抛异常 transactionLog.insert(txId, branchId, "CONFIRM"); } catch (DuplicateKeyException e) { // 已执行过,幂等返回 return true; } // 首次执行,做真正的业务提交 return doConfirm(context); }这里有个顺序问题要特别注意:插入流水和业务操作必须在同一个本地事务里。如果先插流水、再执行业务、业务失败回滚,那流水也得跟着回滚,否则会出现"流水说执行了但业务没执行"的假象。所以正确做法是把两者包在一个@Transactional里,要么都成功,要么都回滚。
状态机的方式适合业务本身就有状态字段的场景。比如订单表有 status 字段,Try 时把订单从"待支付"改成"支付中",Confirm 时改成"已支付",Cancel 时改回"待支付"。每次状态变更前判断当前状态是否允许这次变更,不允许就说明是重复调用,直接返回。这种方式省了额外的流水表,但对业务状态设计的要求更高。
4.3 与协调器对接的关键配置
业务分支写好后,要注册到协调器。以常见的 TCC 框架为例,需要在 Try/Confirm/Cancel 方法上加注解,声明这是哪个阶段的方法,框架会自动生成对应的调用代理。配置上要注意几个点:事务超时时间、重试次数、以及分支注册的方式。
@TccTransaction public class StockService { @TccTry public boolean tryDeduct(StockContext ctx) { ... } @TccConfirm public boolean confirmDeduct(StockContext ctx) { ... } @TccCancel public boolean cancelDeduct(StockContext ctx) { ... } }对接时最容易出问题的是上下文传递。Try 阶段的业务参数(比如扣多少库存、扣哪个商品)要能传到 Confirm 和 Cancel,因为后两个阶段需要同样的参数来定位资源。框架一般会把 Try 的参数序列化后存起来,Confirm/Cancel 时反序列化传入,所以参数对象必须可序列化,而且不能太大。
提示:参数里不要塞大对象或者不可序列化的东西(比如数据库连接、文件句柄),否则序列化会失败。我见过有人在参数里传了整个 request 对象,结果序列化时因为里面有 Servlet 相关字段直接报错。
5. 常见故障排查与避坑清单
5.1 事务卡在"进行中"不动了怎么查
这是线上最常见的问题。事务卡住的原因通常有三类:协调器没收到分支的响应、分支的 Confirm/Cancel 一直失败、或者协调器本身出了问题。排查顺序建议是:先看协调器日志里这个全局事务的状态和最近一次操作,再看各分支的流水表里 Try/Confirm/Cancel 的记录,最后看分支服务的日志有没有异常。
如果发现 Try 都成功了但 Confirm 没发起,多半是协调器在收集结果时卡住了,检查协调器的线程池和数据库连接。如果 Confirm 发起了但一直失败,看分支服务日志里的具体异常,常见的是幂等表插入冲突处理不当、或者业务 SQL 报错。如果协调器日志里根本没有这个事务,那可能是事务 ID 生成或传递出了问题。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Try 成功但无 Confirm | 协调器收集结果卡住 | 协调器线程池、DB 连接 |
| Confirm 反复失败 | 分支业务异常或幂等逻辑 bug | 分支服务日志、流水表 |
| 协调器无事务记录 | 事务 ID 生成/传递异常 | 入口日志、上下文透传 |
| 资源长期冻结 | 悬挂或 Cancel 未执行 | 检查 CANCEL 记录、防悬挂逻辑 |
5.2 数据不一致的典型模式与修复
TCC 落地后如果出现数据不一致,通常是这几种模式:一是 Confirm 部分成功部分失败,导致有的分支扣了有的没扣;二是 Cancel 没把资源退干净,冻结字段残留;三是悬挂导致资源永久冻结。第一种最严重,需要人工对账修复;后两种相对好处理,补执行 Cancel 或者清理悬挂记录即可。
修复的核心是"对账 + 补偿"。定期跑对账任务,比对全局事务状态和各分支的实际业务状态,发现不一致就生成补偿任务。补偿任务要幂等,重复执行不能造成二次伤害。我一般会在对账任务里加一个"补偿次数"字段,超过阈值就转人工,避免自动补偿陷入死循环。
5.3 我踩过的三个真实坑
第一个坑是 Try 里做了实扣。当时图省事,Try 直接扣了库存,Cancel 的时候想加回去,结果发现如果 Confirm 已经执行了一部分,加回去的数目就不对了。后来老老实实改成冻结模型,才把逻辑理顺。这个坑的教训是:Try 的语义必须是"预留",任何"消耗"动作都不能放进来。
第二个坑是幂等表没加唯一索引。早期用"先查后写"做幂等,压测时并发重试直接把库存扣成了负数。加了唯一索引、改成"插入冲突即幂等"之后才稳定。这个坑告诉我,幂等不能靠应用层判断,得靠存储层的原子性。
第三个坑是防悬挂逻辑漏了空回滚留痕。有次线上出现一批永久冻结的库存,查了半天发现是 Cancel 空回滚时没记录 CANCEL,导致迟到的 Try 顺利执行,资源就悬挂了。补上"空回滚也记录 CANCEL"之后问题消失。这个坑最隐蔽,因为空回滚本身看起来"什么都没做",很容易被忽略。
6. 什么场景该用 TCC,什么场景别硬上
TCC 不是银弹,它的适用边界其实挺清晰的。适合用的场景:业务本身有明确的"预留"语义(库存、额度、名额),一致性要求高(不能接受最终不一致),并发量中等(预留模型会增加字段和逻辑复杂度,超高并发下要考虑性能)。不适合的场景:业务没有预留概念(比如纯日志记录)、一致性要求可以用最终一致兜底(用本地消息表更简单)、团队对分布式事务经验不足(TCC 的坑需要一定经验才能填)。
我个人的判断标准是:如果这个业务失败了能靠人工兜底、且对实时一致性要求不高,就别上 TCC,Saga 或者消息表足够;如果这个业务一旦不一致就会造成资金损失或者用户投诉,且业务天然有预留语义,那 TCC 是值得的。选型的时候别被"分布式事务"这四个字唬住,工具是为业务服务的,不是反过来。
最后分享一个实操小技巧:上线 TCC 之前,一定要做混沌测试,专门模拟网络超时、服务重启、协调器宕机这些场景,验证幂等、空回滚、防悬挂三条路径都真的生效。我见过太多团队代码写得漂亮,但一遇到乱序就崩,就是因为没做异常路径的测试。把异常当常态来测,TCC 的原子性才真的靠得住。