1. 为什么需要分布式事务
1.1 从一次转账超时说起
很多同学第一次接触分布式事务,都是被线上问题逼出来的。我印象很深的一个案例:一个交易系统做用户余额扣减和积分发放,两个操作分别落在两个独立的数据库实例上。某天网络抖动,扣款成功了,积分发放超时失败,用户账户里钱少了但积分没到账,客服电话被打爆。这就是典型的分布式场景下数据不一致问题。
单机数据库时代,我们靠本地事务的ACID就能搞定一切。一个BEGIN到COMMIT之间,要么全成功,要么全回滚,数据库引擎帮我们兜底。可一旦业务拆分到多个服务、多个数据库、多个存储系统,本地事务就鞭长莫及了。每个服务只能管好自己的那一亩三分地,跨服务的数据一致性就成了一个必须正面硬刚的架构难题。
分布式事务要解决的核心问题就一句话:在多个独立节点参与的情况下,如何保证所有节点的操作要么全部生效,要么全部不生效。听起来简单,做起来极难,因为网络会超时、节点会宕机、消息会丢失、时钟还不同步。
1.2 2PC在分布式事务家族中的位置
分布式事务的解决方案有很多流派,常见的有2PC(两阶段提交)、3PC(三阶段提交)、TCC(Try-Confirm-Cancel)、Saga、本地消息表、事务消息等。其中2PC是出现最早、理论最扎实、也是很多其他方案思想源头的一种。
2PC的全称是Two-Phase Commit,中文叫两阶段提交。它把一个分布式事务的提交过程拆成两个阶段:准备阶段(Prepare Phase)和提交阶段(Commit Phase)。整个流程由一个协调者(Coordinator)统一指挥,多个参与者(Participant)配合执行。
为什么先讲2PC?因为它是理解分布式事务的“地基”。你搞懂了2PC的投票机制、阻塞问题、单点故障,再看3PC怎么改进、TCC怎么变通、Saga怎么补偿,思路会清晰很多。很多团队在选型时直接跳过2PC,但面试和架构评审里,2PC永远是绕不开的必答题。
1.3 适合哪些读者和场景
这篇内容适合三类人:一是正在做微服务拆分、被跨库一致性问题困扰的后端开发;二是准备系统学习分布式事务、想搞懂底层原理的技术爱好者;三是需要在架构评审中讲清楚方案选型理由的技术负责人。
需要提前说明的是,2PC不是银弹,它有明确的适用边界。它更适合对一致性要求极高、参与方数量可控、事务执行时间较短的场景,比如跨库的账户扣减、库存锁定等。如果你的业务涉及十几个微服务、每个操作耗时几百毫秒,硬上2PC大概率会把系统拖垮。具体原因后面会详细拆解。
2. 2PC的核心原理拆解
2.1 两个阶段到底在干什么
先打个生活化的比方。假设你和几个朋友合伙买一批设备,你是牵头人(协调者),朋友们是出资方(参与者)。流程是这样的:
第一阶段,你挨个问每个朋友:“钱准备好了吗?准备好就告诉我,但先别打款。”每个朋友检查自己账户,回复“准备好了”或者“钱不够”。这就是准备阶段,也叫投票阶段。
第二阶段,如果所有人都回复“准备好了”,你就通知大家“统一打款”;只要有一个人说“钱不够”,你就通知所有人“取消,别打款了”。这就是提交阶段,也叫执行阶段。
对应到技术层面,准备阶段协调者向所有参与者发送prepare请求,参与者执行事务操作(但不提交),把undo和redo日志写好,锁定相关资源,然后回复Yes或No。提交阶段协调者根据所有参与者的回复决定全局提交还是全局回滚,并把决定发给所有参与者执行。
这里有个关键点:准备阶段参与者已经执行了操作,只是没有提交。这意味着资源已经被占住了,其他事务想动这些数据就得等着。这是2PC阻塞问题的根源。
2.2 协调者与参与者的角色分工
协调者通常是一个独立的事务管理器组件,也可以是发起全局事务的那个服务。它的职责包括:开启全局事务、向所有参与者广播prepare、收集投票结果、做出全局决策、广播commit或rollback、处理超时和异常。
参与者的职责相对简单:接收prepare请求、执行本地事务操作、记录日志、锁定资源、返回投票结果、等待最终指令、执行提交或回滚。
角色分工看似清晰,但实际落地时有个坑:协调者本身也可能宕机。如果协调者在发出prepare之后、做出决策之前挂了,所有参与者就会一直卡在准备阶段,资源锁着不放,整个系统可能陷入停滞。这就是经典的协调者单点故障问题。
2.3 投票机制与全局决策逻辑
投票机制是2PC的核心。协调者的决策规则非常严格:
- 所有参与者都投
Yes,全局提交。 - 任意一个参与者投
No,全局回滚。 - 任意一个参与者超时未回复,协调者通常按
No处理,触发全局回滚。
为什么超时按失败处理?因为协调者无法区分参与者是“正在处理”还是“已经挂了”。为了保证一致性,宁可回滚也不能冒险提交。这个选择牺牲了可用性,换来了强一致性,符合CAP理论中偏向CP的取舍。
全局决策一旦做出,就必须持久化到协调者的日志里。哪怕协调者随后宕机重启,也能从日志里恢复决策,继续通知参与者。这一点非常关键,否则参与者可能永远等不到最终指令。
2.4 与3PC的关键差异
3PC在2PC基础上加了一个阶段,把准备阶段拆成CanCommit和PreCommit,再加上最后的DoCommit。同时引入了超时机制:参与者在等待PreCommit或DoCommit超时后,可以自行做出决策,而不是无限期阻塞。
3PC的改进点在于降低了阻塞范围,但代价是逻辑更复杂,而且在网络分区场景下仍可能出现不一致。实际生产中3PC用得并不多,更多是作为理论演进的一个中间形态。理解2PC之后再去看3PC,你会发现它并没有从根本上解决协调者单点问题和网络分区问题,只是缓解了阻塞。
3. 2PC的实操落地要点
3.1 数据库层面的XA规范
说到2PC的落地,就绕不开XA规范。XA是X/Open组织提出的分布式事务处理标准,定义了事务管理器(TM)和资源管理器(RM)之间的接口。主流关系型数据库基本都支持XA,比如MySQL、PostgreSQL、Oracle等。
在MySQL中,XA事务的语法大致是这样的:
XA START 'transaction_id'; -- 执行具体的SQL操作 UPDATE account SET balance = balance - 100 WHERE user_id = 1; XA END 'transaction_id'; XA PREPARE 'transaction_id'; -- 此时事务处于准备状态,等待最终指令 XA COMMIT 'transaction_id'; -- 或者 XA ROLLBACK 'transaction_id';XA START开启一个XA事务,XA END表示SQL执行完毕,XA PREPARE进入准备阶段,XA COMMIT或XA ROLLBACK是最终提交或回滚。协调者需要为每个参与者维护一个全局事务ID,确保各分支事务能正确关联。
注意:MySQL的XA事务在准备阶段会持有行锁,直到最终提交或回滚才释放。如果协调者迟迟不发指令,锁会一直占着,其他事务只能排队等待。
3.2 一个可运行的Java示例框架
下面用Java伪代码展示一个简化的2PC协调者实现思路,帮助理解流程。实际生产不会自己手写协调者,而是用Seata、Atomikos这类框架,但手写一遍对理解原理极有帮助。
public class TwoPhaseCommitCoordinator { private List<Participant> participants; private String globalTxId; public boolean executeTransaction() { // 第一阶段:准备 List<Boolean> votes = new ArrayList<>(); for (Participant p : participants) { try { boolean ok = p.prepare(globalTxId); votes.add(ok); } catch (Exception e) { votes.add(false); } } // 决策 boolean allYes = votes.stream().allMatch(v -> v); // 第二阶段:提交或回滚 if (allYes) { for (Participant p : participants) { p.commit(globalTxId); } return true; } else { for (Participant p : participants) { p.rollback(globalTxId); } return false; } } }参与者接口大致定义如下:
public interface Participant { boolean prepare(String txId); void commit(String txId); void rollback(String txId); }这段代码省略了日志持久化、超时处理、重试机制等关键细节,但核心投票和决策逻辑已经体现出来了。真实框架里,prepare的结果要落盘,commit和rollback要支持幂等重试,协调者状态要能恢复。
3.3 超时与重试的处理策略
超时处理是2PC落地最容易出问题的地方。协调者发出prepare后,必须设置合理的超时时间。超时太短,正常慢查询会被误判为失败;超时太长,故障时资源锁定时间过久。
我的经验是:超时时间设置为参与者平均响应时间的3到5倍。比如参与者平时处理一个prepare请求平均50毫秒,超时可以设200到300毫秒。同时要配合重试,但重试次数不宜过多,一般2到3次即可,避免雪崩。
重试必须保证幂等。参与者收到重复的commit指令时,如果本地事务已经提交,直接返回成功即可,不能重复执行。这要求参与者在本地记录事务状态,通常用一张事务日志表来维护。
3.4 日志与恢复机制
协调者和参与者都需要持久化事务日志。协调者的日志记录全局事务ID、参与者列表、当前阶段、最终决策。参与者的日志记录分支事务ID、所属全局事务ID、本地操作内容、当前状态。
恢复流程是这样的:协调者重启后,扫描日志中处于“已决策但未完成通知”的事务,重新向参与者发送commit或rollback。参与者重启后,扫描日志中处于“已准备但未收到最终指令”的事务,主动向协调者查询决策。
提示:日志表要定期清理,否则会无限膨胀。一般保留最近7到30天的记录,确认所有事务都已终结后归档或删除。
4. 常见问题与排查技巧
4.1 参与者长时间锁定资源怎么办
这是2PC最常被吐槽的问题。准备阶段参与者已经执行了操作并加锁,如果协调者迟迟不做决策,锁就一直不释放。在高并发场景下,这可能导致大量请求超时。
排查思路:先看协调者日志,确认是否卡在收集投票阶段。如果某个参与者一直没返回,检查该参与者的数据库连接池是否耗尽、是否有慢SQL、是否发生了死锁。如果协调者本身挂了,需要尽快恢复协调者,让它从日志中恢复决策并通知参与者。
缓解手段包括:设置prepare阶段的锁等待超时、限制单个全局事务的参与者数量、对耗时长的操作做拆分。但根本上,2PC不适合长事务,这是它的基因决定的。
4.2 协调者宕机后的数据一致性
协调者宕机分两种情况。一种是在发出prepare之前挂了,此时参与者还没执行任何操作,直接回滚即可,没有一致性问题。另一种是在发出prepare之后、做出决策之前挂了,参与者处于准备状态,资源锁定,等待指令。
第二种情况最麻烦。如果协调者没有持久化决策日志,重启后无法判断该提交还是回滚。这时候只能人工介入,或者依赖参与者之间的协商机制。所以协调者必须把决策日志落盘,这是2PC可靠性的生命线。
4.3 网络分区导致的脑裂问题
网络分区时,协调者和部分参与者失去联系,可能做出错误的全局决策。比如协调者认为某个参与者挂了,决定回滚,但实际上该参与者只是网络不通,本地事务已经准备好。等网络恢复后,参与者收到回滚指令,数据就出现了不一致。
2PC本身无法完美解决网络分区问题,这是它的理论局限。实际生产中,通常会结合超时、重试、人工补偿等手段来降低风险。如果业务对分区容忍度极低,可能需要考虑TCC或Saga这类柔性事务方案。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 参与者锁等待超时 | 协调者未发最终指令 | 检查协调者日志和状态 | 恢复协调者,重发指令 |
| 全局事务长时间未结束 | 某个参与者响应慢 | 查看参与者慢SQL和连接池 | 优化SQL,调整超时时间 |
| 数据不一致 | 协调者决策未持久化 | 检查协调者日志表 | 补全日志机制,人工修复 |
| 重复提交 | 重试未做幂等 | 检查参与者事务状态表 | 增加幂等校验 |
| 协调者重启后状态丢失 | 日志未落盘 | 检查日志持久化配置 | 开启同步刷盘 |
4.5 几个踩过的坑
第一个坑:把2PC用在跨多个微服务的业务编排上。微服务之间的调用链路长,每个服务内部还可能调用其他服务,参与者数量一多,2PC的协调开销和阻塞时间呈指数级上升。后来改成只在数据库层面用XA,服务之间用事务消息,效果好很多。
第二个坑:忽略参与者的幂等设计。网络抖动导致协调者重发commit指令,参与者重复执行,数据被改了两次。后来在参与者侧加了一张事务状态表,每次执行前先查状态,已完成的直接返回。
第三个坑:超时时间设置过短。高峰期数据库响应变慢,prepare请求频繁超时,协调者误判为失败触发回滚,导致大量正常事务被回滚。后来把超时时间从100毫秒调整到500毫秒,并增加了动态调整机制。
5. 2PC的适用边界与替代思路
5.1 什么时候该用2PC
2PC最适合的场景有几个特征:参与方数量少(一般不超过5个)、事务执行时间短(毫秒级)、对强一致性要求极高、参与方都支持XA协议。比如跨两个数据库的账户转账、跨库的订单和库存扣减。
如果参与方超过10个,或者单个操作耗时超过1秒,或者参与方包含不支持XA的存储(比如Redis、MongoDB),2PC就不是好选择。硬上只会让系统变得脆弱。
5.2 与TCC、Saga的对比
TCC把操作拆成Try、Confirm、Cancel三个阶段,Try阶段做资源预留,Confirm做确认,Cancel做补偿。相比2PC,TCC不依赖数据库的XA支持,锁的粒度更细,但业务侵入性大,需要为每个操作写三个方法。
Saga把长事务拆成一串本地事务,每个本地事务都有对应的补偿操作。Saga适合长流程业务,比如订单履约、旅行预订,但补偿逻辑复杂,且只能保证最终一致性。
| 方案 | 一致性 | 业务侵入 | 适用场景 |
|---|---|---|---|
| 2PC | 强一致 | 低 | 短事务、少参与方 |
| TCC | 最终一致 | 高 | 资源预留型业务 |
| Saga | 最终一致 | 中 | 长流程业务 |
5.3 生产环境的选型建议
我的建议是:能用本地事务就不用分布式事务,能用柔性事务就不用强一致事务。很多业务场景其实可以接受最终一致性,通过消息队列、定时对账就能解决,没必要上2PC。
如果确实需要强一致,优先考虑数据库层面的XA,把参与方控制在最小范围。如果参与方多、流程长,考虑TCC或Saga。选型时不要只看理论,要结合团队的技术储备和运维能力。2PC的协调者组件如果没人能维护,出了问题就是灾难。
5.4 一个务实的演进路径
从单库到分布式,我一般建议这样演进:先做业务拆分但共用一个数据库,用本地事务保证一致性;然后按业务边界拆库,跨库操作先用本地消息表做最终一致;如果某些核心链路必须强一致,再引入2PC或TCC。
这个路径的好处是每一步都有退路,不会一上来就搞最复杂的方案。很多团队一拆微服务就上分布式事务框架,结果业务还没跑通,先被事务问题拖垮了。
6. 写在最后的一点体会
2PC这个方案,理论很优雅,落地很骨感。它的投票机制和全局决策逻辑是理解分布式事务的绝佳入口,但它的阻塞特性和单点故障问题也是真实存在的。我在实际项目里用2PC,基本都是限定在数据库层面的XA,参与方控制在两三个,事务执行时间压到毫秒级。
如果你正在做分布式事务选型,我的建议是先问自己三个问题:业务真的需要强一致吗?参与方能不能再少一点?团队有没有能力维护协调者组件?这三个问题想清楚了,方案基本就定了。
最后分享一个小技巧:不管用哪种分布式事务方案,对账系统都是最后的防线。再完美的方案也可能出问题,定期对账能帮你及时发现不一致并修复。我在几个项目里都加了对账任务,虽然不能防止问题发生,但至少能在用户投诉之前把问题找出来。