2PC:两阶段提交协议详解

说到分布式事务,2PC 绝对是个绕不开的话题。我最早接触它是在做银行核心系统的时候,那时候还没什么分布式概念,但跨库转账的需求已经摆在那了。嗯,说白了,2PC 就是最朴素的「大家一起成功,要么一起失败」的思路。

什么是两阶段提交?

两阶段提交,英文叫 Two-Phase Commit,简称 2PC。它把一个分布式事务拆成两个阶段:准备阶段提交阶段

你想想看,为什么需要两个阶段?因为在一个分布式系统里,多个节点各自为政。你让 A 扣钱,让 B 加钱,A 说「我准备好了」,B 说「我也准备好了」,这时候协调者才能拍板说「好,大家提交」。如果有一个节点说「我不行」,那就全部回滚。

这个协调者,我们通常叫它 Transaction Coordinator(TC)。参与事务的节点叫 参与者(Participant)

核心思想:所有参与者先投票,协调者根据投票结果决定是提交还是中止。

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

准备阶段,也叫投票阶段。协调者会问每个参与者:「你能提交这个事务吗?」

具体流程是这样的:

  1. 协调者向所有参与者发送 prepare 请求,带上事务内容。
  2. 每个参与者收到请求后,执行事务操作,但不提交
  3. 参与者把操作结果写入本地事务日志,然后回复协调者:YesNo

这里有个关键点:参与者回复 Yes 之后,它就不能反悔了。它必须保证后续能提交成功。所以参与者会把事务日志刷到磁盘上,确保即使宕机重启,也能恢复。

我个人的习惯:在准备阶段,我会让参与者把 undo 日志和 redo 日志都写完整。这样无论后续是提交还是回滚,都有据可依。

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

提交阶段,协调者根据投票结果做决策。

有两种情况:

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

参与者收到 commitabort 后,执行相应操作,然后回复 ack 给协调者。协调者收到所有 ack 后,事务结束。

为什么会这样设计?因为协调者必须确保每个参与者都知道了最终决策。如果某个参与者没收到 commit,它就会一直卡在「已准备」状态,这可不是什么好事。

我曾经踩过的坑:有一次网络分区,协调者发送的 commit 请求丢失了。参与者一直等,协调者也一直等。最后只能人工介入,查日志手动提交。所以,超时重试机制一定要加上。

2PC 的优缺点分析

2PC 看起来很完美,对吧?但实际用起来,问题不少。我列几个典型的:

同步阻塞

这是 2PC 最大的痛点。在准备阶段,参与者会持有资源锁(比如数据库的行锁)。如果协调者挂了,参与者只能一直等。你想想看,一个事务锁住一行数据几分钟,这在交易系统里简直是灾难。

我记得有一次压测,一个 2PC 事务因为协调者 GC 停顿,导致所有参与者锁了 30 秒。那 30 秒里,整个系统的 TPS 直接掉到零。嗯,从那以后,我对 2PC 的同步阻塞问题就特别敏感。

单点故障

协调者是单点。如果协调者在第二阶段挂了,整个事务就悬在那了。参与者不知道是该提交还是该回滚。

更麻烦的是,协调者宕机后重启,它需要从日志里恢复状态。但如果日志没写完整,那就尴尬了——协调者自己都不知道该发 commit 还是 abort

数据不一致

理论上,2PC 能保证强一致性。但现实中,总有意外。比如协调者发了 commit 给一部分参与者,然后自己挂了。收到 commit 的提交了,没收到的还在等。这时候,数据就不一致了。

说白了,2PC 的一致性是在「协调者不崩溃」的前提下才成立的。一旦协调者崩溃,就需要额外的恢复机制来保证最终一致。

优缺点 说明 我的建议
同步阻塞 参与者持有锁,等待协调者决策 控制事务粒度,尽量短平快
单点故障 协调者宕机导致事务悬空 使用高可用方案,比如协调者集群
数据不一致 部分提交、部分未提交 配合补偿事务或定期对账

2PC 的代码实现思路

说了这么多理论,咱们看看代码怎么落地。我习惯用 Java 写一个简单的 2PC 框架,核心就三个角色:协调者、参与者、事务管理器。

先看参与者的接口:

public interface Participant {
    // 准备阶段
    boolean prepare(Transaction tx);
    
    // 提交
    boolean commit(Transaction tx);
    
    // 回滚
    boolean rollback(Transaction tx);
}

每个参与者实现这个接口。比如一个数据库参与者,prepare 里执行 SQL 但不提交,commit 里执行真正的提交。

再看协调者的核心逻辑:

public class Coordinator {
    private List<Participant> participants;
    
    public boolean execute(Transaction tx) {
        // 第一阶段:准备
        for (Participant p : participants) {
            if (!p.prepare(tx)) {
                // 有参与者准备失败,回滚
                rollbackAll(tx);
                return false;
            }
        }
        
        // 第二阶段:提交
        for (Participant p : participants) {
            if (!p.commit(tx)) {
                // 提交失败,需要人工介入或补偿
                log.error("Commit failed for participant: " + p);
                return false;
            }
        }
        return true;
    }
    
    private void rollbackAll(Transaction tx) {
        for (Participant p : participants) {
            try {
                p.rollback(tx);
            } catch (Exception e) {
                log.error("Rollback failed", e);
            }
        }
    }
}

这段代码看起来简单,但实际生产环境要复杂得多。比如:

  • 需要加超时机制,防止参与者一直不响应。
  • 需要写事务日志,保证协调者宕机后能恢复。
  • 需要重试机制,处理网络抖动。

我建议:不要自己从头写 2PC。用现成的框架,比如 Seata 的 AT 模式,或者 Atomikos。它们把很多细节都处理好了,比如日志恢复、超时重试。

流程图:2PC 核心流程

下面这张图展示了 2PC 的完整流程,从准备到提交,以及异常情况下的回滚路径。

2PC 两阶段提交流程图 协调者 (TC) 参与者 A 参与者 B 参与者 C 阶段一:准备阶段 prepare prepare prepare Yes / No Yes / No Yes / No ? 全部 Yes 有 No 阶段二:提交 commit commit commit 提交成功 提交成功 提交成功 阶段二:回滚 abort abort abort 回滚成功 回滚成功 回滚成功 最终:全部提交 或 全部回滚

从图上可以看得很清楚:2PC 的核心就是「先问后做」。所有参与者先表态,协调者再决策。但正如前面说的,这个模型在协调者崩溃时会有问题。

总结一下:2PC 适合对一致性要求极高、事务参与者不多、网络稳定的场景。比如跨库转账、账户扣款。但如果你的系统有高并发、长事务、或者网络不稳定,那就要慎重了。我个人的经验是,能用 TCC 或者 Saga 就别用 2PC,除非你真的需要那种「要么全有,要么全无」的强一致性。


公众号:蓝海资料掘金营,微信deep3321