2. 强一致性方案:2PC(两阶段提交)原理、优缺点与协调者单点问题

聊到分布式事务,很多人第一个想到的就是两阶段提交(2PC)。

说实话,2PC 是个老古董了。早在 80 年代的数据库论文里就有它的身影。但直到今天,很多金融、支付系统里依然在用。为什么?因为它够简单,够直接,能保证强一致性。

我当年刚接触分布式系统时,第一个学的分布式事务协议就是 2PC。当时觉得这玩意儿太直观了——不就是先问一圈,再统一执行吗?后来踩了坑才发现,事情没那么简单。

2.1 两阶段提交的核心思想

2PC 把一次分布式事务拆成两个阶段:准备阶段提交阶段

参与的角色有两个:

  • 协调者(Coordinator):通常是事务管理器,负责调度所有参与者。
  • 参与者(Participant):一般是各个数据库或资源管理器,负责执行本地事务。

说白了,协调者就是那个发号施令的人。参与者就是干活的人。

2.2 第一阶段:准备阶段(Voting Phase)

这个阶段,协调者会问所有参与者:「我要提交事务了,你们准备好了吗?」

具体流程是这样的:

  1. 协调者向所有参与者发送 prepare 请求。
  2. 每个参与者收到请求后,执行本地事务,但不提交
  3. 参与者把执行结果(Undo/Redo 日志)写入本地磁盘。
  4. 如果执行成功,返回 Yes;如果失败,返回 No

关键点:参与者一旦回复 Yes,就意味着它承诺——哪怕之后宕机重启,也要保证能提交这个事务。因为日志已经落盘了。

嗯,这里要注意。我在项目中遇到过一种情况:参与者回复了 Yes,但协调者迟迟没给下一步指令。这时候参与者只能一直等,资源也被锁着。这就是 2PC 的阻塞问题,后面会细说。

2.3 第二阶段:提交阶段(Commit Phase)

第二阶段就简单了。协调者根据第一阶段的投票结果做决策:

  • 全部 Yes:发送 commit 指令,所有参与者提交事务。
  • 有 No 或超时:发送 abort 指令,所有参与者回滚事务。

你想想看,这个逻辑其实很符合直觉。就像团队开会,先问一圈「方案行不行」,大家都说行,那就执行。有人说不行,那就推翻重来。

但问题在于——如果协调者在第二阶段挂了,怎么办?

2.4 2PC 的优缺点分析

先说说优点,毕竟它能在业界活这么多年,肯定有它的道理。

优点

  • 强一致性:要么全部提交,要么全部回滚。数据不会出现部分更新。
  • 实现简单:协议本身很直观,容易理解和编码。
  • 兼容性好:很多数据库(MySQL、Oracle、PostgreSQL)原生支持 XA 协议,可以直接用 2PC。

我记得有一次做银行核心系统改造,老系统用的是本地事务,迁移到分布式架构后,业务方最担心的就是数据不一致。最后我们选了 2PC,虽然性能有损耗,但至少账不会错。

缺点

缺点嘛,说实话,挺多的。我一个个说。

  • 同步阻塞:参与者准备阶段会锁住资源。如果协调者挂了,参与者只能一直等。系统吞吐量直接打折扣。
  • 单点故障:协调者是单点。它一挂,整个事务就卡住了。
  • 数据不一致风险:第二阶段,协调者发送 commit 后挂了。部分参与者收到了指令并提交,部分没收到。数据就不一致了。
  • 性能问题:两轮网络交互,加上磁盘日志写入,延迟很高。不适合高并发场景。

避坑指南:我曾经在一个秒杀系统里尝试用 2PC 做库存扣减。结果压测时,数据库连接池直接被打满。所有线程都在等锁。后来换成了 TCC 模式才解决问题。所以,高并发场景下慎用 2PC。

2.5 协调者单点问题深度剖析

协调者单点问题,是 2PC 最致命的缺陷之一。

为什么会这样?因为整个事务的进度都依赖协调者。它一旦宕机,所有参与者都会陷入「悬而未决」的状态。

具体来说,分两种情况:

故障时机 影响 恢复方式
第一阶段宕机 参与者还没收到 prepare,事务直接超时回滚 参与者超时检测,自动回滚
第二阶段宕机 部分参与者已提交,部分未提交,数据不一致 需要人工介入或协调者恢复后查询日志

你看,第二阶段宕机是最麻烦的。参与者不知道是该提交还是该回滚。只能一直等协调者恢复。

我个人的习惯是,在生产环境中给协调者做高可用。比如用 ZooKeeper 或 etcd 做选主,协调者挂了自动切换。但即便如此,切换期间的事务还是可能卡住。

我的建议:如果业务对一致性要求极高,且能接受一定的性能损耗,可以考虑 2PC + 协调者主备切换。但一定要做好超时补偿机制。比如,参与者可以设置一个超时时间,超时后主动查询协调者状态。

2.6 2PC 的典型应用场景

说了这么多缺点,那 2PC 到底适合什么场景?

  • 金融转账:A 扣钱,B 加钱。必须同时成功或失败。
  • 库存扣减:多个仓库同时扣库存,不能出现超卖。
  • 订单与支付:订单状态和支付状态必须一致。

说白了,就是那些「一分钱都不能错」的场景。性能可以慢,但数据必须准。

我记得之前做一个跨境支付系统,对接了多个国家的银行。每个银行都有自己的数据库,事务必须跨库。最后我们选了 2PC + XA 协议。虽然延迟高了点,但上线两年没出过数据不一致的问题。

2.7 代码示例:一个简化的 2PC 协调者

下面我用伪代码展示一下协调者的核心逻辑。方便你理解整个流程。

class Coordinator {
    List<Participant> participants;

    boolean executeTransaction() {
        // 第一阶段:准备
        for (Participant p : participants) {
            boolean ok = p.prepare();
            if (!ok) {
                // 有参与者准备失败,发起回滚
                rollback();
                return false;
            }
        }

        // 第二阶段:提交
        for (Participant p : participants) {
            p.commit();
        }
        return true;
    }

    void rollback() {
        for (Participant p : participants) {
            p.abort();
        }
    }
}

你看,代码本身很简单。但实际生产环境里,要考虑网络超时、宕机恢复、日志持久化等问题。复杂度一下子就上去了。

2.8 小结

2PC 是分布式事务的入门级方案。它简单、直观、能保证强一致性。但缺点也很明显:阻塞、单点、性能差。

我个人觉得,2PC 适合那些对一致性要求极高、并发量不大的场景。如果你的系统需要高吞吐,或者服务经常宕机,那还是看看 TCC 或 Saga 吧。

下一章,我会讲 3PC(三阶段提交),它解决了 2PC 的阻塞问题,但引入了新的复杂度。到时候咱们再细聊。