1. 为什么会有3PC2PC的阻塞困境与协议进化的动机分布式系统里有个很基础也很头疼的问题多个节点要一起完成一个事务怎么保证要么全部成功、要么全部失败先提数据要么所有副本都写了要么一个都不写。这就是分布式事务要解决的原子性问题。在两阶段提交2PC统治了很长一段时间之后一个叫三阶段提交3PCThree Phase Commit的协议出现了。它从名字上就很直接——比2PC多一个阶段。但如果你以为只是多加一个阶段这么简单那就理解偏了。3PC的诞生是为了修正2PC一个非常致命的缺陷协调者故障时的无限期阻塞。在展开协议细节之前先回顾一下2PC的问题出在哪里这样才能真正明白3PC每一步设计背后的用意而不是死记几个阶段名字。1.1 2PC的一点回顾正常流程与两阶段模型2PC把一次分布式事务的提交流程分割成两个阶段第一阶段准备阶段协调者向所有参与者发送Prepare请求参与者执行事务的写操作并把undo/redo日志刷到磁盘然后回复Yes准备好提交或者No无法提交。第二阶段提交/中止阶段协调者根据收集到的投票结果做决策。如果全员Yes就广播Commit如果有人No就广播Abort。这个过程的本质是把“能不能提交”这个决策从参与者手中收走统一交给协调者做。参与者只负责执行和报告最终命运由协调者一句话决定。2PC在正常运行时其实很好用性能也可以接受。问题全都出在故障场景尤其是协调者挂掉的时候。1.2 阻塞问题的三个典型场景第一个场景参与者已经回复了Yes等Commit命令。协调者在广播Commit之前挂了。此时参与者手里的数据已经写入了事务版本锁也还占着它只能等。等到协调者恢复或者等到超时。可是在2PC里参与者没有超时机制更没有单方面解除阻塞的决策权。如果协调者永久宕机这个参与者就在事务中间态里永远卡住。别的会话访问这些行的时候全都被锁挡住。数据库看起来就像是“假死”了。第二个场景协调者挂了之后恢复了但它的日志丢了。2PC要求协调者在做出决策之后必须把决策日志刷到磁盘才能发Commit这是为了恢复时能依据日志继续通告。但如果日志损坏或者断电时机不巧协调者不知道自己是准备阶段还是提交阶段就没办法给参与者一个确定性的答复。那些等着Commit的参与者就彻底卡死。第三个场景协调者没有挂但网络分区了。一部分参与者联系不上协调者它们同样卡死。2PC没有给参与者提供“联系不上协调者时该怎么自救”的规则那就是一个字——等。这就是2PC的阻塞属性。单点故障会放大成全局停顿这在大规模分布式系统里很难接受。1.3 3PC的设计目标把“未知”变成“已知”3PC想解决的核心问题可以概括成一句话让参与者在协调者失联时也能根据自身已知的信息做出合理的自救决策而不是无限期等待。怎么做到呢2PC的问题在于参与者只知道“我准备好了”但对“别人准备好了没有”“协调者准备干什么”一无所知。3PC的解法是把2PC的提交决策过程劈成两段——先让大家确认“是不是都有条件提交”再让大家进入一个“预提交”状态最后才是真正的提交。这样一来参与者在超时之后可以依据自己身处哪个阶段来判断是安全提交还是安全中止。有人会说那这个跟加个超时时间有什么区别区别很大。盲目超时提交可能导致数据不一致万一协调者最终是想Abort某个参与者超时了自己Commit那这个参与者的数据就和别人不一样了。所以3PC的重点不是“加超时”而是通过阶段设计保证参与者超时后的决策方向是安全的——要么大家一定都能提交要么终止也不会出错。这一点后面会详细展开。2. 三阶段阶段协议的核心拆解从CanCommit到DoCommit3PC的三个阶段分别是CanCommit、PreCommit和DoCommit。名字起得很直观问你能不能提交让你先别急着提交然后再让你真正提交。2.1 第一阶段CanCommit预检与准入门槛这一阶段做的事情比较轻协调者向所有参与者发送CanCommit请求只问一个问题“当前这个事务你有能力提交吗”注意这里跟2PC的Prepare有个关键区别参与者此时并没有执行实际的写操作没有加锁没有改数据也没有写事务日志。它就是检查一下自己的状态——节点活着吗资源够不够相关的锁能不能拿到然后返回Yes或No。所以CanCommit本质上是一个预检。它的价值有两个。第一个价值是尽早失败。如果某个参与者明确说自己不行比如负载已满、资源不足协调者直接在阶段一结束就广播Abort根本不用走后面那些更重的操作。这就避免了一个情况2PC里参与者先执行了Prepare阶段的大量写操作和日志刷盘最后才发现大家投不了票白白浪费了一轮昂贵的磁盘IO和锁等待。第二个价值是提前排除故障节点。如果有节点在CanCommit阶段就联系不上或者超时协调者可以直接判它No事务提前终止不需要等到更深的阶段再回头处理。这样可以缩短事务持有资源的时间窗口从设计上降低冲突率。这个阶段的代价也很明显多了一轮网络往返RTT。本来2PC只需要两轮现在3PC至少需要三轮。这就是为什么3PC并不总是优于2PC的——它用性能换来了更强的容错能力。一个实现上的注意点CanCommit阶段返回Yes之后参与者需要把这个状态记在内存里。因为接下来可能收到PreCommit也可能收到Abort参与者要能区分这两种情况并对PreCommit做好准备。这个状态一般不需要落盘因为还没做任何实质性的数据改动。2.2 第二阶段PreCommit写入但未提交这是3PC的灵魂当协调者收到所有参与者的Yes投票后它会发送PreCommit消息。参与者收到后要做的事情是执行事务的写操作写入undo/redo日志并刷盘保持事务处于预备可提交状态然后回复ACK。这一阶段在形态上跟2PC的Prepare非常像。那它跟2PC的Prepare有什么本质区别答案是人家后面还跟着一个DoCommit阶段。这个“后面还有一段”的设计彻底改变了超时行为。想一下参与者在2PC的Prepare之后的状态它已经写入了数据并持有锁如果协调者没下文它就只能死等。因为它不知道该提交还是该回滚——如果它擅自提交万一别人回滚了数据就不一致如果它擅自回滚万一别人提交了数据也不一致。左右都是错所以最优解就是不动。而3PC的参与者在PreCommit之后虽然也是写入数据、持有锁但它知道既然已经进入了PreCommit说明协调者已经收到了所有人的Yes也就是说正常情况下最终一定会走向CommitAbort的概率已经极小。于是当参与者在PreCommit之后迟迟收不到DoCommit指令时它可以根据超时时间自行提交。这就是3PC解决阻塞问题的关键机制后面单独拆开讲。PreCommit还有一个细节容易被忽略协调者在这个阶段需要等待所有参与者的ACK吗严格来说不需要全量等。协调者只要收到足够多参与者的ACK自己进入PreCommit完成状态就可以对外宣布“马上提交”。如果有些参与者的ACK因为网络原因晚到或者丢包协调者可以靠重发DoCommit来弥补。但为了协议的严谨性实现里通常还是要求尽可能等到多数ACK否则某个参与者可能压根没收到PreCommit后续的DoCommit对它来说就变成了非法消息。这里有一个很现实的经验在实现3PC时PreCommit的ACK超时和重试机制一定要设计好。有些参与者可能临时掉线错过PreCommit协调者不能干等要在自己进入提交阶段后不断重发DoCommit让掉线的参与者通过DoCommit回推确认自己需要提交——它根本不需要它之前做过什么只要在DoCommit阶段能连上就行。2.3 第三阶段DoCommit最终提交与终止的临界操作协调者收到所有参与者的PreCommit ACK之后正式进入提交决策的最终阶段发送DoCommit。参与者收到DoCommit后正式释放锁、提交事务并给协调者回复确认。这里是3PC里最重要的一段逻辑协调者在发送DoCommit之前已经不需要再做任何“投票”了因为所有决定都已经在PreCommit阶段定型了。如果参与者们在PreCommit阶段都投了Yes那DoCommit就是水到渠成的通知把“预定好的提交”变成“实际的提交”。同样如果前期有任何一个环节出现No协调者会广播Abort。Abort不叫DoAbort它其实和PreCommit平级是另一个分支。协调者在任何收到No的节点上选择走Abort路径。有意思的是3PC的Abort路径也喜欢“两段式”——先发一个Abort告知大家大家回复ACK之后再发一个确认终止的消息。为什么要这样跟PreCommit的设计逻辑一脉相承如果协调者只发一个Abort就断了参与者收到Abort之后会立刻回滚这是安全的。但如果有些参与者压根没收到Abort它自己卡住怎么办所以需要类似PreCommit的超时自救机制收到Abort的参与者超时后直接终止事务没收到Abort但超时的参与者呢它可能处于Prepare之后的等待状态这时候它不能提交但也不能一直等。它有没有可能安全地自行中止有可能但有一个前提——它还不能因为超时就乱来。这一段的论理在不少教科书里被压缩成了一个很小的角落但恰恰是理解3PC的分水岭。往下看状态机部分之前先把3PC的消息流用一个最简单的正常运行场景串一遍。阶段消息参与者的动作第一阶段CanCommit检查能力不执行写操作返回Yes/No第二阶段PreCommit执行写操作写undo/redo日志并刷盘返回ACK第三阶段DoCommit提交事务释放锁返回确认3. 用状态机视角理解3PC超时规则与容错行为协议这种东西光看消息序列是不够的。因为真正决定协议正确性的不是正常路径而是所有故障路径。2PC的毛病就出在故障路径上。所以理解3PC必须从状态机和超时规则入手。3.1 参与者側的状态机从Waiting到Committed参与者在3PC中大致经历这些状态Init初始还没参与事务或者事务结束后的清闲状态。Waiting等待投票已经收到CanCommit回复Yes在等PreCommit或Abort。PreCommitted预提交已经执行了写操作、写了日志在等DoCommit。Committed已提交事务正式提交完成。Aborted已中止事务回滚完成。参与者超时规则的核心在Waiting和PreCommitted这两个状态上。在Waiting状态下超时了怎么办也就是参与者回复了CanCommit的Yes之后等了一整个超时周期都没收到协调者的消息。这个时候参与者能不能自己中止在3PC的标准设计里是可以的。因为此时参与者还没有执行任何实际的写操作没有改数据中止的成本几乎为零。它只需要丢弃事务上下文进入Aborted状态就可以了。协调者后续如果发来PreCommit或者DoCommit参与者会以“事务不存在”拒绝——虽然这有点尴尬但不会造成数据不一致因为还没写入任何数据。在PreCommitted状态下超时了呢参与者已经执行了写操作持有锁日志都刷盘了这时候不能随便中止——因为它知道其他参与者大概率都已经准备好提交一旦自己中止而别人提交了数据就不一致。所以3PC设计为PreCommitted状态下超时参与者自主提交。这个规则放在正常运行场景下看有点让人不安——协调者可能是想Abort怎么办但请注意参与者能进入PreCommitted前提是它收到了协调者发出的PreCommit而协调者发出PreCommit的前提是全部参与者都投了Yes。所以一个收到PreCommit的参与者可以合理地推断“既然协调者已经确认人人都有条件提交那它现在最可能的方向就是DoCommit。我等不到它那我就按最可能的方向办——提交。”这就是3PC最核心的思想用阶段递进把“不确定的决策窗口”压缩到最小让参与者在超时时能依据已掌握的信息做出安全倾向的选择。3.2 协调者侧的状态机三个决策点的演进协调者的状态流转也很清晰Init事务创建。WaitingCanCommit等待所有CanCommit回复。PreCommitted已发出PreCommit等待所有ACK。Committed已发出DoCommit等待确认可以不阻塞地收尾。Aborted已发出Abort并确认终止。协调者在两个地方需要做决策。第一个决策点在CanCommit收集完之后如果全部Yes进入PreCommit路径如果任一No或超时进入Abort路径。第二个决策点在收集PreCommit ACK之后如果所有ACK都收到进入Committed路径发DoCommit如果某个ACK迟迟没收到协调者不能无限等它有几种选择重发PreCommit给掉线的参与者或者干脆直接发DoCommit——因为决策方向已经定了提交是唯一的正确方向。这里值得注意协调者在PreCommit阶段不会因为一个参与者的ACK超时而转向Abort。逻辑很简单——如果有参与者没收到PreCommit它可能还停留在Waiting状态它的超时规则是中止如果协调者此时发Abort这个参与者就会中止OK但其他已经进入PreCommitted的参与者会超时提交这俩矛盾了。所以协调者一旦进入PreCommit路径正确的做法就是一条道走到黑努力把DoCommit送出去让所有人最终提交。这个设计也解释了为什么3PC在某些极端网络情况下仍然可能出现不一致——协调者和参与者之间的消息可能会交叉错乱前一个说提交后一个说中止。这个问题最后单独讲。3.3 超时参数的设置与一个常被忽略的坑那超时时间该怎么设置没有标准答案但我可以给一个经验性的建议。通常协调者和参与者的超时时间应该设置成协调者和参与者之间网络往返时间的倍数比如5到10倍的期望RTT。前提是这期间的负载没有剧烈波动。如果设置的太小会出现“假超时”参与者只是因为CPU抢占、磁盘抖动导致处理慢了就被判定超时然后触发了自己提交或者中止的逻辑结果和协调者的实际决策撞上引发不一致。还有一个常见坑是参与者在PreCommitted状态下超时自主提交但它提交完之后协调者后来竟然发来了DoCommit那自然没事事务正常结束。但如果协调者因为某些诡异的原因在PreCommitted状态收到了某个参与者的No理论上不该发生但消息乱序可能让它以为是新事务的No它可能会在DoCommit阶段后发Abort那就灾难了。所以实现3PC时协调者处理消息必须严格做事务ID和状态校验跨阶段的消息不能相互干扰。不少半个协议的Demo实现都有过这类bug我见过有同学在3PC模拟项目里反复调试崩溃最后发现是协调者把上一个事务的No投票误当成当前事务的真是血泪教训。4. 3PC是否真的“完美解决了”阻塞问题辩证地看很多教程给3PC贴的标签是“解决了2PC的阻塞问题”。这个说法要打个问号。更准确的说法是3PC解决了2PC在特定故障场景下的无限阻塞但它并没有从逻辑上消除所有不一致的可能性。4.1 确实解决了的场景协调者故障假设协调者在发出PreCommit之后、发出DoCommit之前宕机了。2PC中的参与者会变成孤儿无限等待。3PC中的参与者因为已经进入PreCommitted状态超时后自主提交。数据库锁被释放事务完成。这个场景下3PC的优越性非常明显。不过这里有一个细思极恐的前提参与者的时钟和超时是可靠的。如果参与者的时钟发生了大的跳跃比如NTP同步导致时间往前拨了一截超时可能异常触发。在容错协议的设计里一般假设时钟偏差在可控范围内但工程上还要留个心眼。再一种情况协调者在CanCommit阶段之后、PreCommit之前宕机。此时参与者们停留在Waiting状态超时后各自中止。因为没有执行任何写操作中止代价极低一致性没问题。第三种情况协调者在DoCommit之后宕机。参与者都已经提交了协调者宕机不过是让最终确认丢失事务结果已经确定恢复后补发确认即可。所以单纯从“协调者挂掉”这个维度看3PC确实解决了2PC的阻塞问题。4.2 未能解决的场景网络分区与脑裂网络分区是分布式系统的噩梦3PC也不例外。假设发生了一个特殊的分区协调者能联系上参与者A和B但联系不上参与者C。协调者给A和B发了PreCommitA和B进入PreCommitted状态。协调者因为等不到C的ACK一直不能发DoCommit或者发了但C收不到。一段时间后参与者A和B在PreCommitted状态下超时自主提交。与此同时分区恢复协调者或C那边因为收不到PreCommit可能仍然认为事务处于早期状态回滚事务或者对C执行Abort。结果A和B提交了C可能回滚或根本不知道事务存在。数据不一致就这样出现了。更进一步如果分区恰好把协调者和所有参与者全隔开每个参与者都在各自的小分区里它们会根据自己所处的状态各自做决策——有的提交有的中止这就是脑裂。3PC在此场景下无能为力。所以说3PC的容错能力是“概率性”的它把阻塞问题的窗口从“无限等待”缩减为“一个超时窗口”但在这个窗口内如果发生极其不巧的分区仍然会破坏一致性。4.3 为什么3PC无法彻底解决一致性问题根本原因原因其实很简单也很残酷在网络异步假设下不存在一个确定性算法能同时满足原子性和终止性。分布式系统领域里经典的FLP不可能性定理已经指明了这一点。3PC没有违背这个定理——它只是在“几乎永远正常”的假设下把可能性从0提升到了一个工程上可接受的值。另外从协议自身看3PC有一个逻辑短板它假设参与者收到PreCommit之后协调者必然已经确认了“全员Yes”。可“确认全员Yes”这个事实本身在异步网络里就是一个无法完美同步的信息。协调者知道全员Yes但参与者并不知道“协调者知道全员Yes”它们只能相信协调者发来的PreCommit。而当协调者玩了失踪之后参与者只能依靠超时和自身状态做推断推断就可能有错。5. 与2PC的全面对比到底什么时候该用谁每次讲完3PC必然会有人问那我在项目里到底用2PC还是3PC这取决于你更怕什么——是怕协调者宕机导致事务卡死还是怕网络分区导致数据不一致。5.1 代价换正确性3PC的性能与复杂性成本从性能看3PC比2PC多了一轮网络往返。在跨数据中心的场景下一轮RTT可能几百毫秒三次往返加起来延迟已经很可观。同时3PC在PreCommit阶段要求参与者写undo/redo日志并刷盘这部分磁盘IO成本和2PC的Prepare阶段一样昂贵但3PC还额外多了一次日志记录状态变化有的实现还要记录两遍日志负担更重。从实现复杂度看3PC的状态机比2PC复杂得多。2PC的实现就两个阶段、两种决策容易想清楚。3PC需要处理的状态转移、超时规则、消息重试和恢复逻辑量级明显上升。代价就是bug更容易埋伏调试更痛苦。可以用一个简单表格总结两者的对比对比维度2PC3PC阶段数23网络往返2次3次协调者宕机时参与者行为无限期阻塞超时后自主决定提交或中止写入数据的时间点Prepare阶段PreCommit阶段无故障时的性能更优有额外开销分区故障下的一致性阻塞不过不一致可能出现不一致实现复杂度低高这个表格一看就明白了2PC在正常情况更快、更简单但在协调者故障时卡死3PC在协调者故障时能自愈但极端分区下反而可能不一致。没有谁绝对更好只有谁更适合你的故障模型。5.2 3PC的思想遗产它影响了谁尽管原版3PC在工程界用得并不广泛在真实数据库和中间件里几乎看不到直接以3PC实现的分布式事务但它的思想渗透到了很多后来的协议设计中。最典型的就是Paxos和Raft它们面向的状态机复制逻辑里面“先预写、再提交、超时重试”的思路跟3PC的PreCommit有很多神似之处。再比如一些数据库的分区共识协议在事务提交阶段也会用“两轮确认多数派ACK”来降低协调者故障的影响。可以说3PC是把“在任何故障下都要给出明确决策”这个问题带火的协议它启发了后来大批一致性协议的设计者去思考“可终止性”要怎么保证。所以学习3PC值不值得非常值得。它不是一个“你生产环境里马上要用的协议”但它是一把钥匙——理解了它你再看Paxos、Raft里那些“预提交/多数派确认/日志复制”的设计会有一种豁然开朗的通透感。6. 工程里的3PC实践以及我的一些体会说实话我在实际项目中几乎没有直接部署过纯正的3PC实现。很多数据库中间件走的还是2PC加补偿事务的折衷路线。当你去GitHub搜“three phase commit”能找到的更多是教学Demo和模拟器而不是生产级实现。但这不妨碍我们从中提炼出很多有价值的工程经验。第一个体会“先问能不能再动手干”是一个很好的通用设计模式。在3PC的CanCommit里先检查能力和资源不急着执行写操作。这个习惯在工程里极有价值。比如你要在多个微服务里执行一批写操作与其直接闷头调用不如先做一轮“预检”——检查这些服务是否可用、限流状态是否允许、关键资源是否拿得到。预检失败就快速失败比执行到一半发现服务不可用再回滚要省太多事。第二个体会阶段推进制是对付长链路事务的法宝。3PC把事务分成三个阶段每个阶段都有明确的状态和超时规则。在工程里一个多步骤业务流程比如下单、锁库存、扣款、发券其实也可以抽象成类似的状态机每个步骤完成之后记录明确的阶段状态。这样任何一步失败我们都能根据“当场处于哪个阶段”来确定补偿动作——是重试、回滚还是直接跳过。很多人设计补偿流程时一团乱麻就是因为没有阶段概念。第三个体会无论协议怎么设计日志和状态持久化永远是大爷。3PC要求PreCommit阶段写undo/redo日志并刷盘这不是为了跑得快而是为了恢复时有据可依。我在实践中见过太多“内存状态机跑得很嗨节点一重启就全乱套”的案例。任何分布式事务系统日志的落盘设计一定要先于业务逻辑做否则后面补都是血。第四个体会是真实的踩坑经历我在学习3PC时想写一个模拟器练手刚开始图省事没有把协调者和参与者的超时事件做成独立线程而是全部挤在同一个事件循环里顺序处理。结果一个参与者的超时事件因为前一个参与者的日志刷盘阻塞晚了半秒触发状态机上直接跳到了错误的转移路径整个模拟器逻辑就崩了。后来我老老实实把超时检测、日志IO、消息收发拆成独立的模块各自跑各自的线程才稳定下来。所以如果你也打算实现一个3PC协议验证器务必把异步事件隔离做干净否则你会被各种时序幽灵问题折磨到怀疑人生。最后说一个很多人问的问题3PC能不能在真实的生产系统里凑合着用我的答案是可以但没必要。如果你的事务参与者数量不多、网络可靠、协调者可以做成高可用那2PC加协调者HA就足够了。如果你们对协调者故障的容忍度极低又不怕多一轮RTT那3PC的姿态可以考虑但务必做好日志持久化和超时参数的调优。绝大多数时候工程上真正需要的不是一个“更完美”的协议而是一个你完全摸透了脾气、知道它什么情况下会出什么问题的方案。我对3PC的总体判断是这样的它不是一个完美的协议甚至也不是一个比2PC“更高级”的协议。它是一个在特定故障假设下更聪明的协议。读懂3PC最大的收获不是会背它三个阶段的名字而是理解了一件事——分布式系统没有银弹任何协议都是拿一部分代价换另一部分保障。你要做的永远是先想清楚你最不能接受哪种失败再反过来选协议。