3、强一致性方案之2PC:两阶段提交原理、协调者与参与者角色、2PC在库存扣减中的应用
3.1 为什么需要2PC?
先说说我个人的经历。几年前我负责一个电商库存系统,每天几百万单的扣减量。一开始觉得,不就是update stock set count = count - 1嘛,简单。结果上线第一天就出事了——订单系统扣了库存,但支付系统那边挂了,用户付了钱却拿不到货。嗯,这就是典型的分布式事务问题。
你想想看,库存扣减涉及多个服务:订单服务、库存服务、支付服务。每个服务都有自己的数据库。单机事务好办,ACID一把梭。但跨服务、跨数据库,怎么保证一致性?
2PC(Two-Phase Commit,两阶段提交)就是解决这个问题的经典方案。说白了,它让多个参与者达成共识:要么全部成功,要么全部回滚。
核心思想:引入一个协调者,由它来统一调度所有参与者的提交或回滚。所有参与者必须等待协调者的最终指令。
3.2 两阶段提交原理
2PC分为两个阶段:准备阶段(Prepare Phase)和提交阶段(Commit Phase)。我习惯叫它「先问能不能,再说行不行」。
第一阶段:准备阶段(Voting Phase)
- 协调者向所有参与者发送「准备提交」请求。
- 每个参与者执行本地事务,但不提交。写undo日志和redo日志。
- 参与者返回结果:
YES(可以提交)或NO(不能提交)。
这里有个坑。我曾经遇到一个案例:参与者返回YES,但实际资源已经不够了。为什么?因为参与者只是预占资源,并没有真正锁定。所以,准备阶段必须确保后续提交一定能成功,否则就是假YES。
第二阶段:提交阶段(Commit Phase)
协调者根据第一阶段的投票结果做决策:
- 全部YES:协调者发送
COMMIT指令,所有参与者正式提交事务。 - 有NO:协调者发送
ROLLBACK指令,所有参与者回滚事务。
你看,这个逻辑其实很朴素。但为什么说2PC是「强一致性」方案?因为它在提交阶段,所有参与者要么全提交,要么全回滚,不存在中间状态。
我的建议:2PC适合对一致性要求极高、并发量不高的场景。比如金融转账、库存扣减中的核心链路。如果并发量太大,2PC的阻塞问题会让你头疼。
3.3 协调者与参与者角色
这两个角色,我分别用一句话概括:
- 协调者(Coordinator):发号施令的人。负责收集投票、做决策、发指令。
- 参与者(Participant):执行任务的人。负责执行本地事务、投票、提交或回滚。
咱们用表格对比一下:
| 角色 | 职责 | 状态 | 风险点 |
|---|---|---|---|
| 协调者 | 发起请求、收集投票、决策、发送指令 | 等待、决策、完成 | 单点故障、阻塞 |
| 参与者 | 执行本地事务、投票、提交/回滚 | 就绪、预提交、已提交/已回滚 | 资源锁定、超时 |
我记得有一次线上事故,协调者挂了,所有参与者都处于「预提交」状态。数据库连接池被占满,整个系统瘫痪。这就是2PC的典型问题——同步阻塞。参与者必须等待协调者的最终指令,期间资源一直被锁定。
避坑指南:我曾经因为没处理好协调者宕机,导致库存扣减服务大面积超时。后来加了超时机制和补偿任务,才把问题解决。记住:2PC不是银弹,它有自己的适用边界。
3.4 2PC在库存扣减中的应用
好,咱们直接上实战。假设一个库存扣减场景:用户下单购买商品A,需要扣减库存、生成订单、扣减账户余额。
参与的服务有三个:
- 库存服务:扣减商品库存
- 订单服务:创建订单记录
- 账户服务:扣减用户余额
协调者由订单服务兼任(也可以独立部署一个协调者服务)。
代码示例:协调者逻辑(伪代码)
// 协调者:两阶段提交核心逻辑
public boolean twoPhaseCommit(Order order) {
// 第一阶段:准备
List<Participant> participants = getParticipants();
Map<String, Boolean> votes = new HashMap<>();
for (Participant p : participants) {
boolean canCommit = p.prepare(order);
votes.put(p.getName(), canCommit);
if (!canCommit) {
// 有参与者说NO,直接进入回滚
rollbackAll(participants);
return false;
}
}
// 第二阶段:提交
for (Participant p : participants) {
p.commit(order);
}
return true;
}
// 参与者:库存服务准备逻辑
public boolean prepare(Order order) {
// 预占库存,但不提交
int result = stockMapper.preDeductStock(order.getSkuId(), order.getQuantity());
if (result > 0) {
// 写undo日志,用于回滚
undoLogService.save(order.getOrderId(), "stock", order.getSkuId(), order.getQuantity());
return true;
}
return false;
}
// 参与者:库存服务提交逻辑
public void commit(Order order) {
// 正式提交,释放预占资源
stockMapper.confirmDeduct(order.getSkuId(), order.getQuantity());
undoLogService.delete(order.getOrderId());
}
你看,代码其实不复杂。但实际生产环境要考虑的东西很多:
- 超时处理:参与者长时间不响应怎么办?协调者要设置超时,超时后按NO处理。
- 协调者宕机:参与者一直等?加一个超时自动回滚机制。
- 网络分区:协调者发COMMIT,部分参与者没收到?需要重试机制。
核心要点:2PC在库存扣减中,本质上是把「扣减库存」这个操作拆成了两步——先预占,再确认。预占阶段保证资源可用,确认阶段保证最终一致性。
3.5 2PC的优缺点总结
我直接说结论:
- 优点:强一致性、实现简单、原理清晰。
- 缺点:同步阻塞、协调者单点、性能差(尤其第二阶段)。
你可能会问:那为什么还要用2PC?因为有些场景,一致性比性能更重要。比如库存扣减,多扣了就是资损,少扣了就是超卖。两害相权取其轻,2PC虽然慢,但至少不出错。
嗯,这里要注意:2PC不是万能的。如果你的系统并发量超过每秒几千笔,建议考虑TCC或者Saga。但如果你刚起步,业务量不大,2PC完全够用。
我的经验:中小型项目用2PC,大型项目用TCC。别一上来就追求高大上的方案,先把业务跑通再说。
3.6 本章知识体系图
下面这张图,我画了2PC的核心流程和角色关系。你看一眼就能记住。
这张图把2PC的流程讲得很清楚了。你记住两个阶段、三个角色、一个决策点,基本就掌握了2PC的核心。
好了,这一章就到这里。2PC虽然古老,但它是理解分布式事务的基石。下一章咱们聊聊TCC,那玩意儿比2PC灵活多了。