ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

分布式事务详解:从2PC到3PC的阻塞、脑裂与工程取舍

2026/9/11 21:02:22 拓冰建站 浏览量
分布式事务详解:从2PC到3PC的阻塞、脑裂与工程取舍 做分布式系统这几年几乎每次聊到跨库事务最后都会绕回到“两阶段提交”2PC和“三阶段提交”3PC这两个词上。我印象最深的是有一回我们负责的一个支付服务在凌晨突然大面积超时数据库里积压了一堆“正在准备中”的事务后面所有订单全部卡死。排查到最后发现就是一条XA分布式事务卡在了两阶段提交的中间态协调者进程挂了参与者的资源锁却一直不释放。那次之后我把2PC和3PC的协议细节、故障场景、工程取舍反反复复重新捋了好几遍今天把这些内容完整写出来希望对刚开始接触分布式事务的同学以及正在为系统选型而纠结的朋友都有点帮助。1. 为什么会有两阶段提交单库事务的“ACID”跨机器后就撑不住了1.1 一个跨行转账的经典困境先回到最基础的场景。你在一个库里有A和B两张表要执行“A扣100B加100”数据库本地事务可以轻松保证两条记录要么同时生效、要么同时回滚。但一旦拆到两个库、两个服务比如订单服务在订单库库存服务在库存库A的数据在服务A的库B的数据在服务B的库情况就完全变了没有哪个单独的数据库能同时控制两边的提交和回滚。我常说这是“原子性”从单机扩展到多机时必然碰到的墙。本地事务靠数据库的redo log、undo log和锁来保证原子性但跨库时没有任何一个节点能独自决定“全部提交”或“全部回滚”。所以我们需要一个额外的机制让多个独立的数据库节点像一个整体一样对外呈现出原子提交的行为。这就是分布式事务协议要解决的核心问题。1.2 分布式事务要回答的核心问题这里最核心的问题其实只有两个第一所有参与者能否在“是否同意提交”上达成一致第二一旦达成一致能否保证每个参与者都严格按照这个决定执行到底。两阶段提交Two-Phase Commit和三阶段提交Three-Phase Commit都是围绕这两个问题设计的。它们的名字里的“阶段”指的是协调者与参与者之间进行消息交互的轮次。2PC分两轮一轮投票一轮提交/回滚3PC分三轮先确认是否能提交再预提交最后才真正提交。看起来只是多了一轮消息但设计哲学完全不同。不少人一开始会把2PC和3PC理解成“更高级的版本”好像3PC就是2PC的升级替代品。实际上它俩在面对故障时的取舍很不一样3PC并不是完美解决2PC所有问题的银弹后面我会详细拆解。2. 两阶段提交一个协调者说了算的方案以及它的资源锁死隐患2.1 投票准备阶段里到底发生了什么两阶段提交引入了一个角色叫协调者Coordinator所有参与事务的数据库节点叫参与者Participant。整个协议的第一步是投票阶段也叫准备阶段。协调者先向所有参与者发送prepare请求参与者收到后执行事务操作但先不提交而是把事务执行过程中产生的undo日志和redo日志都持久化到磁盘然后返回一个“我准备好了”的响应。如果某个参与者执行失败就返回“我搞不定”的响应。这一步的关键在于参与者虽然执行了SQL但事务并没有真正提交数据对其他事务仍然不可见而且相关资源被锁定。用大白话类比就是你组织一次团建先给几家餐厅群发消息问“我这边40个人周末晚上能不能安排”各家餐厅去查自己的排期和备菜回复“能”或者“不能”但都先不真正备菜。这个阶段就是让各方对“能不能做”表态。这里有一个很容易被忽略的技术点prepare不只是执行一句SQL还要保证日志落盘。因为参与者之后可能宕机一旦宕机重启它需要依靠日志来判断自己之前到底处于哪个状态。如果日志没持久化就回复“准备好了”后面宕机恢复后它可能完全不记得这回事事务的一致性就会出问题。2.2 提交阶段决定做出来了怎么让所有人遵守投票结束后协调者汇总所有参与者的响应。只有所有人都返回“准备好了”协调者才会进入提交阶段向所有参与者发送commit指令只要有一个参与者返回失败协调者就发送abort指令让所有参与者回滚。参与者收到commit后把事务真正提交释放资源锁然后返回ack收到abort则执行回滚释放资源锁也返回ack。协调者收到所有ack后整个分布式事务才算结束。这个设计看起来逻辑清晰万事俱备再下决定决定之后人人执行。但它隐藏了一个非常重要的前提假设协调者必须始终活着必须能正常给所有参与者发消息参与者也必须能正常收到并回复。一旦这个假设不成立问题就来了。2.3 最大隐患协调者宕机后参与者的锁就是不释放两阶段提交最被诟病的缺陷是同步阻塞。参与者一旦在prepare阶段表示了“准备好了”就会一直持有资源锁等待协调者的最终指令。如果协调者在这一刻宕机或者协调者和参与者之间的网络断了参与者无法收到commit还是abort的指令它就只能一直等下去。数据库连接不释放事务对应的行锁、表锁全部被占住后面依赖这些资源的操作全部排队。我举个例子你就能感受到这个问题的严重性一个订单服务在prepare阶段扣减了库存返回“准备好了”。结果协调者进程OOM崩了订单服务那边的库存锁就一直挂着。此后所有要操作这个库存SKU的请求全部堆积业务雪崩式超时。这时候你没法靠“杀掉参与者进程重新拉起”来恢复因为参与者重启后会发现一个“未决事务”in-doubt transaction它不知道当初这个事务到底该提交还是回滚只能等协调者来给最终裁定。协调者活不过来事务就永远卡着。所以2PC本质上是一个“集中式决策 全体阻塞等待”的协议。它强调一致性但在可用性上牺牲很大尤其是在协调者单点故障的时候。3. 线上事故复盘一次XA事务僵局从发生到解决的完整链路3.1 现象系统突然大面积超时那是一个不算特别高并发的凌晨值班群突然开始大量告警订单系统、库存系统、积分系统几乎同时出现超时。初始判断可能是数据库慢查询但去看数据库监控CPU、内存、磁盘IO都正常慢查询日志也没发现明显异常的SQL。奇怪的是有一个库存表上的大量更新操作处于等待状态等锁等待事件特别扎眼。我当时的直觉是有人在数据库里锁住了一行但一直没提交。于是查了information_schema下的innodb_trx和innodb_lock_waits果然有一个事务已经运行了好几个小时处于ACTIVE状态持有的行锁一直没有释放。3.2 排查过程一步步从超时日志找到“in-doubt transaction”进一步查这个事务对应的SQL和线程发现事务并不是来自我们自己的业务代码直接发起的更新而是通过XA事务的prepare语句启动的。顺藤摸瓜找到调用链定位到中间件层有一个分布式事务管理器它在发起了一个跨订单、库存、积分三个服务的分布式事务后自己挂了。按照XA协议的流程参与者已经执行完prepare回复了“准备好”但协调者在收到所有参与者的确认后、正要发送commit的时候进程宕机。因为没有协调者的后续指令三个数据库节点上的这个分布式事务全部停留在“未决”状态资源锁全部不释放。这就是2PC典型故障的完整链路。看到这个场景第一反应是不能直接重启协调者了事因为在恢复事务状态之前直接重启后它可能继续发commit也可能发abort而各个参与者还不知道之前发生了什么。需要一个明确的恢复策略。3.3 恢复手段启发式提交/回滚的风险当时我们采取了人工介入的方式逐个连接到三个数据库节点查询XA事务的状态根据业务语义判断这个订单到底应该继续提交还是回滚。判断完成后在参与者节点上执行XA RECOVER查看未决事务再根据结论手动执行XA COMMIT或者XA ROLLBACK。但这里一定要提醒各位手动介入意味着你在做“启发式决定”。所谓启发式提交/回滚就是在无法获得协调者权威指令的情况下由参与者和运维人员自行决定事务的最终走向。这种决定有可能和协调者原意不一致最极端的情况是某个参与者提交了、另一个参与者回滚了造成长期的业务数据不一致。所以每次做这种人工恢复后续都必须要有一轮完整的数据对账把差异找出来修掉不能觉得“我手动commit了就结束了”。这次事故之后我们做了一件事把所有的协调者节点由单机部署改为集群化部署并给协调者增加了独立的高可用选主机制同时对XA事务的存活时长做监控一旦出现超过N分钟未完成的未决事务立刻告警。不要小看这个监控2PC本身没有自愈能力能不能快速发现问题往往比预防问题更影响业务。4. 三阶段提交用多一轮沟通换超时自主权4.1 canCommit、preCommit、doCommit三个阶段各自的职责三阶段提交的设计初衷很直接解决2PC中参与者一直等待协调者指令导致无限阻塞的问题。它在2PC的投票和提交之间又加了一个阶段变成三轮canCommit阶段协调者先问所有参与者“你们能不能提交这个事务”。这个阶段不执行任何实际业务操作只是确认参与者是否具备提交条件比如事务是否合法、资源是否可用。preCommit阶段如果所有参与者都回答“能”协调者发送预提交请求。参与者收到后执行事务操作写日志锁定资源但还不提交然后返回确认。如果canCommit阶段有参与者表示不能提交协调者直接中止事务。doCommit阶段协调者收到所有参与者的预提交确认后发送最终提交指令。参与者收到doCommit后执行真正的提交。和2PC相比3PC多出来的核心变化是参与者在preCommit之后如果一直没有收到doCommit指令不会无限等待而是会启动一个超时机制。超时后参与者根据自己的状态自主决定绝大多数实现里参与者进入的是自动提交因为能够进入preCommit本身就说明所有节点都表态“可以提交”了。4.2 参与者超时后的自决机制是如何设计的从协议设计者的角度看3PC引入超时是为了避免2PC那种“协调者一挂参与者锁死到天荒地老”的局面。协调者故障是一种概率不低的事件网络分区也是3PC让参与者在超时后可以自行往前走至少释放了锁业务不至于一直堵死。为了支撑这个超时自决协议规定参与者在canCommit阶段回答“能”之后就进入一个“可提交”状态在preCommit阶段执行完操作并确认之后进入“预提交”状态。这两个状态的区别很重要如果只是在canCommit阶段回答了能超时后参与者可以安全地中止事务但如果已经进入preCommit阶段参与者已经执行了实际的业务操作并写了日志超时后根据协议它会倾向于提交因为中止一个已经准备提交的事务可能造成未知状态。所以说白了3PC是用“多一轮沟通”换取了“参与者在没有得到最终指令时有自主决策的能力”。它把事务推进过程中的决策权部分下放给了参与者而不是让参与者永远被动等待。4.3 三阶段提交对两阶段提交改善了哪些牺牲了什么好处是明显的协调者故障后的阻塞时间有了上限参与者可以利用超时机制自行解除资源锁系统的可用性比2PC好一些。但代价也很直接事务生命周期里多了一轮RTT延迟变高而由于多了canCommit阶段整个协议的交互次数增加实现复杂度也上去了。更关键的是3PC引入了参与者“超时自决”这个机制本身就是一把双刃剑。大多数教材里只讲“3PC解决了阻塞问题”没强调它带来了新的不一致窗口。我甚至见过不少同学把3PC描述成“既解决阻塞又保证一致性”的完美方案这其实是不对的。在极端网络场景下3PC仍然存在数据不一致的可能这就是下一章要讲的内容。5. 三阶段提交没有根治的脑裂问题一次极端故障推演5.1 doCommit发出后的分区场景假设一个分布式事务有参与者A、B、C三个节点。协调者发送doCommit但发送过程中网络发生了分区A和B收到了doCommit指令正常提交C所在的网络分区与协调者隔离始终没收到doCommit。C在preCommit阶段已经确认过此时它启动了超时计时。超时时间到达之后协调者依然没有传来任何消息C根据3PC的自决逻辑决定提交。从C的角度看这符合协议它已经进入“所有节点都说可以”的状态现在只是暂时联系不上协调者自动提交是合理选择。但问题来了如果协调者在发送doCommit之前其实已经收到了某个参与者的异常信号正准备发送abort指令只是这个abort恰好没来得及到达C呢这种情况下A和B可能因为收到commit而提交C因为超时也提交但如果协调者原本要发送的是abort而部分其他节点因为没进入preCommit状态超时中止最终就会有一部分节点提交、一部分节点中止数据不一致就出现了。5.2 为什么“参与者超时自动提交”反而可能造成数据不一致回到3PC的乐观假设它假设“当所有参与者都同意可以提交时全局提交的概率远大于中止”所以参与者超时后自动提交是合理的。但这个假设随着系统规模变大、网络复杂度变高会越来越脆弱。尤其在多参与者场景下协调者可能在某几个节点上发了commit还没发到其他节点就宕机了剩下的节点只能靠超时自决而超时自决依赖的是“自己在preCommit之后的状态”并不依赖于“其他人是否也收到了doCommit”。这天然就留下了脑裂窗口。我在面试候选人的时候经常用这个推演来区分一个人是真理解分布式事务还是只背了概念。背答案的人会告诉我“3PC解决了2PC的阻塞问题”但追问“协调者在doCommit前宕机且第三个节点超时自决后会发生什么”时很多人就答不上来了。实际情况是3PC把不一致窗口从“提交阶段”扩大到了“预提交之后任意时刻”它并没有也不可能在存在网络分区的情况下保证强一致因为与网络分区期间的决策有关的问题本质上和无中心共识的限制有关3PC不具备彻底解决它的前提。5.3 3PC在实际生产中的尴尬地位正因为这个脑裂问题纯3PC在实际工程里非常少见。你去翻主流数据库和中间件的实现很少看到谁直接用三阶段提交来管理分布式事务。大家更多是借鉴3PC的超时自决思想但很少把它当强一致方案用。有人可能会问那3PC是不是完全没用也不是。如果你的事务流程天然允许“最终能提交的概率很高且允许在极端情况下做数据对账”3PC的机制可以作为参考。比如某些业务场景先用类似canCommit的方式做预检查通过后再用事务消息或本地消息表推进这种模式在思路上是有3PC影子的。但如果你要的是严格的强一致3PC不是一个能让你高枕无忧的答案。6. 现代工程如何绕开2PC/3PCTCC、Saga与最终一致性6.1 TCC把钱和库存的控制权交给业务代码既然纯2PC会阻塞、3PC不完美现实中的分布式事务是怎么做的我自己的实践经验里第一次成功落地且不容易翻车的往往是TCCTry-Confirm-Cancel。TCC的本质是把“数据库资源锁”换成“业务资源预占”。拿下单场景来说Try阶段做资源预留——冻结库存、冻结账户资金Confirm阶段真正扣减Cancel阶段释放预留。整个过程里数据库事务的粒度很小每个本地操作都很快提交锁的持有时间极短不会出现2PC那种长时间挂锁导致全链路阻塞的问题。但TCC的代价是开发量大。你要为每个涉及分布式事务的服务写Try、Confirm、Cancel三套逻辑还要处理幂等、空回滚、悬挂等一系列边界问题。用我同事的话说TCC是“把一致性成本从中间件转移到了业务代码里”适合对一致性要求高、并发量也高的核心交易链路。6.2 Saga长事务的补偿式编排如果流程特别长比如一个业务要跨订单、支付、物流、积分等多个系统且中间有大量外部调用TCC会显得过重Saga更合适。Saga把一个长事务拆成一系列本地事务每个本地事务执行完后立即提交并记录一个对应的补偿操作。一旦某个步骤失败就反向执行之前所有步骤的补偿操作把数据恢复到业务起点。Saga分编排式和协同式两种。编排式由中心化的Saga编排器控制每个步骤的调用和补偿流程集中在代码里容易理解和维护协同式则让各个服务通过事件异步响应对应动作解耦更彻底但流程分散出错时排查链路比较长。我倾向于在业务链路超过三个系统、且允许短暂不一致但最终能对齐时优先用Saga尤其是下单、出库这类有明确前后顺序的流程。6.3 基于消息的最终一致性本地消息表和事务消息很多场景其实不需要强一致只需要最终一致。这时候消息队列加本地事务的方案非常实用。本地消息表的思路是在业务数据库里建一张消息表业务本地事务里把业务数据和消息记录一起写入然后由后台任务轮询这张表把消息发送到MQ消费方确认成功后更新消息状态。业务操作和消息插入在同一个本地事务里天然原子不会出现“业务成功了消息没发出去”的问题。缺点是消息表和业务表耦合且要自己处理消息投递的幂等。事务消息则把这个过程下沉到了MQ本身。比如RocketMQ的事务消息先发送半消息业务本地事务执行成功后向MQ发送commit确认MQ再让消费方可见如果本地事务回滚就发送rollback。这样消息和业务事务的一致性由MQ帮我们完成业务代码会简单很多。这个模式背后的思想非常接近“两阶段”先prepare半消息再根据本地事务结果commit或rollback。但关键不同是真正参与决策的是业务本地事务而不是一个独立的协调者去指挥多个数据库因此不会出现资源锁长期不释放的问题。6.4 选型参考什么时候还能考虑XA什么时候必须换我在项目里一般这么划定边界如果业务是低并发的内部系统跨库数量少并且已经有成熟的XA数据源支持和监控体系那么直接用XA/2PC也未必是坏事毕竟它实现简单、一致性严格。只要你能接受协调者故障时人工介入并且有明确的对账机制。但如果你的系统要支撑高并发且任何一个环节阻塞都可能把整个链路拖垮那我建议尽早放弃裸的2PC/3PC转用TCC、Saga或者事务消息。要知道互联网场景下可用性和性能往往排在强一致性之前收银台宁愿多等几秒最终到账也不能因为锁冲突把全部请求都干掉。7. 把它讲清楚之后面试和架构评审都更顺了7.1 2PC/3PC和Raft/Paxos到底有什么区别很多人把两阶段提交、三阶段提交和Raft、Paxos混为一谈但它们俩解决的根本不是一个问题。2PC/3PC属于“原子提交问题”范畴要解决的是多个节点对“某个事务能不能集体提交/回滚”达成一致而Raft/Paxos属于“共识问题”范畴要解决的是多个节点对“一个值”达成一致比如谁是leader、日志怎么复制。更直白点说2PC需要一个唯一的协调者来做决策本身没有容错能力协调者挂了协议就卡住Raft和Paxos则能在节点故障后继续选主、继续推进它们的容错能力更强。所以在实践中有人会把2PC的协调者用Raft集群化来消除单点这就是两者结合的典型用法。我在架构评审会上遇到“你们用2PC为什么不做成强一致”这类问题时一般会先分清我们讨论的是原子提交还是共识选主然后再进入方案取舍沟通一下就顺了。7.2 回答“为什么不用两阶段提交”的黄金框架如果你被问到“你们为什么不用两阶段提交”不要只丢一句“因为它性能不好”。更完整的回答框架是这样的先说业务场景的一致性要求是什么是强一致还是最终一致再说系统当前的并发压力、链路长度和故障恢复能力然后对比2PC/3PC的阻塞风险、脑裂窗口以及TCC、Saga、事务消息各自的实现代价最后结合监控、对账机制给出明确结论。这套回答方式不仅面试管用在真实架构评审里也很有用。因为分布式事务本就没有银弹关键是能说清楚自己基于什么条件做取舍。我自己现在做方案设计时默认流程是能靠本地事务解决的绝不引入分布式事物必须要跨库的优先看能不能改成最终一致必须要强一致的才认真评估XA的阻塞风险和运维成本。这比一上来就套用某个协议实用得多。2PC和3PC是很经典的协议理解它们不仅是为了应付面试更是为了在面对复杂的分布式系统时能一眼识别出隐患在哪、决策点在哪、风险窗口在哪。把这些想透了遇到线上故障时你才不会慌架构评审时也能说出真正的理由。