1. 先认清目标:分布式事务到底在解决什么问题
分布式事务、两阶段提交、三阶段提交、TCC——凡是做过订单、支付、库存这类线上系统的同学,应该都绕不开这几个词。用户下一单,订单库写入一条订单,库存库扣掉一个库存,这两个操作只要有一个失败,用户就会看到"订单支付成功但没货"的诡异状态。要命的是,等到对账脚本把数据找回来修好,用户早就去客服投诉了。
但在动手看2PC、3PC、TCC的实现细节之前,我建议先把问题本身想透,不然很容易被协议文档绕晕。
1.1 本地事务的"低成本假设"为什么跨库之后不成立
我们天天用的数据库事务,比如MySQL的InnoDB事务,能保证一个库里多条SQL要么全部成功、要么全部回滚。它依赖几个前提:同一个进程连同一个数据库、锁和日志都在本地、节点不会无预警消失。这套机制用起来很便宜,但一旦遇到"订单库"和"库存库"是两套独立部署的MySQL,甚至一个是MySQL、一个是Redis,问题就来了——本地事务的提交范围根本覆盖不到另一个节点。
分布式事务要做的,说白了就是把"跨节点、跨库甚至跨团队"的一组操作,包装成都成功或都失败的整体。但分布式系统里有个残酷现实:你没法让所有节点在同一瞬间做出决定,网络延迟和进程崩溃都是常态。所以所有协议都在做同一件事——在不同时间里收集状态、传播意图、对齐结论。
1.2 一致性不是简单的"同时成功"
很多刚接触分布式事务的同学以为,一致性就是"所有数据库都写入成功"才算成功。实际操作中我们要区分三种状态:所有节点都提交、所有节点都回滚、部分节点提交了但总体认为是失败的异常状态。分布式事务协议的目标永远不是消灭第三种状态,而是让它在特定条件下可检测、可恢复。
这也是我为什么想把2PC、3PC、TCC放一起讲的原因:它们都在处理"不确定性",但应对策略完全不同。2PC靠协调者汇总后强推,3PC靠预判和超时降低风险,TCC靠业务层逆向操作兜底。没有银弹,每套方案都有代价,选型时的关键问题是"你的业务能接受哪种代价"。
2. 两阶段提交(2PC)协议拆解:提交阶段前的一切都可能回滚
2PC是数据库领域最广为人知的分布式事务协议,很多中间件实现的分布式事务底层都脱胎于它。理解2PC的关键在于认识两个角色、两个阶段,以及它为什么不能解决所有问题。
2.1 两个角色:协调者和参与者
2PC里有一个角色叫协调者(Coordinator),其他所有参与事务的节点叫参与者(Participant)。协调者的职责是收集参与者的状态并做最终决策,参与者只负责执行自己的子事务并向协调者报告结果。
这个模型很像公司里开跨部门会议:协调者是会议主持人,参与者是各部门负责人。主持人问"大家能执行吗?",各部门报告"能"或"不能",主持人听完所有人回复后,宣布"开工"或"散会"。这里有个容易被忽略的点:协调者不一定部署在独立节点上,它可以是某个参与者兼任的,也可以是独立服务。
2.2 第一阶段:准备投票——资源先锁住,不提交
第一阶段叫Prepare阶段(也叫投票阶段)。协调者向所有参与者发送Prepare请求,参与者收到后执行本地事务,写入操作数据但是不提交,只把事务状态置为"ready"。执行成功后参与者向协调者回复"OK",执行失败则回复"Abort"。
注意,这个"执行但不提交"并不是白干的,它要求参与者在本地把资源锁住、把Undo/Redo日志写完整,确保将来无论协调者让它提交还是回滚,它都能立即照做。这也是2PC最消耗资源的地方:锁从第一阶段开始一直持有,直到第二阶段结束才释放。
2.3 第二阶段:提交或回滚——协调者拍板定生死
第二阶段叫Commit阶段(也叫提交阶段)。当协调者收到所有参与者的投票后,分两种情况处理:
- 全部投"OK":协调者向所有参与者发送Commit指令,参与者各自提交事务,释放资源并回复确认。
- 任何一个投"Abort",或某个参与者在超时时间内没有回复:协调者向所有参与者发送Abort指令,参与者回滚本地事务并释放资源。
第二阶段最大的特点是"一刀切":协调者一旦发出Commit,就代表它认定所有人都准备好提交了。如果某个参与者在Commit指令发出前网络闪断,协调者会把它当成"投了Abort"处理,不会强行提交。但问题在于第一阶段结束时,某些参与者已经把事务准备好了,如果协调者在第二阶段中途宕机,这些参与者就挂在"准备完成、等待裁决"的状态里无法自行决定——这在行业里叫"悬挂事务"或"in-doubt事务"。
2.4 2PC的三个硬伤,每一个都真实存在
我遇到过不止一次类似问题:线上某个数据库节点短暂失联,协调者判定失败并回滚,但另一个节点的本地事务已经提交了。三个硬伤分别对应三类场景:
- 同步阻塞。第一阶段到第二阶段之间,参与者的所有相关资源都被锁住,其他事务只能干等。如果某个参与者在第二阶段迟迟不回复,整个事务的锁会一直挂在那里。
- 协调者单点故障。协调者宕机,所有参与者都会阻塞在"ready"状态,除非协调者恢复并给出决策,否则谁也动不了。
- 脑裂窗口。协调者发出Commit指令时,如果网络出现分区,一部分参与者收到Commit并提交,另一部分没收到,导致最终数据不一致。
2PC在整个分布式事务体系里的位置有点像"经典但不完美"的老设计——它奠定了"先问后做"的框架,但实际生产环境里几乎没有人敢裸用,后面讲3PC和TCC时会看到它们分别补了哪些坑。
3. 三阶段提交(3PC)的"激进改良":为什么它始终小众
3PC(Three-Phase Commit)是为了解决2PC的阻塞问题提出的。很多人会以为3PC就是"比2PC多了一个阶段",其实它真正改的是决策时机和超时机制。读3PC协议时要抓住一个关键词:预判。
3.1 2PC最大的痛点不是慢,而是"不敢猜"
2PC里,参与者到了"ready"状态后只能傻等协调者的指令。如果协调者崩了,参与者完全不敢猜测最终决定是提交还是回滚。原因很微妙:如果猜"提交"但协调者原本要回滚,数据就错了;如果猜"回滚"但协调者原本要提交,数据也错了。换句话说,2PC把决策权全部集中在协调者,参与者只能被动执行。
3PC的改法是:让参与者在提交前多经历一个"预提交"状态,并引入超时策略,让参与者在协调者长时间失联时,可以根据自己当前所处的状态自行做出倾向性决定。这个改动看似简单,实际上是在"推卸责任"——把一部分决策权交还给了参与者。
3.2 canCommit、preCommit、doCommit:每个阶段的意义
3PC把原先2PC的"准备"阶段拆成了两步,再加上最终提交,一共三个阶段:
- canCommit阶段(询问):协调者向参与者发送事务请求,参与者检查自身资源是否空闲、事务是否可执行,然后回复"可以"或"不可以"。注意这个阶段参与者还没有执行任何实质操作,它只是"面试评估",不会锁资源。
- preCommit阶段(预提交):协调者收到所有"可以"的回复后,向参与者发送预提交指令。参与者收到后真的执行事务操作,写入Undo/Redo日志,然后锁定资源,回复"预提交完成"。这个时刻相当于2PC里的"ready"状态。
- doCommit阶段(最终提交):协调者收到所有"预提交完成"后,发送最终提交指令。参与者提交事务,释放资源并返回确认。
如果任何参与者在canCommit阶段打了"不可以",协调者就提前中止,此时什么都没发生,代价最小。
3.3 超时机制:3PC降低阻塞的关键设计
3PC里有两个超时策略值得记下来。第一个:参与者收到preCommit指令后,如果等待doCommit指令超时,默认继续提交事务。逻辑是:既然已经过了canCommit集体评估,说明事务基本是被认可的,这时候选择提交的胜率更高。第二个:参与者在canCommit阶段等待协调者指令超时,则默认中止事务,因为什么都没做,防止资源被悬空占用。
这两个超时策略让3PC理论上比2PC更快摆脱"悬挂状态"。但也必须承认,超时策略依赖一个概率假设——"到了这个阶段,绝大多数情况下事务最终会被提交"。这个假设在正常网络下成立,在真正的网络分区场景下可能反噬。
3.4 为什么3PC救不了网络分区:doCommit消息丢失的致命场景
3PC最怕的场景是:协调者发出doCommit指令,但网络分区导致某一部分参与者收到了doCommit并提交,另一部分参与者没收到doCommit,在超时后也根据"preCommit后默认提交"的规则提交了事务。此时所有参与者都会提交,如果协调者在发出doCommit前宕机了,所有参与者在超时后仍然会提交,但原本事务可能已经被某个参与者中途失败了——这种偏差没法靠超时消除。
正因为3PC引入了参与者的"自行猜测",它反而可能制造出2PC不会出现的"超前提交"。2PC至少能保证:只要协调者活着,所有参与者要么全部提交要么全部回滚;而3PC在协调者倒下的情况下,只能靠概率正确。所以很多工程团队最终没有选择3PC,原因不是"3+1个阶段比2个阶段多一个",而是它的正确性依赖网络条件和超时假设,维护成本高,收益不明确。
4. TCC的Try-Confirm-Cancel语义:把事务写进业务代码里
如果说2PC和3PC还站在数据库协议层解决问题,TCC(Try-Confirm-Cancel)则直接把事务逻辑搬到了应用层。这一跳看起来简单,实际是把"如何处理不确定性"的答案从"节点间同步协调"变成了"业务操作自带逆操作"。这也是为什么TCC今天能在微服务体系里活得比2PC、3PC都好。
4.1 设计哲学:不锁资源,只锁业务意图
TCC的核心思想是:每个业务操作都拆成"预留资源—确认执行业务—取消预留资源"三个动作。它不依赖数据库的分布式锁,而是靠业务逻辑设计的预留机制控制资源。
举个例子。扣库存场景下,TCC的Try阶段不是直接扣减库存,而是把库存从"可用库存"冻结成"预占库存";Confirm阶段才真正扣减预占库存,把预占库存清零;Cancel阶段把预占库存归还给可用库存。如果一开始就直接扣库存,后面发现支付失败想恢复,就得做反向加库存操作,而反向操作在并发下很容易覆盖其他订单的库存变化,风险极高。TCC通过"冻结"这个中间态隔离了并发干扰,这是它和普通补偿事务的本质区别。
4.2 一个完整的TCC转账案例
假设有A、B两个账户在不同库中,需求是从A转100元给B。用TCC需要三个动作:
- Try阶段:A账户冻结100元,B账户增加100元"入账额度",但都不算真正余额变化。
- Confirm阶段:把A账户冻结的100元真正扣掉,B账户把已入账额度转为实际余额。
- Cancel阶段:A账户解冻100元,B账户撤销入账额度,双方恢复到Try之前的状态。
关键在于:Try阶段的两个本地操作必须能在同一个本地事务里完成,保证"A冻结+ B挂账"要么都成功、要么都失败。否则就会变成A冻了但B没挂账,后续没法一致处理。
用伪代码表示,TCC接口大概是这样的:
public interface TccAction<T> { boolean tryPhase(T context); // 预留业务资源 boolean confirmPhase(T context); // 实际执行业务 boolean cancelPhase(T context); // 释放预留资源 }每个参与事务的服务都要实现这三个方法,协调者或框架层负责调度:先调用所有参与者的tryPhase,全部成功则依次调用confirmPhase;任何一个失败则对所有已成功的调用cancelPhase。
4.3 TCC与XA(2PC标准实现)的关键差异
很多同学把TCC理解为"应用层的XA",其实不对。两者的关键差异有三个:
| 对比维度 | XA/2PC实现 | TCC |
|---|---|---|
| 事务粒度 | 数据库资源,锁表/锁行 | 业务资源,冻结/释放 |
| 对业务侵入 | 低,无需改业务逻辑 | 高,每个接口写三套动作 |
| 资源锁定方式 | 数据库锁,同步阻塞 | 预留资源,最终还是靠业务校验 |
| 可扩展性 | 依赖数据库能力,跨中间件困难 | 跨服务可靠,符合微服务环境 |
TCC最大的价值是"不过早锁定数据库资源",用冻结状态替代锁,让高并发环境下其他事务还能继续操作其他行,系统吞吐量自然比2PC高。
4.4 TCC落地时的框架选择
很多团队不会从零写TCC框架,而是基于成熟中间件减负。国内用得比较多的Seata AT模式实现了"自动建UndoLog + 全局锁"的近似TCC;Seata TCC模式则允许业务自己定义Try/Confirm/Cancel逻辑。如果不想引入太重的基础设施,自己实现TCC框架也完全可行,但下面这节要讲的三个坑,绕不开,也跳过会出事。
5. TCC落地的三大经典坑:空回滚、悬挂、幂等
TCC看起来思路清晰,但真正写生产代码时,三个问题是必然要面对的:空回滚、悬挂、幂等。这三个问题不解决,TCC在异常场景下会出大问题。
5.1 空回滚:Try没执行却被Cancel,怎么防
所谓空回滚,是指协调者调用了某个参与者的cancelPhase,但该参与者根本没有收到过tryPhase请求。最常见的原因是Try请求在网络上延迟了,协调者等不到回复就判定失败,触发了Cancel;而Cancel都跑完了,那个迟到的Try才到达参与者。
如果cancelPhase不检查任何状态,直接把本来就没冻的资源释放一遍,从结果上看业务数据可能没大问题,但日志会非常难看,更重要的是,cancelPhase里的逆向操作可能会误扣其他合法操作的数据。解决办法是给每个事务分配唯一的transactionId,参与者在记录冻结状态时必须记录这个ID,cancelPhase先按ID查状态,发现"该事务从未冻结过"就直接返回成功,不执行逆向操作。
5.2 悬挂:Try晚到,Cancel已经执行完
悬挂和空回滚是一对孪生坑。空回滚说的是"Try还没到,Cancel先执行了",悬挂则是"Try在Cancel执行完之后才到"。此时Try再执行冻结操作,就会留下一个永远不会被释放的冻结资源——因为Cancel已经结束,后续没人会再调用Cancel。
解决悬挂的思路是"后到Try拒绝执行"。参与者需要记录每个事务ID的执行状态,如果一个事务ID已经执行过Cancel,后续再收到该事务ID的Try请求时,直接拒绝或者忽略。这一步实现起来很讲究,因为要求状态记录具备"先于Try/CanCel操作落库"的约束,常见做法是把执行状态和业务操作放在同一个本地事务里提交,确保"我拒绝了"这个结论本身不会丢。
5.3 幂等:Confirm和Cancel被重复调用时怎么办
网络重发在分布式系统里是家常便饭。协调者调用confirmPhase时如果网络超时,通常会重试,同一笔事务的Confirm可能到达两次。如果confirmPhase把账户余额重复加两次,那不用等用户投诉,对账系统先报警了。
处理方式有三层,我建议按顺序做:
- 数据库层面:给事务操作记录表加唯一约束,用transactionId+actionType作为唯一键,重复插入直接失败。
- 状态机层面:把事务状态字段从初始态流转到终态后,再次收到相同Confirm请求时直接返回成功。
- 业务操作设计层面:把Confirm/Cancel设计成"天然只对最终结果产生一次影响"的操作,比如冻结表的金额字段用"目标值"覆盖而非"累加减"。
前两层覆盖大部分重复场景,第三层属于工程技巧,能把防御做在前面。实际线上我见到不少团队只做了状态判断,但从没想过用覆盖模式设计业务操作,结果状态判断有漏洞时就出数据事故。
5.4 TCC状态机参考设计
我把一套能落地的TCC事务状态机画出来供参考,字段一般包括:transactionId、actionType(Try/Confirm/Cancel)、status(init/committed/cancelled/finished)、createTime、updateTime。
- 收到Try时,先检查transactonId是否已存在,若存在且状态为cancelled,拒绝本次Try(防悬挂);否则插入新记录或更新状态。
- Try执行成功后,状态置为tried。
- 收到Confirm时,先检查当前状态,若是tried则执行业务提交并置为committed;若是committed说明重复调用,直接返回成功。
- 收到Cancel时,先检查当前状态,若是init或不存在则该事务可能发生空回滚,按空回滚逻辑处理;若存在且状态是cancelled,直接返回成功;若状态是tried,执行业务回滚并置为cancelled。
这个状态机不复杂,但它把所有异常情况收拢到了可控路径里,真出了问题也可以根据状态日志快速定位是哪个分支跳错了。
6. 选型分水岭:2PC、3PC、TCC、Saga在不同场景下该怎么判断
整理完原理,回到工程选型。热搜词里提到"2pc 3pc tcc saga 区别",所以我顺带把Saga也拉进来一起对比。这四个方案本质上不是参数差异,而是对"失败后怎么办"的哲学差异:2PC和3PC追求"全部成功或全部失败"的强一致性,TCC追求"资源预留+业务补偿"的最终一致性,Saga则接受中间状态,通过反向动作逐步修正。
6.1 订单与库存的TCC落地实例
最常见的业务案例莫过于订单和库存。下单时如果让订单库直接插入订单、库存库直接扣减库存,一旦后续支付失败,库存就莫名少了。用TCC改造后,下单动作变成:
- 订单服务尝试创建订单,但订单状态先置为"待确认"(预留订单号资源)。
- 库存服务冻结对应库存数量(可用库存减少,冻结库存增加)。
- 支付成功回调后,两个服务分别确认:订单状态改为"已支付",冻结库存变为已扣减。
- 支付超时或取消,则两个服务分别Cancel:订单标记"已取消",冻结库存释放回可用库存。
这套流程的价值在于,任何一步失败,业务都处于明确可控的中间态,而不是"不知道到底扣没扣"。实际做的时候要特别注意:确认和取消必须串行,一个事务不能同时既确认又取消。
6.2 四套方案的多维度对比表
| 比较项 | 2PC | 3PC | TCC | Saga |
|---|---|---|---|---|
| 一致性形态 | 强一致 | 基本强一致(有窗口) | 最终一致 | 最终一致 |
| 资源锁定 | 数据库行锁,阻塞时间长 | 同上,但超时可解锁 | 业务冻结资源,控制灵活 | 不锁资源,执行正向操作 |
| 实现难度 | 低,数据库原生支持 | 中,自行实现复杂 | 高,每个接口三套动作 | 中,需设计反向动作 |
| 适用场景 | 短事务、数据库同类 | 极少直接使用 | 高并发、对一致性要求高的核心链路 | 长流程、弱一致性要求的异步链路 |
| 风险点 | 协调者单点和阻塞 | 网络分区下的过度提交 | 空回滚、悬挂、幂等坑 | 反向动作不一定能完美抵消正向后果 |
6.3 我的判断逻辑:先问业务接受什么"不一致"
比较完原理我谈谈自己的选型习惯。每次动分布式事务方案之前,我都先问两个问题:
第一个问题,这个操作能不能容忍最终一致?如果能容忍,比如订单状态通知、积分累计这类场景,优先Saga,因为它对业务侵入相对小,性能损耗低。如果不能容忍银行转账这类强依赖,就需要升级成本。
第二个问题,业务能不能承受长期锁资源?如果单笔事务执行时间短,而且涉及的数据库实例少,直接用2PC(XA)最省事,数据库本身帮你处理了大部分细节。如果事务耗时较长,或者并发量很高,2PC的锁会成为系统吞吐的瓶颈,这时候转向TCC更明智,虽然开发和维护成本高,但换取的是并发度和灵活度。
至于3PC,说实话,我之前在项目里几乎没见过它在生产环境被大量使用。它更多是理论教学里的关键一环,帮助理解"超时决策"和"预判"这两个概念。工程上,大家会用Paxos/Raft这类共识算法去解决协调者高可用问题,而不是靠3PC的参与者超时猜测来兜底。
6.4 关于TCC和Saga互补使用的一点经验
成熟的核心链路里,TCC和Saga其实可以共存。比如"下单减库存"这种敏感操作可以用TCC控制,保证高并发下资源冻结有序;而"订单完成后的积分赠送"这种非关键操作,用Saga反向补偿就够。不必让全链路统一用一种方案,关键是每个子链路都定义清楚自己的不一致容忍度和补偿手段。
还有一个容易忽略的点:不管选哪种方案,都要为事务日志建立独立的监控大盘。分布式事务框架最大的隐患不是协议本身,而是出了问题你不知道到底卡在哪个参与者的哪个阶段。事务状态机的日志如果没打全,排障全靠猜,那这套方案再漂亮也白搭。
7. 个人实操体会收尾
做分布式事务这几年,我总结下来就一句话:架构上没有免费午餐。2PC、3PC、TCC、Saga,每一套方案都在"一致性、可用性、性能、复杂度"里做取舍。2PC最直观,但阻塞让人心疼;3PC理论很美,但不确定性没被彻底消灭;TCC最灵活,但要求业务方有很高的自律性,把Try/Confirm/Cancel三个动作每个都写严谨;Saga最轻量,但反向补偿操作要真正覆盖正向操作的所有影响。
我特别喜欢一个细节:真正的TCC工程实践里,Cancel动作的设计难度永远高于Confirm。很多人写代码时会习惯性先把"成功路径"写顺,后来才补Cancel,结果Cancel全是"取巧版"——要么没考虑空回滚,要么没考虑幂等。所以我建议任何团队引入TCC之前,先组织一次"失败演练":故意让Try超时、让Confirm重发、让Cancel先于Try到达,把状态机的每个分支都跑一遍。这些演练能帮你发现那些平时用不到但一旦用到就会出大事的边界分支。
最后分享一个小技巧:在TCC框架的日志里,每次状态变更都要记录"事务ID + 当前状态 + 触发事件 + 传入上下文",并且打清楚"这次变更由哪个阶段、哪个节点触发"。线上排查分布式事务问题时,这些日志比任何监控面板都管用。分布式事务的方案选择没有标准答案,但有了清晰的日志和状态机,出了问题至少能站稳脚跟。