2PC:两阶段提交协议详解
说到分布式事务,2PC 绝对是个绕不开的话题。我最早接触它是在做银行核心系统的时候,那时候还没什么分布式概念,但跨库转账的需求已经摆在那了。嗯,说白了,2PC 就是最朴素的「大家一起成功,要么一起失败」的思路。
什么是两阶段提交?
两阶段提交,英文叫 Two-Phase Commit,简称 2PC。它把一个分布式事务拆成两个阶段:准备阶段和提交阶段。
你想想看,为什么需要两个阶段?因为在一个分布式系统里,多个节点各自为政。你让 A 扣钱,让 B 加钱,A 说「我准备好了」,B 说「我也准备好了」,这时候协调者才能拍板说「好,大家提交」。如果有一个节点说「我不行」,那就全部回滚。
这个协调者,我们通常叫它 Transaction Coordinator(TC)。参与事务的节点叫 参与者(Participant)。
核心思想:所有参与者先投票,协调者根据投票结果决定是提交还是中止。
第一阶段:准备阶段(Voting Phase)
准备阶段,也叫投票阶段。协调者会问每个参与者:「你能提交这个事务吗?」
具体流程是这样的:
- 协调者向所有参与者发送
prepare请求,带上事务内容。 - 每个参与者收到请求后,执行事务操作,但不提交。
- 参与者把操作结果写入本地事务日志,然后回复协调者:
Yes或No。
这里有个关键点:参与者回复 Yes 之后,它就不能反悔了。它必须保证后续能提交成功。所以参与者会把事务日志刷到磁盘上,确保即使宕机重启,也能恢复。
我个人的习惯:在准备阶段,我会让参与者把 undo 日志和 redo 日志都写完整。这样无论后续是提交还是回滚,都有据可依。
第二阶段:提交阶段(Commit Phase)
提交阶段,协调者根据投票结果做决策。
有两种情况:
- 全部 Yes:协调者发送
commit请求,所有参与者提交事务。 - 有 No 或超时:协调者发送
abort请求,所有参与者回滚事务。
参与者收到 commit 或 abort 后,执行相应操作,然后回复 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 的核心就是「先问后做」。所有参与者先表态,协调者再决策。但正如前面说的,这个模型在协调者崩溃时会有问题。
总结一下:2PC 适合对一致性要求极高、事务参与者不多、网络稳定的场景。比如跨库转账、账户扣款。但如果你的系统有高并发、长事务、或者网络不稳定,那就要慎重了。我个人的经验是,能用 TCC 或者 Saga 就别用 2PC,除非你真的需要那种「要么全有,要么全无」的强一致性。
公众号:蓝海资料掘金营,微信deep3321