补偿模式(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 补偿流程(协调器模式) Saga 协调器 1. 创建订单 2. 扣减库存 3. 扣减余额 步骤3 失败 补偿3:退回余额 补偿2:加回库存 补偿1:取消订单 补偿完成(最终状态) 正向操作 补偿操作 最终状态

这张图展示的就是 Saga 协调器的工作流程。正向操作从上往下执行,一旦某个步骤失败(比如步骤3扣余额失败),就反向执行补偿操作,直到所有步骤都补偿完毕,系统回到一个一致的状态。

避坑指南: 我曾经在一个项目里,补偿操作里又调了另一个 Saga,结果形成了嵌套补偿,最后数据乱成一锅粥。记住,补偿操作要尽量简单,不要引入新的 Saga。如果补偿本身很复杂,那就把它设计成幂等的本地事务,不要搞链式调用。

好了,关于补偿模式就聊这么多。它不是什么高深的技术,但用好了能解决分布式系统里最头疼的数据一致性问题。记住三个关键词:反向操作、幂等性、最终一致性。下次你在设计跨服务业务流程时,不妨先画一张 Saga 流程图,看看每个步骤的补偿操作是什么。嗯,这个习惯我一直保持到现在。