资讯详情

资讯详情

两阶段提交与三阶段提交:从协议状态机到工程落地的深度解析

分布式系统里最让人头疼的问题从来不是服务挂了怎么办而是服务没挂但状态不一致了怎么办。两阶段提交和三阶段提交这两个名字几乎每个做后端的人都听过但真正在项目里手写过协调者逻辑、处理过协调者宕机、被参与者超时卡死过的人并不多。我见过太多团队在选型时把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 问题的人都把它打印出来贴在显示器边上。当前状态收到消息动作迁移到INITPrepare执行本地 prepare写日志锁资源PREPARED / ABORTEDPREPAREDCommit提交本地事务释放锁COMMITTEDPREPAREDRollback回滚本地事务释放锁ABORTEDPREPARED无协调者失联保持阻塞等待PREPARED阻塞态COMMITTED任意幂等返回成功COMMITTEDABORTED任意幂等返回失败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 用超时换活性代价是牺牲了一致性保障。维度2PC3PC阶段数23CanCommit/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 的阻塞性会被无限放大。假设一个事务要调用三个外部服务每个平均 500msprepare 阶段就要锁资源 1.5 秒以上高并发下连接池瞬间打满。这种场景应该考虑 TCCTry-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 后没做悬挂监控某次协调者所在机器磁盘满导致日志写不进去几十个事务悬挂了两天才被发现期间相关账户全部被锁客诉炸了。后来他们补了监控和手动干预工具才算把这块补上。协议保证的是正常情况下正确工程保证的是异常情况下可恢复两者缺一不可。我在实际项目里踩过最疼的一次是协调者日志用了异步刷盘测试环境一切正常压测也没问题上线后某次机器意外重启恢复出来的日志少了一条提交决定导致一个已经部分提交的事务被当成未决事务重新走流程最后靠人工对账才把数据修回来。从那以后我对先记日志再行动这条原则有了近乎偏执的坚持——协议文档里轻描淡写的一句话背后可能是几天的对账和一堆客诉。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →