1. 分布式事务的根源与核心矛盾
先聊一个非常典型的业务场景。你在电商平台下单,订单系统要写一条订单记录,库存系统要扣减一件商品库存。如果两个操作都成功,买卖双方皆大欢喜;如果订单写了但库存扣减失败,用户会发现钱付了却发不了货;如果库存扣了但订单没生成,那更麻烦,货没了但订单不存在,对账的时候谁都说不清。这其实就是最典型的分布式事务问题——一个业务操作横跨多个独立的数据源,而我们又希望这个操作具备原子性:要么全部成功,要么全部失败。
要理解分布式事务为什么难,得先从单机数据库的事务说起。在单库环境下,我们用 ACID 四个特性就能把事务管得明明白白,原子性由 undo log 保证,一致性由约束和业务逻辑保证,隔离性靠锁和 MVCC,持久性靠 redo log。这些机制高度依赖一个前提:所有数据都在同一个数据库实例里,可以通过本地事务管理器统一协调。但一旦服务拆分、数据库分库,订单数据在订单库,库存数据在库存库,原本由数据库底层承担的协调能力就失效了,因为没有任何一个数据库能看到全局状态。
这时候就出现了分布式事务的需求本质:如何在多个独立节点之间达成一个原子性的结果约定。这里有个绕不开的理论基石,就是 CAP 理论。分布式系统在一致性、可用性、分区容错性三者之间只能取其二。分布式事务本质上是在分区容错的前提下,追求强一致性或最终一致性,而代价就是或多或少的可用性损失。所有分布式事务方案的差异,核心就在于"在一致性上较真到什么程度"以及"为了这个程度愿意牺牲多少可用性"。
理解了这道根源,后面的 2PC、3PC、TCC 就都有了坐标。它们不是凭空出现的三个技术名词,而是工程师在面对"跨节点原子性"这一问题时,一步步演化出来的三套思路:2PC 是朴素可靠的仲裁者思路,3PC 是试图降低 2PC 阻塞风险的改良思路,TCC 则是从业务侧主动补偿的思路。这三者各有各的适用土壤,也有各自的致命短板。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 两阶段提交(2PC)的核心实现原理
2.1 2PC 的参与者与协调者模型
两阶段提交的英文是 Two-Phase Commit,通常简写为 2PC。它是分布式事务领域历史最悠久、也是最基础的一种方案,几乎所有分布式数据库的底层事务一致性都借鉴了它的思想。2PC 的参与者模型非常清晰:由一个全局的协调者(Coordinator)和多个参与者(Participant)组成。协调者负责发起事务、收集各参与者的意见、做出最终决策;参与者负责执行本地事务,并向协调者上报自己的执行结果。
这个过程很像一个团队里有个项目经理在推动一项跨部门合作。项目经理先给每个部门发通知,问"这个需求你们能不能按期交付",各部门评估后回话"能"或"不能"。如果所有人都说能,项目经理就正式下达开工指令;只要有一个人说不能,项目经理就叫停整个计划。2PC 的核心逻辑就是这个——先把所有节点拉到同一条船上,再决定是开船还是散伙。
2PC 之所以叫"两阶段",是因为整个事务生命周期被严格切成了两个阶段。第一阶段叫准备阶段(Prepare Phase),第二阶段叫提交阶段(Commit Phase)。这两个阶段之间,协调者扮演着绝对核心的裁决角色,任何一个参与者出现了异常,协调者都能根据收集到的投票结果做出全局决策。
2.2 第一阶段:准备阶段(Voting Phase)
准备阶段的任务只有一个:协调者向所有参与者发送 prepare 请求,询问它们是否准备好提交本地事务。每个参与者在收到 prepare 请求后,需要执行本地事务的写操作,把数据修改写入本地事务日志,并将事务状态置为"就绪"(Ready),但此时并不会真正提交事务,也不会释放事务占用的资源(比如行锁、表锁)。
参与者完成上述操作后,需要向协调者回复两种结果之一:如果本地事务执行顺利,回复"同意"(Yes);如果本地事务执行过程中出现任何异常,比如约束冲突、死锁超时、磁盘空间不足,则回复"中止"(No)。这里有一个容易被忽略的细节:参与者一旦回复 Yes,它就不再拥有"反悔"的权利。数据已经写入了本地日志,事务已经处于"可提交"状态,只等协调者一声令下,就必须如实提交。
这个阶段是整个 2PC 流程中最耗时的环节,因为所有参与者要真正执行一次业务写操作,但不提交。锁的持有时间因此被拉长,这是 2PC 性能瓶颈的主要来源。我在实际项目中见过不少这样的场景:一个分布式事务里挂了三个参与者,其中一个是相对慢的报表库,最慢的节点耗时 800ms,那整个事务的 prepare 阶段就得等完 800ms,其他节点再快也得陪跑。
2.3 第二阶段:提交阶段(Commit Phase)
进入第二阶段的前提是协调者已经集齐了所有参与者的投票。此时只有两种可能。
第一种情况:所有参与者都回复了 Yes。协调者判断全局事务可以提交,于是向所有参与者广播 commit 请求。每个参与者在收到 commit 请求后,执行本地事务的正式提交操作,释放事务占用的锁和资源,然后向协调者返回确认消息。协调者在收到所有参与者的确认后,本次分布式事务就算圆满结束了。
第二种情况:只要有一个参与者回复了 No,或者某个参与者在第一阶段超时未响应,协调者就会判定全局事务失败,向所有参与者广播 abort 请求。每个参与者收到 abort 后,回滚本地事务,释放资源,同样需要向协调者确认。
这里的关键点是,2PC 的提交阶段不能做部分提交。哪怕只有一台机器回复了 No,其他所有机器已经写好的数据也要一并回滚。这种"一刀切"的风格保证了全局原子性,但代价就是参与者在状态不确定期间只能一直持有资源、阻塞等待。
2.4 2PC 的三个经典痛点:同步阻塞、单点故障、脑裂
2PC 的原理看起来很干净,但一旦落进真实生产环境,它的三个先天缺陷就会被迅速放大。
第一个痛点是同步阻塞。在 prepare 阶段,参与者写入了事务数据但未提交,此时它持有的行锁或表锁不会释放。所有依赖这些数据的其他事务,不管是不是这个分布式事务的一部分,都得排队等待。如果某个参与者响应极慢,整个数据库的并发能力都会被拉低。这种阻塞特性在高并发场景里几乎是致命的,所以 2PC 只适合低并发、强一致要求的场景。
第二个痛点是协调者单点故障。协调者在整个事务流程中处于绝对核心地位,一旦协调者在发送 commit 请求后宕机,所有参与者都会处于"事务状态未知"的尴尬境地——本地数据已经写入、事务未提交、资源被持有,但它们不知道自己该提交还是该回滚。这种状态只能等协调者恢复后继续处理,在协调者恢复期间,整个分布式事务链路是卡死的。
第三个痛点是脑裂问题。假设协调者向参与者 A 发送了 commit 请求,但在向参与者 B 发送 commit 请求前宕机了。结果是 A 提交了事务,B 没有收到指令,仍然停留在就绪状态。当协调者恢复后,它自己也不知道事务的最终状态,需要通过日志追溯才能勉强恢复现场。如果此时网络分区导致协调者和部分参与者无法通信,整个集群的决策一致性就彻底失去保障了。
这三个痛点决定了 2PC 在真实业务中的适用范围极其有限。纯粹的 2PC 实现(比如 XA 协议)在单机数据库集群中还有一些用武之地,但在微服务这种跨系统、跨团队的架构里,很难大规模落地。因为微服务环境里网络抖动、节点宕机、服务限流几乎每天都在发生,任何一个小概率事件都会把 2PC 的缺陷放大成线上故障。
3. 三阶段提交(3PC)的改进思路与局限
3.1 从 2PC 到 3PC:改了什么
3PC(Three-Phase Commit,三阶段提交)是 2PC 的改良版,目标非常明确:解决 2PC 中参与者在提交阶段长时间阻塞、以及协调者故障时参与者无所适从的问题。3PC 的改进思路可以概括为两句话:把 2PC 的提交阶段拆成两个阶段,并在参与者和协调者两侧都引入超时机制。
具体来说,3PC 把整个事务生命周期划分为三个环节:CanCommit(询问阶段)、PreCommit(预提交阶段)、DoCommit(提交阶段)。相比 2PC,多出来的是第一阶段的 CanCommit——在真正执行写操作之前,先问所有参与者一句"这个事务你们能不能接得住"。这一步的价值在于,如果一个参与者因为资源不足、依赖不可用等原因根本无法执行事务,协调者可以提前知道,而不必像 2PC 那样硬着头皮先执行 prepare。
3.2 三个阶段分别做了什么
CanCommit 阶段:协调者向所有参与者发送 canCommit 请求,询问节点是否有能力完成这个事务。参与者此时不需要执行具体的业务写操作,只需要根据自己的资源和状态给出"能"或"不能"的答复。这一步很像团队开会前先问一句"大家今天有没有空",能大概率避免后面空跑一轮。
如果所有参与者都回复"能",协调者进入 PreCommit 阶段——发送 preCommit 请求,要求参与者执行本地写操作,将事务数据写入日志,但不提交。这个阶段对应 2PC 的 prepare 阶段,只不过负载更可控了,因为前面已经排除掉一批接不住任务的节点。
PreCommit 完成后,协调者向所有参与者发送 doCommit 请求,参与者收到后正式提交本地事务。这里有一个 3PC 相对 2PC 的关键改进:如果参与者在等待 doCommit 指令期间发生了超时,它不会像 2PC 那样傻等,而是会根据自身判断主动提交。这种机制是为了避免协调者故障导致整个链路永久卡死。
3.3 3PC 的实际落地:为什么反而不如 2PC 普及
理论上 3PC 在可用性上比 2PC 更友好,但你在实际项目中几乎见不到企业大规模使用纯 3PC 方案。原因主要有两个。
第一,3PC 没有真正干掉"脑裂"问题,只是缩小了概率。当网络分区发生时,一个参与者收到了 doCommit 请求并提交了事务,但另一个参与者和协调者失联,超时后盲目提交,两个节点的事务决策就可能不一致。3PC 通过超时机制降低了阻塞风险,却付出了"可能在异常场景下违反原子性"的代价。这个代价在强一致场景里是不可接受的。
第二,3PC 多了一个交互轮次,网络开销和延迟成本变高了。分布式事务本来就是对性能的消耗大户,3PC 在 2PC 的基础上增加了一轮询问,等于把事务时延又拉长了一截。在追求低延迟的业务场景里,这是非常吃亏的。
业界对 3PC 的普遍定位是"理论价值大于工程价值"。它提出的超时和预判思路给了后来者很多启发,但真要落地时,工程师通常会用别的方案(比如 TCC、本地消息表)来兼顾一致性和可用性,而不是直接套用 3PC。
4. TCC 的核心理念与实战要点
4.1 TCC 与 2PC 的本质区别:从"资源锁定"到"业务补偿"
TCC 是 Try-Confirm-Cancel 的缩写,和 2PC/3PC 有本质区别。2PC/3PC 走的都是"数据库资源锁定"路线,事务管理器锁定资源,协调者统一决策提交或回滚;而 TCC 走的是"业务补偿"路线,每个参与者要自己实现三个业务方法,框架只负责按套路调用,业务逻辑自己控制一致性。
这两条路线的差异打个比方就很好理解。2PC 像请客吃饭时先把菜全部点好并且让后厨开始备菜(加锁),吃完再说这顿算谁的;TCC 则像事前先分别确认每个人的口味、预算、时间(Try),确认没问题再一起下单(Confirm),只要任何一个人临时有事,就各自散伙、互不追究(Cancel)。2PC 靠"锁住资源等别人"来保证一致,TCC 靠"业务上把每一步都想清楚"来保证可回退。
从工程角度看,TCC 最重要的特点是"性能好、侵入性强"。因为没有全局锁,每个分支事务可以独立提交、释放资源,所以吞吐量远高于 2PC。但代价就是业务方要为一个分布式事务编写三套方法,并且要把幂等、空回滚、悬挂这些边界问题全部处理干净。
4.2 Try、Confirm、Cancel 三个阶段的职责解构
Try 阶段是整个 TCC 事务的检查与预留阶段。拿最经典的"订单+库存"场景来说,订单服务要创建一个状态为"待确认"的订单记录,库存服务要把对应商品的库存数量预留出来(比如冻结 N 件库存,而不是直接扣减 N 件)。Try 阶段的重点是"检查资源够不够"和"把资源预占住",但不要真正完成业务变更。如果任意一个分支的 Try 失败,全局事务就会被判失败,已经执行过 Try 的分支需要执行 Cancel 来回滚。
Confirm 阶段是真正提交业务结果的阶段。当协调者(在 TCC 框架里通常叫事务管理器)收到所有分支 Try 成功的消息后,会向每个分支发送 Confirm 请求。此时,订单服务把订单状态更新为"已创建",库存服务把预占的库存真实扣减掉。Confirm 阶段的执行结果必须是确定的——要么成功,要么快速失败后通过重试达成成功,因为此时全局事务已经决定要提交了,任何分支都不能再回滚。
Cancel 阶段是补偿阶段,用于释放 Try 阶段预占的资源。比如订单服务把"待确认"订单置为"已取消",库存服务把冻结库存释放回可用库存。Cancel 会在两种情况下触发:一是某个分支 Try 失败,全局事务决定回滚;二是 Confirm 阶段某个分支执行失败,事务管理器启动逆向补偿。
4.3 TCC 的三大经典难题:空回滚、幂等、悬挂
这三大难题是任何做 TCC 的团队都绕不过去的坎,处理不好就是线上事故。
空回滚指的是一个分支在 Try 方法还没执行的情况下,直接收到了 Cancel 请求。什么情况下会发生这种事?比如 Try 请求因为网络延迟迟迟没有到达下游,但上游已经因为其他分支失败而触发了全局回滚,于是 Cancel 请求先到了。如果 Cancel 方法直接按"有预留就释放、没预留就报错"来处理,就会因为找不到可回滚的数据而出错。解决办法是在业务表里增加一个事务状态字段,每次操作前先查询状态:如果状态为空,说明 Try 还没执行,Cancel 直接返回成功并标记状态为"已回滚";如果状态是"已提交",Cancel 需要幂等处理。
幂等问题在 Confirm 和 Cancel 中都非常常见。分布式环境下,框架的重试机制可能导致同一个分支收到多次 Confirm 或 Cancel 请求。如果 Confirm 方法不处理幂等,就可能出现"同一笔订单被重复创建"或"同一批库存被重复扣减"的情况。通用的解法是借助唯一事务 ID 建立去重表或状态机,每次执行前先查一次状态,已经成功执行过就直接返回成功。
悬挂问题比空回滚更隐蔽。假设 Try 请求发出后,因为网络问题延迟了很久,而 Cancel 已经在超时后执行完毕了。此时 Try 请求才姗姗来迟,如果它继续执行业务预占,就会导致一条已经被取消的事务重新产生资源预留,但这些预留又永远不会被 Confirm 或再次 Cancel 处理——这就是悬挂。解决办法是在 Try 阶段执行前检查状态,如果发现状态已经被标记为"已回滚",就直接拒绝执行 Try 并返回成功。
这三个问题本质上都能通过对业务表增加状态字段、以状态机驱动分支流转来解决,所以我在做 TCC 方案设计时,通常会把状态机字段的设计放在最前面,而不是先写业务方法。状态机设计好了,后面三个问题会自然消解大半。
4.4 TCC 落地时的防悬挂、防幂等设计思路
这里给一个可以直接参考的状态机设计雏形。业务分支表至少需要四个字段:事务 ID、分支状态、重试次数、最后更新时间。分支状态用一个整数表示,0 表示初始未处理,1 表示 Try 成功,2 表示 Confirm 成功,3 表示 Cancel 成功,-1 表示悬挂拒绝。
Try 方法的执行逻辑是:先检查事务 ID 是否已经存在且状态为 3(已回滚),如果是,直接返回成功并写入一条"悬挂拒绝"日志,绝不继续执行;否则执行预占操作,并把状态置为 1。
Confirm 方法的执行逻辑是:先检查状态,如果状态是 1,执行正式提交,把状态改为 2;如果状态已经是 2,说明是重试,直接返回成功;如果状态是 3,说明事务已经被 Cancel,此时绝对不能执行 Confirm,直接抛异常告警。
Cancel 方法的执行逻辑是:如果状态是 0,说明 Try 尚未执行,属于空回滚,直接标记状态为 3 并返回成功;如果状态是 1,执行释放资源,把状态改为 3;如果状态已经是 3,说明是重试,直接返回成功。
这套状态机的精妙之处在于,每个分支在任何状态下收到任何合法请求,都能给出确定的、安全的响应,不会产生中间态。实际落地时,配合数据库的唯一索引(事务 ID 加分支 ID)作为幂等约束,基本就能把 TCC 的三大难题全部兜住。
5. 三种方案的选型对比与生产实践建议
5.1 2PC、3PC、TCC 的核心维度对比
三种方案放在一张表里对照,各自的取舍会非常清晰。
| 对比维度 | 2PC | 3PC | TCC |
|---|---|---|---|
| 一致性强度 | 强一致 | 强一致(但异常场景可能被打破) | 最终一致 |
| 资源占用 | 高,事务期间持有锁 | 中,超时机制降低阻塞 | 低,Try 后即可释放 |
| 吞吐能力 | 低 | 中 | 高 |
| 业务侵入性 | 低,对业务方透明 | 低 | 高,需实现三类方法 |
| 实现复杂度 | 中 | 中高 | 高(需处理空回滚、幂等、悬挂) |
| 适用场景 | 单库或多个数据库副本间的强一致 | 理论方案,实际落地少 | 高并发微服务、跨系统业务 |
从表里可以看出来,2PC 追求的是最严格的一致性,代价是性能和可用性;TCC 把一致性降级为最终一致,换来了高吞吐和业务灵活性。这没有绝对的好与坏,关键是场景匹配。
5.2 什么时候选 2PC,什么时候选 TCC
根据我在生产环境里的经验,选型可以遵循几条非常直接的规则。
如果你的分布式事务边界集中在同质的数据库节点之间,比如数据在两个 MySQL 实例之间同步,节点数量少、网络环境稳定,2PC(XA 协议)依然是最省事的选择。因为你不需要改业务代码,数据库本身就把事务管好了。不少银行、支付系统的账务类操作,至今还在用 XA 风格的事务,因为这种场景对一致性的要求高于一切,性能可以靠基础设施扩容来补。
如果你的场景是典型的微服务调用链,订单服务、库存服务、支付服务各自独立部署,并且接口响应时间要求高、并发量大,那 TCC 几乎是最主流的选择。这种方式牺牲了强一致,但换来了业务粒度可控的最终一致性,同时把资源消耗降到了最低。很多互联网电商的订单流程就是基于 TCC 改造的。
3PC 我个人的建议是:可以了解原理,不要轻易落地。它的超时改进理论上有价值,但在复杂网络环境下反而引入了新的不一致窗口,远不如 TCC 这种主动补偿的思路可控。如果你恰好看到某个中间件宣称自己用了 3PC,也要谨慎评估它在脑裂场景下的真实表现。
5.3 事务中间件选型与团队协作建议
当前业界主流的分布式事务中间件,像 Seata,就同时提供了 AT 模式、TCC 模式、Saga 模式等多种方案。很多团队容易在这里犯一个错误:把 Seata 的 AT 模式当成 2PC 来理解,但 AT 模式本质上更像"自动化的补偿事务"——它通过全局锁和数据快照模拟了 2PC 的效果,但实现上用的是反向 SQL 做回滚。这种模式对业务方友好,但也有性能开销和全局锁冲突的问题,选型时同样需要根据压测数据来决定。
在实际落地过程中,我还有一个比较重要的经验:分布式事务方案选完之后,一定要在团队内部做一次统一约定,明确哪些操作走 TCC、哪些操作可以退化为本地消息表、哪些操作只做最终对账。不可能全公司上下所有跨库操作都套同一个分布式事务模板,得有一定的分层策略。比如金融对账类的数据必须强一致,走 TCC;日志、积分这类允许延迟一致的数据,用本地消息表就足够了。
最后再分享一个踩坑心得。TCC 的 Try、Confirm、Cancel 三个方法一定要单独配置超时时间和重试次数,不能复用常规接口的配置。我之前有一个项目,Confirm 方法的重试次数设得太大,加上下游接口响应慢,结果大量线程阻塞在重试上,直接把连接池打满了。后来把超时缩到 500ms,重试次数限制在 2 次,并把超过重试上限的请求落库做定时巡检,问题才彻底解决。分布式事务的坑,往往不在原理本身,而在细节参数上。
