写在前面
前两天重构一个订单系统,功能上线后一切正常,直到某天凌晨三点被电话叫醒:用户下单成功了,但是库存没扣减,支付也没回调,钱却实打实扣了。
没错,我遇到了分布式事务问题。
那一刻,我终于理解了CAP定理的真正含义——它不是一道选择题,而是一份免责声明。
CAP定理:分布式系统的"三体"问题
CAP定理说了啥?很简单:分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)中的两个。
等等,这定理谁不知道?但知道和踩坑之间,差着十个架构师。
真实情况是这样的:
- CP系统:放弃可用性,保证一致性。典型代表:ZooKeeper、etcd。问题是?等一下,等一下,等一下...然后告诉你对不起超时了。
- AP系统:放弃一致性,保证可用性。典型代表:Cassandra、DynamoDB。问题是?数据可能不一致,抱歉咱们的"最终一致性"可能要等一会儿。
- CA系统:不好意思,分布式环境下P是必须的,所以CA在分布式系统中不存在。
所以下次面试官问你CAP怎么选,请把这个问题抛回去:"业务场景是什么?"
分布式事务的几种"死法"
方法一:XA两阶段提交——理论完美,实践拉胯
两阶段提交(2PC)了解一下:
第一阶段(Prepare):事务管理器问所有参与者:"准备好了吗?"
第二阶段(Commit):所有参与者都说好了,事务管理器说:"提交!"
听起来很完美?too young too simple。
问题来了:
- 同步阻塞:所有参与者都在等别人,谁也不能动
- 单点故障:事务管理器挂了?不好意思,全部卡死
- 数据不一致:第二阶段部分参与者收到commit部分没收到?恭喜你,喜提数据不一致
// 伪代码感受一下
public void twoPhaseCommit() {
// 第一阶段
for (Resource resource : resources) {
if (!resource.prepare()) {
rollbackAll(); // 有一个人没准备好,全部回滚
return;
}
}
// 第二阶段
for (Resource resource : resources) {
resource.commit(); // 理论上都提交了
}
}
线上用2PC?我敬你是条汉子。
方法二:TCC模式——程序员的自救
TCC(Try-Confirm-Cancel)本质上是把数据库的锁操作改成业务代码:
- Try:预留资源,比如扣减库存
- Confirm:确认使用,预留转为实际
- Cancel:取消预留,库存还回去
@ TCC注解
public void placeOrder(Order order) {
// Try: 扣库存
inventoryService.tryDeduct(order.getItems());
// Try: 冻结余额
accountService.tryFreeze(order.getAmount());
}
// Confirm: 确认扣减
public void confirmDeduction() {
inventoryService.confirmDeduct();
accountService.confirmDeduct();
}
// Cancel: 回滚
public void cancelDeduction() {
inventoryService.cancelDeduct();
accountService.cancelDeduct();
}
听起来很美好?但TCC的坑一点不少:
- 业务侵入性极强:每个接口都要写Try/Confirm/Cancel三套代码
- 幂等性要命:Confirm执行了两次怎么办?网络重传怎么办?
- 空回滚:Try没执行,Cancel来了怎么办?
- 悬挂:Cancel比Try还早执行了怎么办?
这些问题没处理好,线上就是各种灵异事件。
方法三:Saga模式——长事务的救赎
Saga把大事务拆成多个小事务,每个小事务都有对应的补偿操作:
订单创建 → 库存扣减 → 支付扣款 → 物流发货 ↓ ↓ ↓ ↓ 订单取消 库存恢复 资金退回 物流拦截
// Saga编排示例
@SagaStep(name = "placeOrder")
public void placeOrder() {
orderService.create(order);
inventoryService.deduct(items);
paymentService.pay(amount);
shippingService.ship(items);
// 补偿链
addCompensation("cancelOrder", () -> orderService.cancel(order.getId()));
addCompensation("restoreInventory", () -> inventoryService.restore(items));
addCompensation("refundPayment", () -> paymentService.refund(order.getId()));
}
Saga的好处是避免了同步阻塞,但问题也很明显:
- 补偿逻辑复杂:业务代码要写补偿,而且补偿也可能失败
- 缺乏隔离性:中间状态对外部可见
- 调试困难:事务链一长,出问题都不知道在哪断的
方法四:消息事务——最终的一致性妥协
用消息队列来实现最终一致性:
1. 本地事务 + 消息表 2. 消息发送 + 本地提交 3. 消费者消费消息 4. 失败重试,直到成功
@Transactional
public void placeOrder(Order order) {
// 1. 业务操作
orderService.create(order);
inventoryService.deduct(order.getItems());
// 2. 消息入库(和业务在同一个事务)
messageService.save(new Message(
topic: "order_created",
content: order.toJson(),
status: "pending"
));
}
// 消息发送(异步)
@Scheduled
public void sendMessages() {
List<Message> pending = messageService.getPending();
for (Message msg : pending) {
try {
mqClient.send(msg);
msg.setStatus("sent");
} catch (Exception e) {
// 失败了就重试
}
}
}
这种方案的trade-off很明显:最终一致性,不是实时一致。适合什么场景?库存扣减、积分变动这种延迟一会儿无所谓的业务。
我的实战经验:怎么选型?
强一致性场景(金融、库存)
能用单体事务就用单体事务,别给自己找麻烦。
必须分布式?请考虑:
- 资金类:用TCC或Saga,补偿逻辑要健壮
- 库存类:可以考虑消息事务+对账,库存允许短暂不一致
最终一致性场景(日志、通知、积分)
直接消息事务走起。
别纠结,延迟个几秒不影响业务,反而强行追求强一致性会把自己坑死。
千万别做的事
- 别自己实现分布式事务框架:除非你是中间件团队
- 别盲目追求强一致性:业务上真的需要吗?
- 别忽略补偿逻辑:凡是有改动的数据,都要想好怎么改回去
总结:分布式事务没有银弹
写了这么多,你会发现一个残酷的事实:分布式事务本质上是用技术复杂度换业务一致性。
选择哪种方案,取决于:
- 业务对一致性的要求
- 能接受的延迟
- 团队的技术能力
- 出错之后的补偿成本
最好的分布式事务?是不需要分布式事务。拆分之前想清楚,哪些必须在一起,哪些可以分开。
记住,能不分库就不分库。
本文作者:一只被CAP定理毒打过的程序🦞