1. 从一个真实的数据不一致说起去年帮一个做电商的朋友排查线上问题,场景很典型:用户下单后,订单库写入了记录,但库存库扣减失败,结果订单显示"已支付"却发不出货。查日志发现,订单服务和库存服务各自连了独立的数据库,下单逻辑里先调订单服务、再调库存服务,中间任何一步网络抖动或者服务重启,整个链路就断了。这不是代码写得烂,而是典型的跨库操作缺少事务保障。单库单表的时候,我们靠数据库自身的ACID就能搞定,一个BEGIN到COMMIT之间要么全成功要么全回滚。可一旦业务拆成了多个独立服务、每个服务有自己的库,本地事务就管不到别人家的库了。这时候就需要分布式事务出场,而2PC(Two-Phase Commit,两阶段提交)是其中最经典、最基础的一种解决方案。这篇内容适合谁看?如果你正在做微服务拆分、遇到过跨服务数据不一致、或者面试被问到分布式事务答不上来,那这篇就是写给你的。我会从2PC的核心原理讲起,拆解它的两个阶段到底在干什么,然后给出可落地的实现思路和代码骨架,最后重点聊聊它在生产环境里那些"文档不会告诉你"的坑。全程用大白话加实际场景,不堆砌学术词汇。需要先说明一点:2PC不是一个具体的框架或中间件,它是一种协议规范,数据库层面(如XA规范)、消息队列、以及各种分布式事务框架都实现了它。理解协议本身,比记住某个框架的API重要得多。2. 2PC的两个阶段到底在做什么2.1 角色划分:协调者与参与者2PC里有两个核心角色。协调者(Coordinator)是整个事务的"总指挥",负责发起投票、收集结果、下达最终指令。参与者(Participant)是真正执行数据操作的那些节点,比如订单库、库存库、账户库。打个比方,协调者就像婚礼的司仪,参与者是新郎新娘双方家长。司仪先问"都准备好了吗",所有人回答"准备好了",司仪才宣布"礼成";只要有一个人说"不行",司仪就宣布"取消"。这个"先问再定"的过程,就是2PC的精髓。协调者本身可以是一个独立的服务,也可以是发起事务的那个业务服务。参与者就是各个资源管理器(数据库、消息中间件等)。关键点在于:协调者必须能可靠地记录事务状态,否则它自己挂了,整个事务就悬空了。2.2 第一阶段:准备阶段(投票阶段)准备阶段的核心动作是:协调者向所有参与者发送prepare请求,每个参与者执行事务操作但不提交,把数据写到undo/redo日志里,锁住相关资源,然后回复"我可以提交"或者"我不行"。这里有个特别容易被忽略的细节:参与者回复"可以提交"之后,它其实已经把该做的活都干完了,只是差最后一步commit。数据已经写进了日志,资源已经锁住了,就等协调者一声令下。这意味着如果参与者回复yes之后协调者挂了,这个参与者会一直持有锁,其他事务碰这些数据就会被阻塞。我用一个简化的时序来说明:协调者 -> 参与者A: prepare 协调者 -> 参与者B: prepare 参与者A: 执行操作,写日志,锁资源,回复 yes 参与者B: 执行操作,写日志,锁资源,回复 yes注意,参与者执行的操作是"预执行",比如扣库存这个动作,在准备阶段就已经把库存数从100改成了99,只是这个修改还没真正落盘生效(取决于具体实现,XA里是写入了但未提交)。2.3 第二阶段:提交阶段(执行阶段)如果所有参与者都回复yes,协调者向所有人发送commit,参与者正式提交,释放锁,回复ack。如果任何一个参与者回复no,或者超时没回复,协调者向所有人发送rollback,参与者用undo日志回滚,释放锁。第二阶段是"一锤定音"的阶段,没有商量余地。协调者根据第一阶段的投票结果做决定,这个决定一旦做出就不可撤销。参与者必须无条件执行,哪怕它自己觉得不该提交。这里有个反直觉的点:如果协调者在第二阶段发送commit的过程中挂了,那些已经收到commit的参与者提交了,没收到commit的参与者还锁着资源等指令。等协调者恢复后重新发送,最终大家还是会一致。但如果协调者永久性挂了,没收到指令的参与者就会一直等下去,这就是2PC最被人诟病的阻塞问题。3. 为什么2PC能保证一致性:从日志和锁说起3.1 undo/redo日志是2PC的命根子参与者能在准备阶段"先干活不提交",靠的就是日志。以数据库为例,当执行UPDATE inventory SET count = count - 1 WHERE id = 1时,数据库会先把"原值100"写进undo日志,把"新值99"写进redo日志,然后修改内存中的数据页。此时事务处于"已准备但未提交"状态。如果协调者说commit,数据库就把redo日志刷盘,标记事务已提交。如果协调者说rollback,数据库就用undo日志把数据改回100。整个过程,日志保证了无论发生什么,数据都能恢复到某个确定状态。提示:不同数据库对XA的支持程度差异很大。有些数据库在prepare阶段就把redo日志刷盘了,有些则延迟到commit。这直接影响崩溃恢复的行为,选型时一定要查清楚。3.2 锁机制带来的隔离性代价准备阶段锁住资源,是为了防止其他事务读到"中间状态"。比如库存已经预扣成99,但事务还没提交,这时候另一个查询进来,它应该看到100还是99?2PC通过锁让其他事务等待,保证它们看到的要么是事务前的100,要么是事务后的99,绝不会是"99但可能回滚"的脏数据。但这个锁的代价非常大。假设一个事务涉及5个库,准备阶段锁了5个库的相关行,然后协调者因为网络问题卡了30秒,这30秒里所有想操作这些行的事务全部排队。在高并发场景下,这几乎是灾难性的。我见过一个案例,一个大促活动因为一个跨库事务卡住,导致库存表的行锁等待队列飙到几千,整个下单链路雪崩。3.3 协调者的单点问题协调者要记录全局事务的状态,通常需要一张transaction_log表或者一个高可用的存储。如果协调者本身是单点,它挂了整个系统就瘫了。所以生产级的2PC实现,协调者必须做高可用,比如用Raft或Paxos选主,或者把状态存到可靠的分布式存储里。但即便协调者高可用,它和参与者之间的网络分区仍然可能导致问题。经典的"协调者发出commit后网络分区,部分参与者收到部分没收到",这时候系统处于不一致状态,直到网络恢复。2PC本身不解决网络分区,它假设网络最终会恢复。4. 手写一个2PC骨架:从接口设计到落地4.1 核心接口定义要理解2PC,最好的方式是自己实现一个最小骨架。我用Java风格伪代码来演示,其他语言思路一致。// 参与者接口 public interface Participant { // 准备阶段:执行操作,返回是否就绪 boolean prepare(TransactionContext ctx); // 提交阶段 void commit(TransactionContext ctx); // 回滚阶段 void rollback(TransactionContext ctx); } // 协调者 public class Coordinator { private List<Participant> participants; private TransactionLog log; // 持久化事务状态 public boolean execute(TransactionContext ctx) { // 第一阶段:准备 List<Participant> ready = new ArrayList<>(); for (Participant p : participants) { try { if (p.prepare(ctx)) { ready.add(p); } else { // 有人说不,全部回滚 rollbackAll(ready, ctx); return false; } } catch (Exception e) { rollbackAll(ready, ctx); return false; } } // 记录全局状态为"准备提交" log.write(ctx.txId, "PREPARED"); // 第二阶段:提交 for (Participant p : ready) { p.commit(ctx); } log.write(ctx.txId, "COMMITTED"); return true; } }这个骨架看起来简单,但魔鬼在细节里。比如prepare返回true之后,参与者必须保证即使自己重启,也能在收到commit时正确提交。这就要求参与者把"已准备"状态持久化,而不是放在内存里。4.2 参与者如何实现prepare的持久化以库存服务为例,prepare阶段不能直接UPDATE inventory SET count = count - 1,因为这样改了数据但事务没提交,其他事务会读到脏数据(除非用行锁)。更稳妥的做法是引入一张事务中间表:-- 事务记录表 CREATE TABLE tx_record ( tx_id VARCHAR(64) PRIMARY KEY, status VARCHAR(16), -- PREPARED / COMMITTED / ROLLED_BACK created_at TIMESTAMP ); -- 库存预扣表 CREATE TABLE inventory_freeze ( tx_id VARCHAR(64), product_id BIGINT, freeze_count INT, PRIMARY KEY (tx_id, product_id) );prepare时,先往tx_record插入一条PREPARED记录,再往inventory_freeze插入冻结数量,同时扣减可用库存。commit时,把tx_record状态改成COMMITTED,删除冻结记录。rollback时,把冻结的数量加回可用库存,删除冻结记录,状态改成ROLLED_BACK。这种"冻结"思路比直接锁行更可控,因为它把锁的粒度从数据库行锁转移到了业务层面的记录,减少了数据库层面的锁竞争。很多实际生产系统就是这么干的。4.3 协调者的状态机与恢复逻辑协调者需要一张全局事务表:CREATE TABLE global_tx ( tx_id VARCHAR(64) PRIMARY KEY, status VARCHAR(16), -- BEGIN / PREPARING / PREPARED / COMMITTING / COMMITTED / ROLLING_BACK / ROLLED_BACK created_at TIMESTAMP, updated_at TIMESTAMP );协调者启动时,要扫描global_tx表,找出所有处于中间状态(PREPARING、PREPARED、COMMITTING、ROLLING_BACK)的事务,根据状态决定是继续提交还是继续回滚。这就是崩溃恢复。比如一个事务状态是PREPARED,说明所有参与者都准备好了,协调者应该继续发commit。如果状态是PREPARING,说明还没收集完投票,协调者应该发rollback(因为不确定是否所有人都准备好了,保守回滚)。注意:恢复逻辑里有个坑——如果协调者在PREPARED状态挂了,恢复后继续commit,但某个参与者其实在prepare时回复的是no(只是协调者还没记录),那就会导致部分提交。所以协调者必须在收到所有yes之后才写PREPARED状态,这个写入顺序不能错。5. 生产环境里2PC的那些坑5.1 性能问题:为什么大厂都在逃离2PC2PC的性能瓶颈主要来自三个方面:锁持有时间长、网络往返多、协调者吞吐受限。锁持有时间从prepare开始到commit结束,中间包含网络传输、协调者决策、日志写入。一个跨3个库的事务,锁持有时间可能是单库事务的5到10倍。网络往返方面,2PC至少需要两轮RPC(prepare一轮,commit一轮),如果加上超时重试,可能更多。协调者吞吐方面,所有事务都要经过协调者,它很容易成为瓶颈。我做过一个粗略的压测:单库事务QPS能到8000,换成2PC跨两个库,QPS直接掉到800左右,差了10倍。这还是在网络状况良好的情况下。所以2PC适合低频、短事务、对一致性要求极高的场景,比如金融转账、订单创建,不适合高并发秒杀。5.2 超时与重试:最容易被忽略的细节2PC里超时处理极其关键。协调者发prepare后等多久?参与者回复慢怎么办?commit阶段参与者没响应怎么办?我的经验是:prepare超时时间要短(比如3秒),因为参与者如果正常,准备动作很快;commit超时时间可以长一些(比如10秒),因为commit是最终决定,必须确保送达。超时后不能简单重试,因为重试可能导致重复提交。正确的做法是:协调者记录每次请求的发送状态,超时后先查询参与者的实际状态(通过tx_id查tx_record表),如果参与者已经commit了,就跳过;如果还是PREPARED,就重发commit。这叫幂等重试。参与者这边也要做幂等:收到commit时,先查tx_record,如果已经是COMMITTED,直接返回成功,不重复执行。5.3 空回滚与悬挂:两个隐蔽的陷阱空回滚是指:参与者在prepare阶段因为网络问题没收到请求,但协调者超时后发了rollback,参与者收到rollback时发现自己根本没执行过prepare,这时候如果直接回滚,可能把其他事务的数据搞乱。解决办法是:参与者收到rollback时,先查tx_record,如果不存在该tx_id,就插入一条ROLLED_BACK记录,表示"这个事务我虽然没参与,但我知道它回滚了"。悬挂是指:参与者在prepare超时后,协调者发了rollback,参与者执行了回滚。但之后那个延迟的prepare请求才到达,参与者又执行了prepare,导致数据被锁定但永远不会被提交。解决办法是:参与者在执行prepare前,先查tx_record,如果发现该tx_id已经是ROLLED_BACK,就拒绝prepare。这两个问题在文档里经常一笔带过,但实际生产中一旦出现,排查起来非常痛苦。我建议在tx_record表里加一个status字段,把PREPARED、COMMITTED、ROLLED_BACK都记下来,所有操作前先查状态。6. 2PC的替代方案与选型建议6.1 TCC:把2PC的业务逻辑显式化TCC(Try-Confirm-Cancel)本质上是2PC的业务层实现。Try对应prepare,Confirm对应commit,Cancel对应rollback。区别在于,TCC的Try阶段不是简单锁资源,而是执行一个"预留"操作,比如冻结库存而不是扣减库存。TCC的好处是把控制权交给业务,性能比XA好,因为不需要数据库层面的长事务。坏处是开发成本高,每个参与方都要实现三个方法,而且要考虑幂等、空回滚、悬挂。适合对性能有要求、业务逻辑相对清晰的场景。6.2 本地消息表与事务消息本地消息表的核心思路是:把分布式事务拆成多个本地事务,用消息表来保证最终一致性。比如订单服务在本地事务里同时写订单和一条"扣库存"消息,然后异步发消息给库存服务,库存服务消费消息后扣库存。如果扣库存失败,消息会重试。这种方案不保证强一致性,但保证最终一致性,性能好,实现简单。缺点是消息可能重复消费,需要业务做幂等。事务消息(如RocketMQ的事务消息)是本地消息表的进阶版,把消息表的维护交给消息队列。6.3 选型对比表方案一致性性能开发成本适用场景2PC/XA强一致低低(框架支持)低频金融交易TCC强一致中高高并发、业务可控本地消息表最终一致高中异步解耦场景事务消息最终一致高中消息驱动架构Saga最终一致高中长流程、多步骤选型的核心原则是:不要为了强一致而强一致。很多业务场景其实可以接受最终一致,比如发优惠券、积分累计,晚几秒到账用户根本感知不到。只有涉及钱、库存这种不能出错的,才需要强一致方案。7. 我在实际项目中的几点体会第一,能不用分布式事务就不用。最好的分布式事务是没有分布式事务。通过合理的业务拆分,把需要事务的操作收敛到同一个服务、同一个库里,从根源上避免跨库。比如订单和库存,能不能合并成一个"交易服务"?很多团队拆得太细,反而给自己挖坑。第二,如果一定要用2PC,把事务范围压到最小。一个事务涉及的服务不要超过3个,参与的操作要尽可能快。我见过一个事务里包含了调用外部API、发短信、写日志,这种绝对不能用2PC,因为外部API的延迟不可控,会把整个事务拖死。第三,监控和告警必须到位。2PC的中间状态(PREPARED、COMMITTING)如果长时间不结束,就是异常信号。我一般会设置一个阈值,比如PREPARED状态超过30秒就告警,然后人工介入或者自动触发回滚。同时要监控协调者的队列积压、参与者的锁等待时间。第四,测试要覆盖异常场景。正常流程谁都能跑通,关键是网络抖动、服务重启、超时这些情况。我习惯用工具模拟网络延迟和丢包,专门测prepare阶段断网、commit阶段断网、协调者重启这些case。很多bug都是在这种极端情况下暴露的。最后分享一个小技巧:在tx_record表里加一个expire_at字段,记录事务的过期时间。后台起一个定时任务,扫描所有超过过期时间还是PREPARED状态的事务,自动触发回滚。这能兜住大部分因为协调者崩溃导致的悬挂事务,虽然不能保证100%正确,但比人工排查快得多。这个机制我们叫它"事务兜底清理",在几个项目里都救过场。
1. 从一个真实的数据不一致说起去年帮一个做电商的朋友排查线上问题,场景很典型:用户下单后,订单库写入了记录,但库存库扣减失败,结果订单显示"已支付"却发不出货。查日志发现,订单服务和库存服务各自连了独立的数据库,下单逻辑里先调订单服务、再调库存服务,中间任何一步网络抖动或者服务重启,整个链路就断了。这不是代码写得烂,而是典型的跨库操作缺少事务保障。单库单表的时候,我们靠数据库自身的ACID就能搞定,一个BEGIN到COMMIT之间要么全成功要么全回滚。可一旦业务拆成了多个独立服务、每个服务有自己的库,本地事务就管不到别人家的库了。这时候就需要分布式事务出场,而2PC(Two-Phase Commit,两阶段提交)是其中最经典、最基础的一种解决方案。这篇内容适合谁看?如果你正在做微服务拆分、遇到过跨服务数据不一致、或者面试被问到分布式事务答不上来,那这篇就是写给你的。我会从2PC的核心原理讲起,拆解它的两个阶段到底在干什么,然后给出可落地的实现思路和代码骨架,最后重点聊聊它在生产环境里那些"文档不会告诉你"的坑。全程用大白话加实际场景,不堆砌学术词汇。需要先说明一点:2PC不是一个具体的框架或中间件,它是一种协议规范,数据库层面(如XA规范)、消息队列、以及各种分布式事务框架都实现了它。理解协议本身,比记住某个框架的API重要得多。2. 2PC的两个阶段到底在做什么2.1 角色划分:协调者与参与者2PC里有两个核心角色。协调者(Coordinator)是整个事务的"总指挥",负责发起投票、收集结果、下达最终指令。参与者(Participant)是真正执行数据操作的那些节点,比如订单库、库存库、账户库。打个比方,协调者就像婚礼的司仪,参与者是新郎新娘双方家长。司仪先问"都准备好了吗",所有人回答"准备好了",司仪才宣布"礼成";只要有一个人说"不行",司仪就宣布"取消"。这个"先问再定"的过程,就是2PC的精髓。协调者本身可以是一个独立的服务,也可以是发起事务的那个业务服务。参与者就是各个资源管理器(数据库、消息中间件等)。关键点在于:协调者必须能可靠地记录事务状态,否则它自己挂了,整个事务就悬空了。2.2 第一阶段:准备阶段(投票阶段)准备阶段的核心动作是:协调者向所有参与者发送prepare请求,每个参与者执行事务操作但不提交,把数据写到undo/redo日志里,锁住相关资源,然后回复"我可以提交"或者"我不行"。这里有个特别容易被忽略的细节:参与者回复"可以提交"之后,它其实已经把该做的活都干完了,只是差最后一步commit。数据已经写进了日志,资源已经锁住了,就等协调者一声令下。这意味着如果参与者回复yes之后协调者挂了,这个参与者会一直持有锁,其他事务碰这些数据就会被阻塞。我用一个简化的时序来说明:协调者 -> 参与者A: prepare 协调者 -> 参与者B: prepare 参与者A: 执行操作,写日志,锁资源,回复 yes 参与者B: 执行操作,写日志,锁资源,回复 yes注意,参与者执行的操作是"预执行",比如扣库存这个动作,在准备阶段就已经把库存数从100改成了99,只是这个修改还没真正落盘生效(取决于具体实现,XA里是写入了但未提交)。2.3 第二阶段:提交阶段(执行阶段)如果所有参与者都回复yes,协调者向所有人发送commit,参与者正式提交,释放锁,回复ack。如果任何一个参与者回复no,或者超时没回复,协调者向所有人发送rollback,参与者用undo日志回滚,释放锁。第二阶段是"一锤定音"的阶段,没有商量余地。协调者根据第一阶段的投票结果做决定,这个决定一旦做出就不可撤销。参与者必须无条件执行,哪怕它自己觉得不该提交。这里有个反直觉的点:如果协调者在第二阶段发送commit的过程中挂了,那些已经收到commit的参与者提交了,没收到commit的参与者还锁着资源等指令。等协调者恢复后重新发送,最终大家还是会一致。但如果协调者永久性挂了,没收到指令的参与者就会一直等下去,这就是2PC最被人诟病的阻塞问题。3. 为什么2PC能保证一致性:从日志和锁说起3.1 undo/redo日志是2PC的命根子参与者能在准备阶段"先干活不提交",靠的就是日志。以数据库为例,当执行UPDATE inventory SET count = count - 1 WHERE id = 1时,数据库会先把"原值100"写进undo日志,把"新值99"写进redo日志,然后修改内存中的数据页。此时事务处于"已准备但未提交"状态。如果协调者说commit,数据库就把redo日志刷盘,标记事务已提交。如果协调者说rollback,数据库就用undo日志把数据改回100。整个过程,日志保证了无论发生什么,数据都能恢复到某个确定状态。提示:不同数据库对XA的支持程度差异很大。有些数据库在prepare阶段就把redo日志刷盘了,有些则延迟到commit。这直接影响崩溃恢复的行为,选型时一定要查清楚。3.2 锁机制带来的隔离性代价准备阶段锁住资源,是为了防止其他事务读到"中间状态"。比如库存已经预扣成99,但事务还没提交,这时候另一个查询进来,它应该看到100还是99?2PC通过锁让其他事务等待,保证它们看到的要么是事务前的100,要么是事务后的99,绝不会是"99但可能回滚"的脏数据。但这个锁的代价非常大。假设一个事务涉及5个库,准备阶段锁了5个库的相关行,然后协调者因为网络问题卡了30秒,这30秒里所有想操作这些行的事务全部排队。在高并发场景下,这几乎是灾难性的。我见过一个案例,一个大促活动因为一个跨库事务卡住,导致库存表的行锁等待队列飙到几千,整个下单链路雪崩。3.3 协调者的单点问题协调者要记录全局事务的状态,通常需要一张transaction_log表或者一个高可用的存储。如果协调者本身是单点,它挂了整个系统就瘫了。所以生产级的2PC实现,协调者必须做高可用,比如用Raft或Paxos选主,或者把状态存到可靠的分布式存储里。但即便协调者高可用,它和参与者之间的网络分区仍然可能导致问题。经典的"协调者发出commit后网络分区,部分参与者收到部分没收到",这时候系统处于不一致状态,直到网络恢复。2PC本身不解决网络分区,它假设网络最终会恢复。4. 手写一个2PC骨架:从接口设计到落地4.1 核心接口定义要理解2PC,最好的方式是自己实现一个最小骨架。我用Java风格伪代码来演示,其他语言思路一致。// 参与者接口 public interface Participant { // 准备阶段:执行操作,返回是否就绪 boolean prepare(TransactionContext ctx); // 提交阶段 void commit(TransactionContext ctx); // 回滚阶段 void rollback(TransactionContext ctx); } // 协调者 public class Coordinator { private List<Participant> participants; private TransactionLog log; // 持久化事务状态 public boolean execute(TransactionContext ctx) { // 第一阶段:准备 List<Participant> ready = new ArrayList<>(); for (Participant p : participants) { try { if (p.prepare(ctx)) { ready.add(p); } else { // 有人说不,全部回滚 rollbackAll(ready, ctx); return false; } } catch (Exception e) { rollbackAll(ready, ctx); return false; } } // 记录全局状态为"准备提交" log.write(ctx.txId, "PREPARED"); // 第二阶段:提交 for (Participant p : ready) { p.commit(ctx); } log.write(ctx.txId, "COMMITTED"); return true; } }这个骨架看起来简单,但魔鬼在细节里。比如prepare返回true之后,参与者必须保证即使自己重启,也能在收到commit时正确提交。这就要求参与者把"已准备"状态持久化,而不是放在内存里。4.2 参与者如何实现prepare的持久化以库存服务为例,prepare阶段不能直接UPDATE inventory SET count = count - 1,因为这样改了数据但事务没提交,其他事务会读到脏数据(除非用行锁)。更稳妥的做法是引入一张事务中间表:-- 事务记录表 CREATE TABLE tx_record ( tx_id VARCHAR(64) PRIMARY KEY, status VARCHAR(16), -- PREPARED / COMMITTED / ROLLED_BACK created_at TIMESTAMP ); -- 库存预扣表 CREATE TABLE inventory_freeze ( tx_id VARCHAR(64), product_id BIGINT, freeze_count INT, PRIMARY KEY (tx_id, product_id) );prepare时,先往tx_record插入一条PREPARED记录,再往inventory_freeze插入冻结数量,同时扣减可用库存。commit时,把tx_record状态改成COMMITTED,删除冻结记录。rollback时,把冻结的数量加回可用库存,删除冻结记录,状态改成ROLLED_BACK。这种"冻结"思路比直接锁行更可控,因为它把锁的粒度从数据库行锁转移到了业务层面的记录,减少了数据库层面的锁竞争。很多实际生产系统就是这么干的。4.3 协调者的状态机与恢复逻辑协调者需要一张全局事务表:CREATE TABLE global_tx ( tx_id VARCHAR(64) PRIMARY KEY, status VARCHAR(16), -- BEGIN / PREPARING / PREPARED / COMMITTING / COMMITTED / ROLLING_BACK / ROLLED_BACK created_at TIMESTAMP, updated_at TIMESTAMP );协调者启动时,要扫描global_tx表,找出所有处于中间状态(PREPARING、PREPARED、COMMITTING、ROLLING_BACK)的事务,根据状态决定是继续提交还是继续回滚。这就是崩溃恢复。比如一个事务状态是PREPARED,说明所有参与者都准备好了,协调者应该继续发commit。如果状态是PREPARING,说明还没收集完投票,协调者应该发rollback(因为不确定是否所有人都准备好了,保守回滚)。注意:恢复逻辑里有个坑——如果协调者在PREPARED状态挂了,恢复后继续commit,但某个参与者其实在prepare时回复的是no(只是协调者还没记录),那就会导致部分提交。所以协调者必须在收到所有yes之后才写PREPARED状态,这个写入顺序不能错。5. 生产环境里2PC的那些坑5.1 性能问题:为什么大厂都在逃离2PC2PC的性能瓶颈主要来自三个方面:锁持有时间长、网络往返多、协调者吞吐受限。锁持有时间从prepare开始到commit结束,中间包含网络传输、协调者决策、日志写入。一个跨3个库的事务,锁持有时间可能是单库事务的5到10倍。网络往返方面,2PC至少需要两轮RPC(prepare一轮,commit一轮),如果加上超时重试,可能更多。协调者吞吐方面,所有事务都要经过协调者,它很容易成为瓶颈。我做过一个粗略的压测:单库事务QPS能到8000,换成2PC跨两个库,QPS直接掉到800左右,差了10倍。这还是在网络状况良好的情况下。所以2PC适合低频、短事务、对一致性要求极高的场景,比如金融转账、订单创建,不适合高并发秒杀。5.2 超时与重试:最容易被忽略的细节2PC里超时处理极其关键。协调者发prepare后等多久?参与者回复慢怎么办?commit阶段参与者没响应怎么办?我的经验是:prepare超时时间要短(比如3秒),因为参与者如果正常,准备动作很快;commit超时时间可以长一些(比如10秒),因为commit是最终决定,必须确保送达。超时后不能简单重试,因为重试可能导致重复提交。正确的做法是:协调者记录每次请求的发送状态,超时后先查询参与者的实际状态(通过tx_id查tx_record表),如果参与者已经commit了,就跳过;如果还是PREPARED,就重发commit。这叫幂等重试。参与者这边也要做幂等:收到commit时,先查tx_record,如果已经是COMMITTED,直接返回成功,不重复执行。5.3 空回滚与悬挂:两个隐蔽的陷阱空回滚是指:参与者在prepare阶段因为网络问题没收到请求,但协调者超时后发了rollback,参与者收到rollback时发现自己根本没执行过prepare,这时候如果直接回滚,可能把其他事务的数据搞乱。解决办法是:参与者收到rollback时,先查tx_record,如果不存在该tx_id,就插入一条ROLLED_BACK记录,表示"这个事务我虽然没参与,但我知道它回滚了"。悬挂是指:参与者在prepare超时后,协调者发了rollback,参与者执行了回滚。但之后那个延迟的prepare请求才到达,参与者又执行了prepare,导致数据被锁定但永远不会被提交。解决办法是:参与者在执行prepare前,先查tx_record,如果发现该tx_id已经是ROLLED_BACK,就拒绝prepare。这两个问题在文档里经常一笔带过,但实际生产中一旦出现,排查起来非常痛苦。我建议在tx_record表里加一个status字段,把PREPARED、COMMITTED、ROLLED_BACK都记下来,所有操作前先查状态。6. 2PC的替代方案与选型建议6.1 TCC:把2PC的业务逻辑显式化TCC(Try-Confirm-Cancel)本质上是2PC的业务层实现。Try对应prepare,Confirm对应commit,Cancel对应rollback。区别在于,TCC的Try阶段不是简单锁资源,而是执行一个"预留"操作,比如冻结库存而不是扣减库存。TCC的好处是把控制权交给业务,性能比XA好,因为不需要数据库层面的长事务。坏处是开发成本高,每个参与方都要实现三个方法,而且要考虑幂等、空回滚、悬挂。适合对性能有要求、业务逻辑相对清晰的场景。6.2 本地消息表与事务消息本地消息表的核心思路是:把分布式事务拆成多个本地事务,用消息表来保证最终一致性。比如订单服务在本地事务里同时写订单和一条"扣库存"消息,然后异步发消息给库存服务,库存服务消费消息后扣库存。如果扣库存失败,消息会重试。这种方案不保证强一致性,但保证最终一致性,性能好,实现简单。缺点是消息可能重复消费,需要业务做幂等。事务消息(如RocketMQ的事务消息)是本地消息表的进阶版,把消息表的维护交给消息队列。6.3 选型对比表方案一致性性能开发成本适用场景2PC/XA强一致低低(框架支持)低频金融交易TCC强一致中高高并发、业务可控本地消息表最终一致高中异步解耦场景事务消息最终一致高中消息驱动架构Saga最终一致高中长流程、多步骤选型的核心原则是:不要为了强一致而强一致。很多业务场景其实可以接受最终一致,比如发优惠券、积分累计,晚几秒到账用户根本感知不到。只有涉及钱、库存这种不能出错的,才需要强一致方案。7. 我在实际项目中的几点体会第一,能不用分布式事务就不用。最好的分布式事务是没有分布式事务。通过合理的业务拆分,把需要事务的操作收敛到同一个服务、同一个库里,从根源上避免跨库。比如订单和库存,能不能合并成一个"交易服务"?很多团队拆得太细,反而给自己挖坑。第二,如果一定要用2PC,把事务范围压到最小。一个事务涉及的服务不要超过3个,参与的操作要尽可能快。我见过一个事务里包含了调用外部API、发短信、写日志,这种绝对不能用2PC,因为外部API的延迟不可控,会把整个事务拖死。第三,监控和告警必须到位。2PC的中间状态(PREPARED、COMMITTING)如果长时间不结束,就是异常信号。我一般会设置一个阈值,比如PREPARED状态超过30秒就告警,然后人工介入或者自动触发回滚。同时要监控协调者的队列积压、参与者的锁等待时间。第四,测试要覆盖异常场景。正常流程谁都能跑通,关键是网络抖动、服务重启、超时这些情况。我习惯用工具模拟网络延迟和丢包,专门测prepare阶段断网、commit阶段断网、协调者重启这些case。很多bug都是在这种极端情况下暴露的。最后分享一个小技巧:在tx_record表里加一个expire_at字段,记录事务的过期时间。后台起一个定时任务,扫描所有超过过期时间还是PREPARED状态的事务,自动触发回滚。这能兜住大部分因为协调者崩溃导致的悬挂事务,虽然不能保证100%正确,但比人工排查快得多。这个机制我们叫它"事务兜底清理",在几个项目里都救过场。