TCC分布式事务原子性实现原理与落地避坑指南
分布式事务里最容易被讲得云山雾罩的一个点就是 TCC 的原子性到底从哪来。很多人第一次接触 TCC脑子里冒出来的疑问都差不多Try、Confirm、Cancel 明明是三次独立的远程调用中间还可能隔着网络抖动、服务重启、超时重试凭什么说它整体是原子的更让人困惑的是Cancel 本身也可能失败那失败之后又靠什么兜底我当年第一次在项目里落地 TCC 的时候就因为在 Confirm 阶段没做好幂等硬生生把一个订单状态改成了已支付但库存没扣的诡异中间态排查了大半天才定位到是重复回调惹的祸。这篇内容就围绕TCC 如何实现指令执行的原子性这个核心问题展开把 Try-Confirm-Cancel 三段式背后的原子性来源、每个阶段的设计约束、异常场景的兜底逻辑、以及真正落地时必须踩过的坑一条一条拆开讲清楚。适合已经了解分布式事务基本概念、准备在业务里落地 TCC 的中高级开发者也适合被空回滚悬挂幂等这几个词折磨过、想彻底搞明白原理的同学。读完之后你应该能自己判断什么业务适合上 TCCTry 阶段该预留什么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 前检查 CancelCANCEL 执行记录3. 事务协调器原子性的调度中枢3.1 协调器到底管什么不管什么很多人对事务协调器的角色有误解以为它像 2PC 的协调者一样保证原子性。实际上 TCC 协调器的职责要克制得多它负责记录全局事务和分支事务的状态、按顺序驱动 Try/Confirm/Cancel 的调用、在失败时按策略重试、以及在异常时做补偿决策。它不参与业务逻辑也不持有业务资源更不会替业务保证幂等——幂等是业务自己必须实现的。协调器的工作流程大致是开启全局事务时生成全局事务 ID每个分支注册时分配分支 IDTry 阶段收集所有分支的执行结果全部成功则进入 Confirm 阶段任一失败则进入 Cancel 阶段Confirm/Cancel 阶段对每个分支发起调用失败的进入重试队列直到成功或达到重试上限后转人工处理。这个重试直到成功的机制是 TCC 原子性的重要保障。因为 Confirm 和 Cancel 被设计成不应因业务失败所以理论上它们最终一定能成功协调器的重试就是在等待这个最终。但要注意重试是有代价的——如果某个分支的 Confirm 因为代码 bug 永远失败协调器会一直重试这时候必须有告警和人工介入机制否则事务会无限期卡住。3.2 为什么协调器也要做高可用和持久化协调器本身如果挂了整个事务的推进就断了。所以生产环境的协调器必须做高可用部署而且全局事务和分支事务的状态必须持久化到数据库不能只放内存。原因很简单协调器重启后要能恢复未完成的事务继续驱动 Confirm/Cancel如果状态只在内存里一重启全丢那些已经 Try 成功但还没 Confirm 的事务就成了孤儿。持久化的粒度也有讲究。全局事务状态进行中、已提交、已回滚和分支事务状态Try 成功/失败、Confirm 成功/失败、Cancel 成功/失败都要落库而且每次状态变更都要更新。这里有个性能权衡状态更新越频繁协调器的写压力越大但恢复时越精确。我的经验是分支级别的状态必须实时落库全局状态可以稍微粗一点因为恢复时主要靠分支状态来驱动补偿。注意协调器的高可用不是简单加个从库就完事要考虑主备切换时未完成事务的接管。切换过程中如果有事务正在 Confirm新主能不能正确识别并继续推进这需要在设计时就想清楚状态机的幂等性。3.3 超时与重试策略原子性的时间维度TCC 的原子性不仅在空间维度上跨服务状态一致还在时间维度上最终收敛。超时和重试策略就是管时间维度的。Try 阶段要有超时超时视为失败触发 CancelConfirm/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 的坑需要一定经验才能填。我个人的判断标准是如果这个业务失败了能靠人工兜底、且对实时一致性要求不高就别上 TCCSaga 或者消息表足够如果这个业务一旦不一致就会造成资金损失或者用户投诉且业务天然有预留语义那 TCC 是值得的。选型的时候别被分布式事务这四个字唬住工具是为业务服务的不是反过来。最后分享一个实操小技巧上线 TCC 之前一定要做混沌测试专门模拟网络超时、服务重启、协调器宕机这些场景验证幂等、空回滚、防悬挂三条路径都真的生效。我见过太多团队代码写得漂亮但一遇到乱序就崩就是因为没做异常路径的测试。把异常当常态来测TCC 的原子性才真的靠得住。