2PC 的阻塞问题与缺陷

某电商平台在双十一高峰期,订单系统出现了大规模卡顿。

经过排查,原因是:数据库管理员在维护时重启了一台服务器,这台服务器恰好是 2PC 分布式事务的协调者。

结果:

  • 参与事务的其他数据库节点,都处于"等待协调者指令"的状态
  • 所有相关行的锁都没有释放
  • 其他业务请求开始堆积
  • 最终导致整个订单系统超时

这是一个典型的 2PC 协调者故障导致的级联阻塞问题。

【架构权衡】 2PC 的阻塞问题不是"小概率事件",而是在生产环境中必然会遇到的问题。任何选择 2PC 作为分布式事务方案的团队,都必须提前设计好协调者的 HA(高可用)方案,以及参与者的超时恢复机制。


一、问题背景

1.1 阻塞问题的定义

2PC 阻塞问题:

正常流程:
参与者1 ──PREPARE──► 协调者 ──COMMIT──► 参与者2
     ◄─────────────────────────────
     提交完成,锁释放

阻塞流程:
参与者1 ──PREPARE──► 协调者(崩溃)
     ◄─────────────────────── ???
     等待协调者指令
     锁无法释放 ──────────────────────► 其他操作阻塞

1.2 阻塞的触发条件

阻塞发生的条件:

条件1:协调者崩溃
├─ 场景:协调者在发送 COMMIT/ROLLBACK 之前崩溃
├─ 影响:参与者永远等待
└─ 发生概率:取决于协调者可靠性

条件2:参与者崩溃
├─ 场景:参与者在回复 PREPARE OK 后崩溃
├─ 影响:协调者永远等待
└─ 发生概率:取决于参与者可靠性

条件3:网络分区
├─ 场景:协调者和参与者之间的网络中断
├─ 影响:互相等待
└─ 发生概率:网络抖动是常态

二、缺陷详解

2.1 缺陷一:协调者单点故障

// 单点故障的代码表现
public class SingleCoordinator {
    private Participant participant = new Participant();

    public void commit(String transactionId) {
        // 单点:协调者没有备份
        boolean ready = participant.prepare(transactionId);
        if (ready) {
            participant.commit(transactionId);
        }
    }
}

// 问题:如果 commit() 执行到一半,进程崩溃
// participant.prepare() 已成功,participant.commit() 未执行
// participant 的资源永远锁定

协调者故障恢复的困境

协调者崩溃前的状态:
├─ 已发送 PREPARE
├─ 已收到所有参与者的 OK
├─ 准备发送 COMMIT
└─ 此时崩溃

参与者状态:
├─ 收到 PREPARE
├─ 已锁定资源
├─ 已回复 OK
└─ 等待 COMMIT

协调者恢复后:
├─ 从 WAL 读取事务状态
├─ 发现事务处于"预提交"状态
├─ 重新发送 COMMIT
└─ 问题:参与者是否收到了之前的 COMMIT?

2.2 缺陷二:同步阻塞

// 阻塞导致的性能问题
public class BlockingCoordinator {
    public void commit(List<Participant> participants) {
        // 同步等待所有参与者
        for (Participant participant : participants) {
            participant.prepare(transactionId); // 同步阻塞
        }

        // 等待所有参与者完成
        for (Participant participant : participants) {
            participant.commit(transactionId); // 同步阻塞
        }
    }
}

// 问题:
// 1. 每个参与者的延迟累加
// 2. 最慢的参与者决定整体延迟
// 3. 如果有一个参与者响应慢,所有参与者都要等待
阻塞对性能的影响:

T=0:   协调者发送 PREPARE
T=100: 参与者1 响应
T=200: 参与者2 响应
T=500: 参与者3 响应  ← 最慢
T=600: 协调者收到所有响应,发送 COMMIT
T=700: 参与者1 COMMIT 完成
T=800: 参与者2 COMMIT 完成
T=1100: 参与者3 COMMIT 完成 ← 整体耗时 1100ms

如果参与者3 延迟更高(如跨机房),整体延迟可能达到秒级

2.3 缺陷三:数据不一致

数据不一致的触发场景:

协调者发送 COMMIT 给参与者1
参与者1 收到 COMMIT 并提交
协调者崩溃(在发送 COMMIT 给参与者2 之前)

参与者1 状态:已提交 ✓
参与者2 状态:等待 COMMIT(超时后回滚)
参与者3 状态:等待 COMMIT(超时后回滚)

