分布式系统里最让人头疼的问题从来不是"服务挂了怎么办",而是"服务没挂,但状态不一致了怎么办"。两阶段提交和三阶段提交这两个名字,几乎每个做后端的人都听过,但真正在项目里手写过协调者逻辑、处理过协调者宕机、被参与者超时卡死过的人并不多。我见过太多团队在选型时把"2PC"当成一个黑盒组件直接引入,结果上线后遇到参与者长时间锁资源、协调者重启后事务状态丢失、网络分区下参与者互相等待这些经典问题,才开始回头补协议本身的课。
这篇内容想做的事情很明确:把两阶段提交(2PC)和三阶段提交(3PC)从协议报文层面拆开讲清楚,包括每个阶段到底在等什么、为什么这么设计、失败时各方的状态机怎么走,以及在实际工程里落地时会遇到哪些协议文档不会告诉你的坑。适合已经了解分布式事务基本概念、准备自己实现或排查协调逻辑的开发者,也适合想搞清楚"为什么3PC没能取代2PC"这个经典疑问的读者。我会尽量用生活化的类比配合状态迁移表来讲,让协议不再是几张看不懂的时序图。
1. 先把"提交"这件事拆成可验证的状态机
很多人学2PC的第一反应是背流程:协调者问大家能不能提交,大家说能,协调者说那就提交。这个描述没错,但它把协议最核心的东西——状态机——给掩盖了。协议之所以能在各种故障下被讨论,是因为每个参与方在任何时刻都处于一个明确的状态,而协议规则定义了"收到什么消息、当前处于什么状态、应该迁移到什么状态"。不理解状态机,后面所有的故障分析都是空中楼阁。
1.1 为什么"一次提交"在分布式里根本做不到
单机数据库提交一个事务,本质是写一条日志然后刷盘,原子性由本地磁盘和 WAL 保证。到了分布式环境,事务涉及多个独立的资源管理器,每个都有自己的日志和磁盘,没有一个"全局磁盘"能一次性把所有人的决定写下去。这就导致一个根本矛盾:你无法让多个节点在同一个瞬间同时做出决定。
举个生活化的例子。几个人合伙转账,A 要扣钱、B 要加钱,这两步必须同生共死。如果 A 先扣了,然后通知 B 加钱的消息在路上丢了,钱就凭空消失了;如果 B 先加了,通知 A 扣钱的消息丢了,钱就凭空多出来了。单机事务靠"一个日志文件记录所有操作"解决这个问题,分布式里没有这个统一日志,所以只能靠协商:先让所有人表态"我准备好了",等所有人都表态完,再统一发"执行"指令。这个"先协商、后执行"的两步结构,就是两阶段提交的雏形。
这里有个容易被忽略的点:协商阶段的意义不是"确认大家都能做",而是"确认大家都已经把该做的准备工作做完了,并且把'我准备提交'这个意图持久化了"。持久化这三个字是关键,它意味着即使参与者随后宕机重启,也能从日志里恢复出"我曾经投过赞成票"这个事实,从而在收到最终指令时不会反悔。没有持久化的赞成票,整个协议就是不可靠的。
1.2 协调者与参与者的职责边界
2PC 里有两类角色。协调者(Coordinator)负责驱动整个流程、记录全局决定;参与者(Participant)负责执行本地事务的 prepare 和 commit/rollback。职责边界划得很清楚,但工程上经常被搞混的是:协调者到底要不要持久化?答案是必须。协调者在第一阶段收集完所有投票后,要把"最终决定"写进自己的日志再往下发,否则它自己宕机重启后就不知道这个事务该提交还是回滚了。
参与者这边,prepare 阶段要做的事情比很多人想的要多。它不只是"检查一下能不能做",而是要真正执行事务的本地部分(写 undo/redo 日志、锁住相关资源),只是不提交。这个"锁住资源"的动作是后面所有麻烦的根源——资源被锁住期间,其他事务碰不了,如果协调者迟迟不发最终指令,参与者就只能干等,这就是所谓的阻塞问题。
| 角色 | 核心职责 | 必须持久化的内容 |
|---|---|---|
| 协调者 | 驱动流程、收集投票、记录全局决定 | 全局事务 ID、最终 commit/abort 决定 |
| 参与者 | 执行本地 prepare、响应投票、执行最终动作 | 本地事务日志、赞成/反对票、最终执行结果 |
1.3 状态迁移表比时序图更有信息量
时序图好看,但它只画了"顺利路径"。真正有价值的是状态迁移表,因为它把每个状态、每个可能的输入、每个迁移目标都列出来了,故障分析时直接查表就行。下面这张表是参与者侧的核心状态迁移,我建议每个要排查 2PC 问题的人都把它打印出来贴在显示器边上。
| 当前状态 | 收到消息 | 动作 | 迁移到 |
|---|---|---|---|
| INIT | Prepare | 执行本地 prepare,写日志,锁资源 | PREPARED / ABORTED |
| PREPARED | Commit | 提交本地事务,释放锁 | COMMITTED |
| PREPARED | Rollback | 回滚本地事务,释放锁 | ABORTED |
| PREPARED | 无(协调者失联) | 保持阻塞,等待 | PREPARED(阻塞态) |
| COMMITTED | 任意 | 幂等返回成功 | COMMITTED |
| ABORTED | 任意 | 幂等返回失败 | ABORTED |
注意 PREPARED 状态那一行"无消息"的迁移——它迁移到自身,也就是卡住。这不是 bug,是 2PC 的固有特性。参与者在投出赞成票之后,除了等协调者的最终指令,没有任何自主决策权。它不能自己决定提交(万一别人回滚了就全局不一致),也不能自己决定回滚(万一别人提交了同样不一致)。这种"把命运完全交给协调者"的设计,就是 2PC 阻塞性的本质来源。
2. 两阶段提交的报文细节与失败路径
把状态机理清楚之后,我们来看 2PC 实际跑起来是什么样。协议本身很简单,但简单的东西在故障下暴露的问题往往最刁钻。这一节我会把两个阶段的报文交互讲透,然后重点分析几种典型故障下各方的行为,因为协议的正确性恰恰是在故障路径上体现的,顺利路径谁都不会出错。
2.1 第一阶段:prepare 到底在"准备"什么
第一阶段协调者向所有参与者发 Prepare 请求,参与者收到后执行本地事务的"预执行"。这个预执行具体做什么,取决于底层资源管理器,但抽象来看包含三件事:检查本地能否完成这个事务(约束、锁冲突、资源充足性)、把 undo 和 redo 信息写进日志并刷盘、锁住事务涉及的所有资源。三件事都成功,回 Yes;任何一件失败,回 No。
这里有个实操中特别容易踩的坑:prepare 阶段的耗时是不确定的。如果某个参与者本地锁冲突严重,prepare 可能卡很久,而协调者通常会给 prepare 设一个超时。超时之后协调者怎么办?它不能假设对方失败了就回滚,因为对方可能只是慢,实际已经 prepare 成功并锁住了资源。如果协调者此时决定回滚并通知其他参与者回滚,那个"慢"的参与者稍后 prepare 成功、锁住资源、然后收到一个它没预期到的 Rollback——虽然协议上它能处理(PREPARED 收到 Rollback 就回滚),但中间这段时间它一直占着锁,其他事务全被堵住。
所以工程上的常见做法是:协调者对 prepare 超时的参与者重试而不是直接判失败,重试若干次仍无响应才走"疑似失败"分支,并且这个分支要配合后续的补偿或人工介入。这一点协议文档不会写,但线上出问题时它是救命的关键。
2.2 第二阶段:commit 与 rollback 的幂等性要求
第二阶段协调者根据投票结果发 Commit 或 Rollback。看起来简单,但有个硬性要求经常被实现者忽略:第二阶段的指令必须支持幂等重发。因为协调者发完 Commit 后如果没收到某个参与者的 ACK,它会重发,而参与者可能已经提交过了。如果参与者不幂等,重复提交就会出问题(比如重复扣款)。
实现幂等的标准做法是参与者维护一张"已处理事务表",记录每个全局事务 ID 的最终状态。收到 Commit 时先查表,已提交就直接回 ACK,没提交才真正执行。这张表要持久化,否则参与者重启后表丢了,幂等性就没了。我见过一个实现把这张表放在内存里,结果参与者重启后收到重发的 Commit,又提交了一遍,虽然大多数业务有唯一约束兜底没出事,但这种设计本身就是定时炸弹。
提示:幂等表不能只记录"已提交",还要记录"已回滚"。因为协调者可能在 Commit 和 Rollback 之间因为自身状态恢复而重发不同指令(虽然正常流程不会,但故障恢复时可能出现),参与者要能识别并拒绝矛盾的指令,而不是盲目执行。
2.3 协调者宕机:2PC 最致命的单点
现在来看 2PC 最被诟病的场景:协调者在发完 prepare、收齐投票、但还没发最终指令时宕机了。此时所有参与者都处于 PREPARED 状态,锁着资源,等着一个永远不会来的指令。它们不能自己决定,只能阻塞。这就是协调者单点故障导致的全局阻塞。
有人会说,协调者重启后从日志恢复,继续发指令不就行了?理论上可以,但前提是协调者的日志可靠、恢复逻辑正确、且能在合理时间内重启。如果协调者是永久性故障(磁盘损坏、机器报废),那这些参与者就永远卡在 PREPARED,除非人工介入。人工介入的方式通常是:找一个能读到协调者日志的副本,确认这个全局事务的最终决定,然后手动给每个参与者发指令。这个过程极其痛苦,而且容易出错。
更麻烦的是,如果协调者的日志本身也丢了(比如日志没刷盘就宕机),那连"这个事务该提交还是回滚"都无从得知。这时候只能靠参与者之间的相互询问来推断:如果有一个参与者已经收到了 Commit 并提交了,那其他参与者就应该提交;如果所有参与者都还在 PREPARED,那就只能赌一把统一回滚(有风险,因为可能协调者其实已经决定提交但没发出去)。这种"靠参与者互相打听"的恢复方式,就是 3PC 想要解决的问题之一。
2.4 参与者宕机与网络分区的处理差异
参与者宕机比协调者宕机好处理一些。如果参与者在 prepare 之前宕机,协调者收不到它的投票,超时后可以决定全局回滚(因为还没到"必须提交"的地步)。如果参与者在投了赞成票之后宕机,协调者收齐了其他票、决定提交,然后给这个宕机的参与者重发 Commit,等它重启后处理。参与者的日志保证了它重启后能恢复出 PREPARED 状态并正确响应。
网络分区则是另一回事。分区可能导致协调者和部分参与者失联,但和另一部分参与者正常通信。这时候协调者如果收不齐投票,通常会超时回滚;但如果它已经收齐了投票、进入第二阶段,而分区恰好把某个参与者隔开了,那个被隔开的参与者就会一直卡在 PREPARED。等分区恢复,它收到重发的 Commit,正常提交。整个过程对业务的影响是:被隔开的参与者锁住的资源在分区期间一直不可用。
| 故障类型 | 协调者行为 | 参与者行为 | 业务影响 |
|---|---|---|---|
| 协调者宕机(发指令前) | 重启后从日志恢复 | 阻塞等待 | 资源锁定,事务悬挂 |
| 参与者宕机(投票前) | 超时回滚 | 重启后按日志恢复 | 较小,可回滚 |
| 参与者宕机(投票后) | 重发最终指令 | 重启后响应 | 该参与者资源短暂锁定 |
| 网络分区 | 超时或重试 | 被隔开者阻塞 | 分区期间资源不可用 |
3. 三阶段提交想解决什么,又为什么没解决彻底
3PC 的出现动机非常明确:2PC 在协调者宕机时会导致参与者无限阻塞,3PC 想通过增加一个阶段、引入超时机制,让参与者在等不到指令时能自主决策,从而打破阻塞。这个思路听起来很美好,但实际效果有限,原因在于它引入的自主决策是有前提的,而这个前提在网络分区下并不总成立。
3.1 CanCommit 阶段:把"能不能做"和"做没做"分开
3PC 把 2PC 的第一阶段拆成了两个:CanCommit 和 PreCommit。CanCommit 阶段协调者只问"你们能不能执行这个事务",参与者只做轻量检查(不锁资源、不写日志),回 Yes 或 No。如果大家都 Yes,协调者再发 PreCommit,参与者才真正执行 prepare(锁资源、写日志)。如果 CanCommit 阶段有人回 No 或超时,协调者直接发 Abort,全程没有资源被锁。
这个拆分的好处是:在真正锁资源之前,多了一次"预检"。如果某个参与者明显做不了(比如资源不足),在 CanCommit 阶段就能发现,避免它进入 PreCommit 后锁了资源又失败。这降低了资源被无效锁定的概率,但没有改变"PreCommit 之后仍然要等最终指令"的本质。
3.2 PreCommit 之后的超时自主提交逻辑
3PC 真正想解决问题的地方在 PreCommit 之后。参与者进入 PreCommit 状态(相当于 2PC 的 PREPARED),如果此时协调者失联,参与者不再无限等待,而是超时后自主提交。这个设计的逻辑是:既然大家都走到了 PreCommit,说明 CanCommit 阶段所有人都同意了,那么提交是大概率正确的选择,与其无限阻塞不如赌一把提交。
这个"赌一把"是有理论依据的,但依据很脆弱。它成立的前提是:所有参与者要么都在 PreCommit 状态,要么已经提交。如果存在某个参与者还在 CanCommit 阶段(比如它响应慢,协调者已经推进到 PreCommit 但它还没收到),而其他参与者超时自主提交了,那就出现了部分提交、部分没提交的不一致。3PC 通过"协调者在推进到 PreCommit 前必须确认所有参与者都回了 CanCommit Yes"来尽量保证这个前提,但网络分区下,一个参与者可能回了 Yes 但协调者没收到(分区),协调者超时后可能选择 Abort,而其他已经进入 PreCommit 的参与者超时后自主 Commit——矛盾就出现了。
3.3 3PC 在网络分区下依然会不一致的根因
根因在于 3PC 的自主决策依赖"超时"这个本地判断,而超时无法区分"协调者挂了"和"网络分区导致消息没到"。在一个分区场景里,一部分参与者可能超时自主提交,另一部分可能因为收到了协调者的 Abort 而回滚,两边做出相反决定,全局不一致。2PC 虽然会阻塞,但阻塞至少保证了"不会做出错误决定",而 3PC 用"可能做出错误决定"换取了"不阻塞",这个 trade-off 在很多场景下并不划算。
用一个类比来理解:2PC 像一群人在等队长发话,队长失联了大家就原地不动(安全但僵住);3PC 像约定"等队长超过十分钟就自己决定往前走",但如果队长其实是被困在另一个房间(分区)正在喊"撤退",而这边的人听不到,就会往前走,和另一队听得到撤退命令的人走散。3PC 用超时换活性,代价是牺牲了一致性保障。
| 维度 | 2PC | 3PC |
|---|---|---|
| 阶段数 | 2 | 3(CanCommit/PreCommit/DoCommit) |
| 协调者宕机 | 参与者无限阻塞 | 参与者超时自主决策 |
| 网络分区 | 阻塞,但不做错误决定 | 可能做出不一致决定 |
| 资源锁定时间 | 较长(prepare 即锁) | 略短(CanCommit 不锁) |
| 实际采用度 | 广泛(XA、多数数据库) | 较少(理论价值大于工程价值) |
4. 落地时协议文档不会告诉你的几个坑
协议本身讲完了,但真正让 2PC 在项目里"能用"和"好用"之间差着十万八千里的,是工程实现细节。这一节我挑几个最有代表性的坑来讲,都是那种"不看不知道、一看吓一跳、踩过一次记一辈子"的类型。
4.1 prepare 阶段锁资源导致的连接池耗尽
这是 2PC 在生产环境最常见的雪崩起点。参与者在 prepare 阶段锁住资源,如果协调者因为某种原因(网络慢、自身 GC 停顿、下游依赖超时)迟迟不发第二阶段指令,参与者的锁就一直不释放。锁不释放意味着持有该锁的数据库连接不能归还连接池,连接池被逐渐占满,最终整个参与者实例的所有请求都拿不到连接,服务雪崩。
这个问题的隐蔽性在于:它表现为"数据库连接池耗尽",排查时第一反应往往是"连接池配小了"或者"有慢查询",很难联想到是分布式事务的 prepare 卡住了。我建议的做法是:给 prepare 阶段的资源锁定设一个最大持有时间,超过就强制回滚本地事务并记录告警。这个强制回滚有风险(可能协调者稍后会发 Commit),所以必须配合告警和人工确认,但至少能防止连接池被无限占用。另一个做法是给分布式事务单独配一个连接池,和普通业务隔离,避免一个事务卡死拖垮全部流量。
4.2 协调者日志的刷盘策略与恢复正确性
协调者的日志必须刷盘才能保证宕机恢复,但刷盘是有性能代价的。有些实现为了性能把日志写成异步刷盘,结果协调者在"决定提交"和"日志落盘"之间宕机,重启后日志里没有这个决定,它可能错误地重新发起流程或直接放弃,导致参与者状态和协调者认知不一致。
正确的做法是:协调者在收集完投票、做出决定后,同步刷盘再发第二阶段指令。这个顺序不能反——先发指令后刷盘,如果发完就宕机,日志没落盘,恢复时不知道发过什么;先刷盘后发指令,即使发指令时宕机,恢复后也能从日志知道该发什么,重发即可。这个"先记日志再行动"的原则和数据库 WAL 是一个道理,但很多自研协调者会图省事把顺序搞反。
4.3 空回滚与悬挂事务的防御
有两个经典异常场景必须防。空回滚:参与者在收到 Rollback 时,发现自己根本没收到过 Prepare(可能 Prepare 消息丢了或还没到),这时候它该不该回滚?正确做法是记录一个"已回滚"标记然后返回成功,否则协调者会一直重试 Rollback。但如果它不回滚又返回成功,万一 Prepare 稍后到了,它就会执行 prepare 并锁资源,然后等一个永远不会来的 Commit——这就是悬挂事务。
防御悬挂的标准做法是:参与者在处理 Rollback 时,如果本地没有对应事务记录,就插入一条"防悬挂记录"(标记该全局事务已回滚),后续如果收到迟到的 Prepare,查到这个标记就直接拒绝。这套机制在 TCC 模式里叫"空回滚"和"悬挂"防御,在 2PC 里同样适用,只是很多人实现 2PC 时压根没考虑这两个场景。
注意:空回滚和悬挂是一对孪生问题,只防一个不防另一个等于没防。防了空回滚(记录已回滚),但没防悬挂(迟到的 Prepare 照样执行),悬挂事务还是会出现。
4.4 超时时间的层级设计
2PC 涉及多个层级的超时:参与者执行 prepare 的超时、协调者等待投票的超时、协调者等待第二阶段 ACK 的超时、参与者等待最终指令的超时。这些超时如果设计不当,会出现"下层超时比上层长"的倒挂,导致上层已经判失败回滚了,下层还在傻等,等下层终于超时返回时,状态已经和上层不一致。
设计原则是超时时间从内到外逐层递减:参与者本地 prepare 的超时要小于协调者等待投票的超时,协调者等待投票的超时要小于整个全局事务的超时。这样保证内层先超时、外层后超时,外层判失败时内层已经明确失败了,不会出现状态错位。这个原则说起来简单,但实际配置时经常被忽略,尤其是多个团队各自配置自己那层的超时,没人统一看全局。
5. 从协议到选型:什么场景该用 2PC,什么场景该绕开
讲完原理和坑,最后落到选型上。2PC 不是银弹,也不是毒药,它的适用边界其实很清晰,只是很多团队在选型时被"分布式事务"这个大词唬住了,没想清楚自己的场景到底需不需要强一致、能不能接受阻塞。
5.1 强一致且短事务:2PC 的主场
2PC 最适合的场景是:参与方数量少(通常 2 到 3 个)、事务执行时间短、对强一致有硬要求。典型如跨库转账、订单和库存的强一致扣减。这类场景里,prepare 阶段锁资源的时间很短(毫秒级),协调者失联的概率低,即使偶尔阻塞也能快速恢复,2PC 的开销完全可以接受。
判断标准可以简化成一句话:如果这个事务不一致会导致资损或业务逻辑错误,且事务本身很快,那就用 2PC。反过来,如果事务里包含调用外部服务、发消息、等人工审核这类长耗时操作,2PC 就不合适——prepare 阶段锁着资源等外部服务返回,锁持有时间可能是秒级甚至分钟级,阻塞问题会被放大到不可接受。
5.2 长事务与高并发:为什么该考虑 TCC 或 Saga
长事务场景下,2PC 的阻塞性会被无限放大。假设一个事务要调用三个外部服务,每个平均 500ms,prepare 阶段就要锁资源 1.5 秒以上,高并发下连接池瞬间打满。这种场景应该考虑 TCC(Try-Confirm-Cancel)或 Saga。
TCC 的思路是把"锁资源"和"执行业务"分开:Try 阶段做资源预留(比如冻结金额而不是锁住账户行),Confirm 阶段真正扣减,Cancel 阶段释放预留。资源预留不占用数据库行锁,所以不会阻塞其他事务,代价是业务代码要写三套逻辑,侵入性强。Saga 则是把长事务拆成一串本地事务,每个配一个补偿操作,失败时反向执行补偿。Saga 没有锁,活性好,但只保证最终一致,中间状态对外可见,需要业务能容忍。
| 方案 | 一致性 | 阻塞性 | 业务侵入 | 适用场景 |
|---|---|---|---|---|
| 2PC | 强一致 | 高(锁资源) | 低(对业务透明) | 短事务、少参与方 |
| TCC | 最终一致 | 低(资源预留) | 高(三套逻辑) | 中等时长、需资源隔离 |
| Saga | 最终一致 | 无锁 | 中(补偿逻辑) | 长事务、可容忍中间态 |
5.3 自研协调者还是用现成组件
如果确定要用 2PC,下一个问题是自己写协调者还是用现成的(比如基于 XA 的数据库驱动、或者消息队列的事务消息)。我的建议是:除非有非常特殊的定制需求,否则优先用现成的。自研协调者要处理的边界情况(日志恢复、幂等、空回滚、悬挂、超时层级)远比想象的多,任何一个没处理好都是线上事故。
现成组件的选择上,XA 协议是最标准的 2PC 实现,主流数据库都支持,优点是标准化、对业务透明,缺点是性能开销大(两次网络往返加两次日志刷盘)、且依赖数据库的 XA 实现质量。消息队列的事务消息是另一种思路,它把"本地事务"和"发消息"做成原子,适合"本地操作加通知下游"的场景,但它不是严格意义上的 2PC,一致性保证的边界要搞清楚。
5.4 监控与人工兜底:给协议加一层保险
无论用哪种方案,都必须有监控和人工兜底。2PC 最怕的是"悬挂事务"——参与者卡在 PREPARED 状态没人管。所以要监控:处于 PREPARED 状态超过阈值的事务数量、协调者的日志积压、参与者的锁等待时间。一旦发现异常,要有工具能查到某个全局事务的当前状态(协调者日志加各参与者状态),并支持人工强制提交或回滚。
这套兜底机制平时用不上,但出事时是唯一的救命稻草。我见过一个团队上线 2PC 后没做悬挂监控,某次协调者所在机器磁盘满导致日志写不进去,几十个事务悬挂了两天才被发现,期间相关账户全部被锁,客诉炸了。后来他们补了监控和手动干预工具,才算把这块补上。协议保证的是"正常情况下正确",工程保证的是"异常情况下可恢复",两者缺一不可。
我在实际项目里踩过最疼的一次,是协调者日志用了异步刷盘,测试环境一切正常,压测也没问题,上线后某次机器意外重启,恢复出来的日志少了一条提交决定,导致一个已经部分提交的事务被当成未决事务重新走流程,最后靠人工对账才把数据修回来。从那以后我对"先记日志再行动"这条原则有了近乎偏执的坚持——协议文档里轻描淡写的一句话,背后可能是几天的对账和一堆客诉。