补偿模式(Compensation):分布式事务的“后悔药”
说实话,分布式事务这个话题,我每次跟团队聊起来都挺头疼的。传统的ACID事务在单机数据库里跑得好好的,一上微服务,就变得特别拧巴。你想想看,一个下单流程要扣库存、减余额、发积分,这三个操作分布在三个服务里,怎么保证要么全成功要么全失败?
嗯,补偿模式就是来解决这个问题的。它不追求强一致性,而是通过一系列“反向操作”,让系统最终达到一个一致的状态。说白了,就是给每个操作都配一个“后悔药”。
补偿模式 vs 传统回滚
我在项目中遇到过不少刚接触分布式系统的同学,他们总爱问:“这不就是try-catch里抛异常然后回滚吗?”其实差别大了去了。
| 特性 | 传统事务回滚 | 补偿模式 |
|---|---|---|
| 资源锁定 | 持有锁直到事务结束 | 不长期持有锁,操作完就释放 |
| 一致性 | 强一致性 | 最终一致性 |
| 性能 | 低(锁竞争严重) | 高(无锁或短锁) |
| 实现难度 | 数据库自带 | 需要业务代码实现 |
| 适用场景 | 单体应用、短事务 | 微服务、长事务、跨系统 |
传统回滚是数据库层面的,它知道数据页的原始值,直接恢复就行。补偿模式是业务层面的,你得自己写“反向业务逻辑”。比如扣了库存,补偿操作就是加回库存;发了积分,补偿操作就是扣回积分。
Saga 中的补偿实现
Saga 是补偿模式最经典的落地形式。它把一个长事务拆成多个本地事务,每个本地事务都有对应的补偿事务。Saga 有两种编排方式:
- Choreography( choreography 编排): 每个服务完成自己的事务后,发消息触发下一个服务。如果某个服务失败,它发消息触发前面服务的补偿操作。
- Orchestration(协调器编排): 有一个中央协调器(比如一个状态机),它负责按顺序调用各个服务,并在失败时调用补偿操作。
我个人习惯用协调器的方式。原因很简单——编排方式虽然耦合低,但补偿逻辑散落在各个服务里,出了问题排查起来特别费劲。协调器把所有流程都集中管理,出错了看一眼状态机就明白了。
代码示例:订单 Saga 协调器
下面是一个简化版的订单 Saga 协调器,用 Java 实现。它管理着三个步骤:创建订单、扣库存、扣余额。任何一步失败,都会触发补偿。
public class OrderSagaCoordinator {
private final OrderService orderService;
private final InventoryService inventoryService;
private final PaymentService paymentService;
public OrderSagaCoordinator(OrderService orderService,
InventoryService inventoryService,
PaymentService paymentService) {
this.orderService = orderService;
this.inventoryService = inventoryService;
this.paymentService = paymentService;
}
public void execute(OrderRequest request) {
try {
// 步骤1:创建订单
String orderId = orderService.createOrder(request);
System.out.println("订单创建成功: " + orderId);
// 步骤2:扣库存
inventoryService.deductStock(request.getProductId(), request.getQuantity());
System.out.println("库存扣减成功");
// 步骤3:扣余额
paymentService.deductBalance(request.getUserId(), request.getTotalAmount());
System.out.println("余额扣减成功");
// 全部成功,标记订单为已完成
orderService.markOrderCompleted(orderId);
} catch (Exception e) {
System.err.println("Saga 执行失败,开始补偿: " + e.getMessage());
compensate(request);
}
}
private void compensate(OrderRequest request) {
// 补偿顺序与正向操作相反
try {
// 补偿步骤3:加回余额
paymentService.refundBalance(request.getUserId(), request.getTotalAmount());
System.out.println("余额已退回");
} catch (Exception ex) {
System.err.println("余额补偿失败,需要人工介入: " + ex.getMessage());
}
try {
// 补偿步骤2:加回库存
inventoryService.addStock(request.getProductId(), request.getQuantity());
System.out.println("库存已恢复");
} catch (Exception ex) {
System.err.println("库存补偿失败,需要人工介入: " + ex.getMessage());
}
try {
// 补偿步骤1:取消订单
orderService.cancelOrder(request.getOrderId());
System.out.println("订单已取消");
} catch (Exception ex) {
System.err.println("订单取消失败,需要人工介入: " + ex.getMessage());
}
}
}
补偿操作的幂等性设计
补偿操作被调用多次,结果应该是一样的。比如“加回库存”这个操作,如果被调了两次,库存就多加了,数据就不对了。
怎么做幂等?我常用的办法是:
- 业务键去重: 每个补偿请求带一个全局唯一的补偿ID,服务端记录已经处理过的ID,重复请求直接返回成功。
- 状态机校验: 比如订单状态只有“已创建”才能被取消,如果已经是“已取消”,取消操作就直接返回成功。
- 自然幂等: 有些操作本身就是幂等的,比如“设置库存为100”,不管调多少次,结果都是100。
最终一致性的代价
补偿模式带来的最终一致性,不是没有代价的。在补偿执行期间,系统处于一个“中间状态”。比如用户下单后,库存已经扣了,但余额还没扣,这时候如果用户查库存,看到的是扣减后的值。如果后续补偿把库存加回去了,用户就会看到库存“变多”了。
怎么处理?我建议:
- 对用户屏蔽中间状态,比如订单显示“处理中”,等 Saga 完成后再显示“成功”或“失败”。
- 如果必须展示实时数据,那就加一个“预占”标记,让用户知道这部分资源正在被使用。
这张图展示的就是 Saga 协调器的工作流程。正向操作从上往下执行,一旦某个步骤失败(比如步骤3扣余额失败),就反向执行补偿操作,直到所有步骤都补偿完毕,系统回到一个一致的状态。
好了,关于补偿模式就聊这么多。它不是什么高深的技术,但用好了能解决分布式系统里最头疼的数据一致性问题。记住三个关键词:反向操作、幂等性、最终一致性。下次你在设计跨服务业务流程时,不妨先画一张 Saga 流程图,看看每个步骤的补偿操作是什么。嗯,这个习惯我一直保持到现在。