3. 强一致性方案之3PC:三阶段提交协议详解
聊完2PC,咱们来看看它的升级版——3PC。
说实话,我第一次接触3PC时,觉得它就是把2PC的"二段"拆成了"三段",没啥新鲜的。直到我在一个跨境支付系统里真正用上它,才体会到这多出来的一步有多关键。
3.1 三阶段提交的核心流程
3PC把整个提交过程拆成了三个阶段:CanCommit、PreCommit、DoCommit。
你想想看,2PC最大的痛点是什么?是协调者挂了,参与者只能干等。3PC引入了一个"中间状态",让系统有了回旋余地。
核心思想:在真正提交之前,先让所有参与者确认"我能干",再让所有人准备"我要干了",最后才真正"干"。每一步都有超时机制,不会死锁。
第一阶段:CanCommit(询问阶段)
协调者问所有参与者:"我要提交事务了,你们能行吗?"
参与者检查自身状态,比如资源是否充足、锁是否能拿到。能就回复YES,不能就回复NO。
这一步其实很轻量。我在项目中遇到过一种情况:某个参与者明明能提交,但因为网络延迟,回复超时了。协调者直接判定为NO,整个事务就回滚了。嗯,这里要注意——超时就是NO,这是3PC的默认策略。
第二阶段:PreCommit(预提交阶段)
如果所有参与者都回复YES,协调者就发PreCommit指令。
参与者收到后,开始执行事务操作,但不提交。只是把数据写到undo/redo日志里,把资源锁住。
这一步完成后,参与者会回复ACK。
个人经验:PreCommit阶段是3PC的精髓。它让所有参与者进入"待提交"状态,即使协调者挂了,参与者也能根据超时自行决定下一步。我曾经在一个支付系统中,就因为PreCommit的超时机制,避免了2PC中常见的"协调者宕机导致全局死锁"问题。
第三阶段:DoCommit(最终提交阶段)
协调者收到所有ACK后,发DoCommit指令。
参与者收到后,正式提交事务,释放资源。
如果某个参与者没收到DoCommit(比如网络断了),它会等待超时,然后自动提交。为什么?因为PreCommit已经确认了所有参与者都能提交,所以超时后默认提交是安全的。
3.2 3PC如何解决2PC的问题
2PC有两个硬伤:单点阻塞和脑裂风险。3PC是怎么解决的?
| 问题 | 2PC的表现 | 3PC的改进 |
|---|---|---|
| 协调者宕机 | 参与者阻塞,一直等待 | 参与者超时后自动提交或回滚 |
| 网络分区 | 参与者无法达成一致 | PreCommit阶段确认了所有参与者的能力 |
| 资源锁定时间 | 从准备到提交一直锁着 | CanCommit阶段不锁资源,PreCommit才锁 |
| 容错能力 | 几乎为零 | 引入了超时和自动决策机制 |
说白了,3PC的核心改进就是:引入超时机制 + 增加中间状态。让系统在异常情况下能"自己拿主意",而不是死等协调者。
注意:3PC并没有完全解决脑裂问题。如果网络分区发生在PreCommit之后,一部分参与者收到了DoCommit,另一部分没收到,那么没收到的那部分会超时自动提交。结果就是——所有参与者都提交了,但数据可能不一致。这种情况虽然概率极低,但确实存在。
3.3 3PC的代码实现思路
咱们来看看3PC的核心代码怎么写。我习惯用状态机来管理事务状态。
// 事务状态枚举
enum TransactionState {
INIT,
CAN_COMMIT,
PRE_COMMIT,
DO_COMMIT,
ABORT
}
// 协调者核心逻辑
class Coordinator {
private Map<String, TransactionState> participantStates;
private ScheduledExecutorService scheduler;
// 第一阶段:CanCommit
public boolean canCommit(String transactionId) {
// 向所有参与者发送CanCommit请求
List<Boolean> responses = broadcastCanCommit(transactionId);
// 全部回复YES才继续
if (responses.stream().allMatch(r -> r)) {
participantStates.put(transactionId, TransactionState.CAN_COMMIT);
return true;
}
// 有NO或超时,直接回滚
abort(transactionId);
return false;
}
// 第二阶段:PreCommit
public boolean preCommit(String transactionId) {
// 发送PreCommit指令
List<Boolean> acks = broadcastPreCommit(transactionId);
if (acks.stream().allMatch(r -> r)) {
participantStates.put(transactionId, TransactionState.PRE_COMMIT);
return true;
}
// 有参与者预提交失败,回滚
abort(transactionId);
return false;
}
// 第三阶段:DoCommit
public void doCommit(String transactionId) {
// 发送最终提交指令
broadcastDoCommit(transactionId);
participantStates.put(transactionId, TransactionState.DO_COMMIT);
}
}
// 参与者核心逻辑
class Participant {
private TransactionState state;
private Timer timer;
// 处理CanCommit请求
public boolean handleCanCommit(String transactionId) {
// 检查资源是否充足
if (checkResources()) {
state = TransactionState.CAN_COMMIT;
return true;
}
return false;
}
// 处理PreCommit请求
public boolean handlePreCommit(String transactionId) {
// 执行预提交操作,写入日志
writeUndoRedoLog();
state = TransactionState.PRE_COMMIT;
// 启动超时定时器
startTimeoutTimer(() -> {
// 超时后自动提交
if (state == TransactionState.PRE_COMMIT) {
doCommit(transactionId);
}
});
return true;
}
// 处理DoCommit请求
public void handleDoCommit(String transactionId) {
// 正式提交
commitTransaction();
state = TransactionState.DO_COMMIT;
cancelTimeoutTimer();
}
}
避坑指南:我曾经在实现PreCommit超时逻辑时,把超时时间设得太短。结果网络稍微波动一下,参与者就自动提交了,而协调者还在等ACK。最后导致数据不一致。我的建议是:超时时间至少是网络延迟的3倍,并且要加上随机抖动,避免所有参与者同时超时造成"惊群效应"。
3.4 3PC的适用场景
3PC不是银弹。它适合的场景是:
- 对一致性要求极高,但能接受少量性能损失
- 参与者数量可控,一般不超过10个节点
- 网络环境相对稳定,但需要一定的容错能力
我在金融交易系统中用过3PC,主要是处理跨行转账的"准备-确认-提交"流程。说实话,性能确实不如2PC,但胜在稳定。你想想看,银行转账少个几毫秒无所谓,但要是因为协调者宕机导致资金卡住,那麻烦就大了。
总结一下:3PC用"多一步确认"换来了"更好的容错性"。它没有完全解决分布式事务的所有问题,但在实际工程中,比2PC靠谱得多。如果你在做一个对一致性要求极高的系统,3PC值得考虑。