结果:数据不一致
├─ 用户A 的操作在参与者1 提交了
├─ 用户B 的操作在参与者2、3 回滚了
└─ 业务逻辑被破坏

2.4 缺陷四:无法处理并发

2PC 与并发的冲突:

事务 T1:
├─ 锁定 A 账户(-100元)
└─ 锁定 B 账户(+100元)

事务 T2:
├─ 查询 A 账户余额 ── 等待(T1 锁住 A)
├─ 查询 B 账户余额 ── 等待(T1 锁住 B)
└─ 等待时间可能很长

结论:2PC 的锁定时间 = 整个事务持续时间
      并发度大幅下降

三、解决方案

3.1 协调者高可用(HA)

// 方案1:主备协调者
public class CoordinatorWithHA {
    // 使用 ZooKeeper 实现协调者选举
    private ZooKeeper zk;

    private String coordinatorPath = "/coordinator";
    private String currentCoordinator;

    public void becomeLeader() {
        // 创建临时顺序节点
        String path = zk.create(coordinatorPath + "/lock-",
            "coordinator".getBytes(),
            ZooDefs.Ids.OPEN_ACL_UNSAFE,
            CreateMode.EPHEMERAL_SEQUENTIAL);

        // 检查是否是第一个(最小的序号 = leader)
        List<String> children = zk.getChildren(coordinatorPath, true);
        Collections.sort(children);

        if (children.get(0).equals(path.substring(path.lastIndexOf('/') + 1))) {
            currentCoordinator = path;
            // 成为 leader,开始工作
            startServing();
        } else {
            // 等待当前 leader 崩溃
            waitForLeaderDeath();
        }
    }
}

3.2 参与者超时处理

// 方案2:参与者超时机制
public class ParticipantWithTimeout {
    private ExecutorService executor = Executors.newScheduledThreadPool(1);
    private volatile TransactionDecision pendingDecision;

    public boolean prepare(String transactionId) {
        connection.setAutoCommit(false);
        executeSQL();

        // 启动超时检测
        executor.schedule(() -> {
            if (pendingDecision == null) {
                // 长时间没有收到决策,查询其他参与者或协调者
                TransactionDecision decision = queryNeighbour(transactionId);
                if (decision == TransactionDecision.COMMIT) {
                    commit(transactionId);
                } else if (decision == TransactionDecision.ROLLBACK) {
                    rollback(transactionId);
                }
            }
        }, 30, TimeUnit.SECONDS);

        return true;
    }
}

3.3 三阶段提交(3PC)

3PC 是 2PC 的改进版本,通过增加一个阶段来减少阻塞:

3PC 流程:

阶段1:CanCommit?
  ├─ 协调者询问所有参与者是否可以提交
  ├─ 参与者返回 Yes/No
  └─ 如果有任何 No,发送 Abort

阶段2:PreCommit
  ├─ 协调者发送 PreCommit
  ├─ 参与者收到后执行"预提交"(但不锁定资源)
  └─ 如果参与者超时未收到,发送 Abort

阶段3:DoCommit
  ├─ 协调者发送 DoCommit
  └─ 参与者正式提交

3PC 的改进:

  • 减少了参与者的阻塞时间
  • 协调者崩溃时,参与者可以主动询问其他参与者
  • 但仍无法完全避免数据不一致问题

【架构权衡】 3PC 虽然减少了阻塞,但增加了网络往返次数,延迟更高。而且在极端网络分区情况下,仍可能出现不一致。因此 3PC 在生产环境中很少使用。

四、工程代价评估

维度2PC3PC无协调者
阻塞时间长(整个事务期间)短(减少到 Prepare 期间)
实现复杂度
一致性保证弱(最终一致)
延迟更高
网络开销
可用性

五、替代方案

方案描述适用场景
TCCTry-Confirm-Cancel业务层补偿
Saga异步补偿长事务
本地消息表最终一致可靠消息
RocketMQ 事务消息半消息 + 回调消息队列
SeataAT 模式无侵入事务

六、落地 Checklist

  • 协调者 HA:使用 ZooKeeper/Raft 实现协调者选举
  • 超时配置:设置合理的 PREPARE/COMMIT 超时时间
  • WAL 持久化:协调者和参与者都要持久化事务日志
  • 恢复流程:设计并测试协调者和参与者的崩溃恢复
  • 监控告警:监控超时事务、阻塞资源
  • 替代评估:评估 TCC、Saga 等替代方案