【Java项目技术亮点】Saga模式长事务编排

写在前面:说实话,我第一次在项目里遇到"下单扣库存扣余额加积分发通知"这种跨5个服务的长流程时,脑子里第一反应就是TCC。结果写了一周Try/Confirm/Cancel三套代码后,人差点没了------每个服务都要改,侵入性太强了。后来组长说用Saga模式吧,我才恍然大悟:原来长事务编排还有更优雅的解法。这篇文章就把Saga的原理、实现、踩坑经验一次讲清楚,希望对你有用。

文章目录


一、为什么需要Saga?TCC不够用吗?

场景引入

先看一个典型的电商下单流程:

复制代码
创建订单 → 扣减库存 → 扣减余额 → 增加积分 → 发送通知

这里有5个服务参与,如果用TCC,每个服务都要写Try/Confirm/Cancel三套代码。

我见过太多人一上来就选TCC,结果写到最后发现:

  • 代码量翻三倍:每个服务三套逻辑
  • 侵入性太强:原有业务代码要大改
  • 维护成本高:改一个流程要改多个服务的Try/Confirm/Cancel

TCC vs Saga对比

维度 TCC Saga
适用事务长度 短事务(2-3步) 长事务(5+步)
代码侵入性 高(三套代码) 低(正向+补偿)
一致性 强一致(两阶段) 最终一致
实现复杂度 中等 较低
性能 较低(资源锁定) 较高(无锁定)
典型场景 银行转账 电商下单、旅行预订

生活类比

装修房子:

  • TCC = 每步都先"预占"------先搬家具进来试试,不行再搬走,再试下一件。每件家具都要试三遍,累不累?
  • Saga = 直接干,一件一件按顺序来。发现某步不行了,就把前面干过的"拆回去"(补偿)。简单粗暴,但效率高。

二、Saga模式原理

正向操作与补偿操作

Saga的核心思想就两步:

正向操作:按顺序执行所有步骤

复制代码
T1(创建订单)→ T2(扣减库存)→ T3(扣减余额)→ T4(增加积分)→ T5(发送通知)

补偿操作:某一步失败后,按反向顺序补偿已成功的步骤

复制代码
C5(撤回通知)→ C4(扣减积分)→ C3(退还余额)→ C2(恢复库存)→ C1(取消订单)

流程说明

正常流程(全部成功)

复制代码
T1 → T2 → T3 → T4 → T5 ✓ 全部完成,事务成功

异常流程(T3失败)

复制代码
T1 ✓ → T2 ✓ → T3 ✗(扣减余额失败)
    ↓ 触发补偿
C2(恢复库存)→ C1(取消订单)

注意:T3失败了,只需要补偿T1和T2(已经成功的步骤),T3本身不需要补偿,T4和T5根本没执行也不需要补偿。


三、Saga两种实现方式

1. 编排式(Orchestration)

有一个中央协调器(Saga Manager)来控制整个流程的执行顺序。

复制代码
┌─────────────────┐
│  Saga Manager   │
│  (中央协调器)   │
└────────┬────────┘
         │ 调用
    ┌────┴────┐
    ▼         ▼
┌───────┐ ┌───────┐
│订单服务│ │库存服务│
└───────┘ └───────┘
    │         │
    ▼         ▼
┌───────┐ ┌───────┐
│支付服务│ │积分服务│
└───────┘ └───────┘

优点

  • 集中管理,流程清晰,一眼就能看出整个事务的执行路径
  • 方便做超时处理、重试策略
  • Seata Saga就是这种实现

缺点

  • 协调器存在单点风险
  • 所有服务都要和协调器通信,耦合度相对较高

2. 协同式(Choreography)

没有中央协调器,各服务自主监听事件,通过事件驱动完成流程。

复制代码
订单服务 --[订单创建事件]--> 库存服务
库存服务 --[库存扣减事件]--> 支付服务
支付服务 --[支付完成事件]--> 积分服务
积分服务 --[积分增加事件]--> 通知服务

如果某一步失败,发送补偿事件,反向传播。

优点

  • 去中心化,没有单点风险
  • 服务之间松耦合,通过事件通信
  • 适合简单流程

缺点

  • 流程难以追踪,出了问题不好排查
  • 服务之间可能产生循环依赖
  • 复杂流程下事件链路太长,难以维护

两种方式对比

维度 编排式(Orchestration) 协同式(Choreography)
控制方式 中央协调器 事件驱动,去中心化
耦合度 中(与协调器耦合) 低(事件解耦)
流程可见性 高(集中定义) 低(分散在各服务)
单点风险 有(协调器)
适用场景 复杂长事务 简单短事务
代表实现 Seata Saga Spring Cloud Event

四、Seata Saga实现

Saga状态机设计

Seata Saga通过状态机来定义事务流程。核心概念:

  • State:一个操作步骤(服务调用)
  • Task:具体的服务调用任务
  • Choice:条件分支(成功走正向,失败走补偿)
  • Compensation:补偿操作

JSON状态机配置文件示例

json 复制代码
{
  "Name": "createOrderSaga",
  "Comment": "电商下单Saga编排",
  "StartState": "CreateOrder",
  "States": {
    "CreateOrder": {
      "Type": "ServiceTask",
      "ServiceName": "orderService",
      "ServiceMethod": "createOrder",
      "CompensateState": "CancelOrder",
      "Next": "DeductInventory"
    },
    "DeductInventory": {
      "Type": "ServiceTask",
      "ServiceName": "inventoryService",
      "ServiceMethod": "deductInventory",
      "CompensateState": "RestoreInventory",
      "Next": "DeductBalance"
    },
    "DeductBalance": {
      "Type": "ServiceTask",
      "ServiceName": "paymentService",
      "ServiceMethod": "deductBalance",
      "CompensateState": "RefundBalance",
      "Next": "AddPoints"
    },
    "AddPoints": {
      "Type": "ServiceTask",
      "ServiceName": "pointsService",
      "ServiceMethod": "addPoints",
      "CompensateState": "SubtractPoints",
      "Next": "SendNotification"
    },
    "SendNotification": {
      "Type": "ServiceTask",
      "ServiceName": "notificationService",
      "ServiceMethod": "sendNotification",
      "CompensateState": "WithdrawNotification",
      "Next": "Succeed"
    },
    "CancelOrder": {
      "Type": "ServiceTask",
      "ServiceName": "orderService",
      "ServiceMethod": "cancelOrder"
    },
    "RestoreInventory": {
      "Type": "ServiceTask",
      "ServiceName": "inventoryService",
      "ServiceMethod": "restoreInventory"
    },
    "RefundBalance": {
      "Type": "ServiceTask",
      "ServiceName": "paymentService",
      "ServiceMethod": "refundBalance"
    },
    "SubtractPoints": {
      "Type": "ServiceTask",
      "ServiceName": "pointsService",
      "ServiceMethod": "subtractPoints"
    },
    "WithdrawNotification": {
      "Type": "ServiceTask",
      "ServiceName": "notificationService",
      "ServiceMethod": "withdrawNotification"
    },
    "Succeed": {
      "Type": "Succeed"
    }
  }
}

完整Java代码示例:电商下单Saga编排

OrderService(创建订单 + 补偿:取消订单)
java 复制代码
@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 创建订单 - Saga正向操作
     */
    @Transactional
    public Order createOrder(Long userId, Long productId, Integer quantity, BigDecimal amount) {
        Order order = new Order();
        order.setOrderId(IdGenerator.nextId());
        order.setUserId(userId);
        order.setProductId(productId);
        order.setQuantity(quantity);
        order.setAmount(amount);
        order.setStatus(OrderStatus.CREATED.getStatus());
        order.setCreateTime(new Date());
        orderMapper.insert(order);
        return order;
    }

    /**
     * 取消订单 - Saga补偿操作(幂等)
     */
    @Transactional
    public void cancelOrder(String orderId) {
        Order order = orderMapper.selectByOrderId(orderId);
        if (order == null) {
            // 幂等处理:订单已不存在,直接返回
            return;
        }
        // 只有已创建状态的订单才能取消
        if (OrderStatus.CREATED.getStatus().equals(order.getStatus())) {
            order.setStatus(OrderStatus.CANCELLED.getStatus());
            order.setCancelTime(new Date());
            orderMapper.updateStatus(order);
        }
        // 其他状态说明已经被处理过了,幂等返回
    }
}
InventoryService(扣减库存 + 补偿:恢复库存)
java 复制代码
@Service
public class InventoryService {

    @Autowired
    private InventoryMapper inventoryMapper;

    /**
     * 扣减库存 - Saga正向操作
     */
    @Transactional
    public void deductInventory(Long productId, Integer quantity) {
        Inventory inventory = inventoryMapper.selectByProductId(productId);
        if (inventory == null || inventory.getStock() < quantity) {
            throw new SagaException("库存不足,productId=" + productId);
        }
        inventory.setStock(inventory.getStock() - quantity);
        inventory.setLockedStock(inventory.getLockedStock() + quantity);
        inventoryMapper.updateStock(inventory);
    }

    /**
     * 恢复库存 - Saga补偿操作(幂等)
     */
    @Transactional
    public void restoreInventory(Long productId, Integer quantity) {
        Inventory inventory = inventoryMapper.selectByProductId(productId);
        if (inventory == null) {
            return; // 幂等处理
        }
        // 恢复库存,同时减少锁定库存
        inventory.setStock(inventory.getStock() + quantity);
        inventory.setLockedStock(Math.max(0, inventory.getLockedStock() - quantity));
        inventoryMapper.updateStock(inventory);
    }
}
PaymentService(扣减余额 + 补偿:退还余额)
java 复制代码
@Service
public class PaymentService {

    @Autowired
    private UserAccountMapper accountMapper;
    @Autowired
    private PaymentRecordMapper paymentRecordMapper;

    /**
     * 扣减余额 - Saga正向操作
     */
    @Transactional
    public void deductBalance(Long userId, BigDecimal amount, String bizNo) {
        // 幂等检查:是否已经扣减过
        PaymentRecord existRecord = paymentRecordMapper.selectByBizNo(bizNo);
        if (existRecord != null) {
            return; // 已处理,幂等返回
        }

        UserAccount account = accountMapper.selectByUserId(userId);
        if (account == null || account.getBalance().compareTo(amount) < 0) {
            throw new SagaException("余额不足,userId=" + userId);
        }
        account.setBalance(account.getBalance().subtract(amount));
        accountMapper.updateBalance(account);

        // 记录支付流水
        PaymentRecord record = new PaymentRecord();
        record.setBizNo(bizNo);
        record.setUserId(userId);
        record.setAmount(amount);
        record.setType(PaymentType.DEDUCT.getType());
        record.setCreateTime(new Date());
        paymentRecordMapper.insert(record);
    }

    /**
     * 退还余额 - Saga补偿操作(幂等)
     */
    @Transactional
    public void refundBalance(Long userId, BigDecimal amount, String bizNo) {
        // 幂等检查
        PaymentRecord refundRecord = paymentRecordMapper.selectByBizNo("REFUND_" + bizNo);
        if (refundRecord != null) {
            return; // 已退款,幂等返回
        }

        UserAccount account = accountMapper.selectByUserId(userId);
        if (account == null) {
            return;
        }
        account.setBalance(account.getBalance().add(amount));
        accountMapper.updateBalance(account);

        // 记录退款流水
        PaymentRecord record = new PaymentRecord();
        record.setBizNo("REFUND_" + bizNo);
        record.setUserId(userId);
        record.setAmount(amount);
        record.setType(PaymentType.REFUND.getType());
        record.setCreateTime(new Date());
        paymentRecordMapper.insert(record);
    }
}
PointsService(增加积分 + 补偿:扣减积分)
java 复制代码
@Service
public class PointsService {

    @Autowired
    private UserPointsMapper pointsMapper;
    @Autowired
    private PointsRecordMapper recordMapper;

    /**
     * 增加积分 - Saga正向操作
     */
    @Transactional
    public void addPoints(Long userId, Integer points, String bizNo) {
        // 幂等检查
        PointsRecord existRecord = recordMapper.selectByBizNo(bizNo);
        if (existRecord != null) {
            return;
        }

        UserPoints userPoints = pointsMapper.selectByUserId(userId);
        if (userPoints == null) {
            userPoints = new UserPoints();
            userPoints.setUserId(userId);
            userPoints.setTotalPoints(0);
            userPoints.setAvailablePoints(0);
            pointsMapper.insert(userPoints);
        }
        userPoints.setTotalPoints(userPoints.getTotalPoints() + points);
        userPoints.setAvailablePoints(userPoints.getAvailablePoints() + points);
        pointsMapper.updatePoints(userPoints);

        // 记录积分流水
        PointsRecord record = new PointsRecord();
        record.setBizNo(bizNo);
        record.setUserId(userId);
        record.setPoints(points);
        record.setType(PointsType.EARN.getType());
        record.setCreateTime(new Date());
        recordMapper.insert(record);
    }

    /**
     * 扣减积分 - Saga补偿操作(幂等)
     */
    @Transactional
    public void subtractPoints(Long userId, Integer points, String bizNo) {
        // 幂等检查
        PointsRecord existRecord = recordMapper.selectByBizNo("SUBTRACT_" + bizNo);
        if (existRecord != null) {
            return;
        }

        UserPoints userPoints = pointsMapper.selectByUserId(userId);
        if (userPoints == null) {
            return;
        }
        // 可用积分不足时,扣到0为止(部分补偿场景)
        int actualSubtract = Math.min(points, userPoints.getAvailablePoints());
        userPoints.setAvailablePoints(userPoints.getAvailablePoints() - actualSubtract);
        pointsMapper.updatePoints(userPoints);

        // 记录扣减流水
        PointsRecord record = new PointsRecord();
        record.setBizNo("SUBTRACT_" + bizNo);
        record.setUserId(userId);
        record.setPoints(actualSubtract);
        record.setType(PointsType.SUBTRACT.getType());
        record.setCreateTime(new Date());
        recordMapper.insert(record);
    }
}
Saga编排入口
java 复制代码
@RestController
@RequestMapping("/order")
public class OrderController {

    @Autowired
    private SagaStateMachineEngine sagaStateMachineEngine;

    /**
     * 发起Saga下单流程
     */
    @PostMapping("/create")
    public Result<String> createOrder(@RequestBody CreateOrderRequest request) {
        // 构建Saga状态机输入参数
        Map<String, Object> startParams = new HashMap<>();
        startParams.put("userId", request.getUserId());
        startParams.put("productId", request.getProductId());
        startParams.put("quantity", request.getQuantity());
        startParams.put("amount", request.getAmount());
        startParams.put("bizNo", IdGenerator.nextId().toString());

        // 启动Saga状态机
        StateMachineInstance instance = sagaStateMachineEngine.startWithJSON(
            "createOrderSaga",  // 状态机名称
            null,               // 业务key
            startParams         // 输入参数
        );

        if (instance.isSuccess()) {
            return Result.success("下单成功,orderId=" + instance.getStateMachine().getBusinessKey());
        } else {
            return Result.fail("下单失败,已触发补偿:" + instance.getException().getMessage());
        }
    }
}

五、Saga的补偿设计原则

1. 补偿操作必须是幂等的

这个坑我踩过。补偿操作可能被重复调用------网络超时导致协调器以为补偿失败了,再调一次。如果补偿操作不幂等,就会出现"退了两次钱"的惨剧。

java 复制代码
// 正确做法:通过业务流水号保证幂等
@Transactional
public void refundBalance(Long userId, BigDecimal amount, String bizNo) {
    // 先查流水,存在就说明已经退过了
    PaymentRecord record = paymentRecordMapper.selectByBizNo("REFUND_" + bizNo);
    if (record != null) {
        log.info("重复补偿请求,已处理过,bizNo={}", bizNo);
        return;
    }
    // 执行退款逻辑...
}

2. 补偿操作必须能处理"部分成功"

比如扣减积分成功了100分,但补偿时用户已经消费了30分,只剩70分可退。这时候不能直接报错,要退能退的部分,差额走人工处理。

3. 补偿操作的顺序必须与正向操作相反

复制代码
正向:T1 → T2 → T3 → T4 → T5
补偿:C5 → C4 → C3 → C2 → C1

为什么?因为后面的操作可能依赖前面的结果。比如积分依赖支付,你得先把积分退了,再退钱,否则逻辑上说不通。

4. 超时处理

某一步长时间未响应怎么办?

java 复制代码
// Seata Saga中可以设置超时
"CreateOrder": {
    "Type": "ServiceTask",
    "ServiceName": "orderService",
    "ServiceMethod": "createOrder",
    "CompensateState": "CancelOrder",
    "Next": "DeductInventory",
    "Retry": [
        {
            "Exceptions": ["java.net.SocketTimeoutException"],
            "IntervalSeconds": 3,
            "MaxAttempts": 3,
            "BackoffRate": 2.0
        }
    ]
}

六、踩坑指南

踩坑提醒:Saga看起来简单,但真落地的时候坑不少。下面这些是我实际项目中遇到的问题,希望能帮你少走弯路。

补偿操作失败怎么办?

补偿也可能失败。比如退款接口挂了、数据库连不上了。

处理策略

  1. 自动重试:设置重试次数和退避策略(指数退避)
  2. 重试队列:补偿失败的消息进入重试队列,定时消费
  3. 人工介入:重试超过上限后,生成工单,人工处理
  4. 告警通知:补偿失败时立即告警
java 复制代码
// 补偿重试策略示例
public class CompensationRetryPolicy {
    private int maxRetries = 5;           // 最大重试次数
    private long initialInterval = 1000;  // 初始间隔1秒
    private double multiplier = 2.0;      // 退避倍数

    public long getRetryInterval(int attempt) {
        if (attempt >= maxRetries) {
            return -1; // 超过重试次数,需要人工介入
        }
        return (long) (initialInterval * Math.pow(multiplier, attempt));
    }
}

循环依赖问题

A的补偿依赖B的数据,B的补偿又依赖A的数据------这就死锁了。

比如订单取消需要查支付记录,支付退款又需要查订单状态。如果两个表同时被锁,就完了。

解决办法

  • 补偿操作尽量只操作自己的数据,不依赖其他服务
  • 如果必须依赖,用缓存快照提前保存所需数据

数据不一致的窗口期

Saga是最终一致性,在补偿完成之前,数据是不一致的。比如库存已经扣了,但余额还没扣,这时候查库存是"少了"的。

这个窗口期是Saga的固有特性,业务上要能接受。如果业务要求强一致,别用Saga,老老实实用TCC或者分布式锁。

Saga vs TCC vs 消息最终一致性选型

维度 Saga TCC 消息最终一致性
一致性级别 最终一致 强一致(两阶段) 最终一致
代码侵入性 中(正向+补偿) 高(三套代码)
适用步骤数 5+步长事务 2-3步短事务 异步场景
实时性 较高 低(异步)
典型场景 电商下单、旅行预订 银行转账、支付 通知、日志、积分

七、问题与解答

Q1:Saga和TCC到底怎么选?

A:看你的事务链路长度和一致性要求。2-3步的短事务,一致性要求高的(比如转账),用TCC。5步以上的长事务,能接受最终一致的,用Saga。别什么都上TCC,代码量会让你怀疑人生。

Q2:Saga的补偿操作失败了怎么办?整个事务就卡住了吗?

A :不会卡住,但需要处理。一般做法是:先自动重试(指数退避),重试超过上限后进入"待人工处理"状态,同时发告警。运维人员介入后手动完成补偿。所以补偿操作一定要有完整的日志记录,方便人工排查。

Q3:Seata Saga和手动实现Saga有什么区别?

A:Seata Saga提供了状态机引擎,帮你管理流程编排、重试、超时、补偿顺序等。手动实现的话,你要自己写协调器逻辑,处理各种边界情况。除非流程特别简单(2-3步),否则建议用Seata Saga,省心。


八、面试高频考点汇总

考点1:什么是Saga模式?解决什么问题?

答案 :Saga是一种长事务编排模式,用于解决跨多个微服务的分布式事务问题。核心思想是:每个服务执行一个正向操作(T),如果某一步失败,则按反向顺序执行已成功步骤的补偿操作(C)。Saga适合5步以上的长事务场景,相比TCC侵入性更低,代码量更少。

考点2:Saga的两种实现方式及区别?

答案

  • 编排式(Orchestration):由中央协调器控制流程,服务之间不直接通信。优点是流程清晰、集中管理;缺点是协调器有单点风险。代表实现是Seata Saga。
  • 协同式(Choreography):各服务通过事件自主协作,无中央协调器。优点是去中心化、松耦合;缺点是流程难以追踪。适合简单流程。

考点3:为什么Saga的补偿操作必须是幂等的?

答案 :因为补偿操作可能被重复调用 。网络超时、协调器重试、消息重复投递等情况都可能导致补偿操作被多次执行。如果不幂等,就会出现"退两次款""扣两次积分"等严重问题。幂等性通常通过唯一业务流水号来实现。

考点4:Saga和TCC的一致性有什么区别?

答案 :TCC是两阶段提交 ,Try阶段预留资源,Confirm阶段真正提交,Cancel阶段回滚。在Confirm之前,资源是锁定的,所以能保证强一致性。Saga没有资源预留阶段,正向操作直接提交,失败后通过补偿来恢复,所以是最终一致性,中间存在数据不一致的窗口期。

考点5:Saga模式中如何处理超时?

答案 :在Seata Saga中,可以为每个状态配置超时时间和重试策略。超时后状态机引擎会自动触发补偿流程。重试策略通常采用指数退避(每次重试间隔翻倍),避免短时间内大量重试压垮服务。超过最大重试次数后,进入待人工处理状态。


九、模拟面试官提问和参考答案

场景题1:电商下单涉及6个微服务,你会选TCC还是Saga?为什么?

参考答案:选Saga。6个服务用TCC的话,每个服务要写Try/Confirm/Cancel三套代码,总共18套逻辑,维护成本太高。而且电商下单对一致性要求不是特别严格(最终一致即可),Saga完全够用。只有支付环节如果要求强一致,可以单独对支付服务用TCC,其他服务用Saga编排。

场景题2:补偿操作执行到一半,系统宕机了,怎么保证数据最终一致?

参考答案 :首先,Saga状态机引擎会持久化每一步的执行状态 到数据库。系统恢复后,引擎会检查未完成的事务实例,继续执行未完成的补偿操作。其次,每个补偿操作本身是幂等的,重复执行不会出问题。最后,如果自动恢复失败,会有告警机制通知运维人工介入。关键是:状态持久化 + 补偿幂等 + 告警机制,三管齐下。

场景题3:你的Saga流程中有10个步骤,第7步失败了,补偿到第3步时又失败了,怎么办?

参考答案:第7步失败后,开始反向补偿:C6→C5→C4→C3。C3补偿失败时,引擎会将该补偿任务标记为"重试中",按照指数退避策略自动重试。如果重试超过上限(比如5次),会将整个Saga实例标记为"需要人工介入",同时发送告警。人工处理时,可以通过管理后台查看Saga执行记录,手动触发C3的补偿,或者手动调整数据后标记补偿完成。

场景题4:如何设计一个高可用的Saga协调器?

参考答案:几个关键点:

  1. 无状态设计:协调器本身不存储事务状态,状态全部持久化到数据库
  2. 集群部署:多节点部署,通过数据库选主或注册中心选主
  3. 故障转移:主节点挂了,从节点接管,从数据库恢复未完成的事务
  4. 限流降级:高并发时对Saga请求做限流,避免协调器过载
  5. 监控告警:对协调器的QPS、成功率、待补偿事务数做实时监控

场景题5:如果业务要求"下单后库存必须立即扣减,不能有窗口期",还能用Saga吗?

参考答案 :严格来说,Saga是最终一致性,无法保证"立即"。但可以通过以下方式缩短窗口期

  1. 将库存扣减放在Saga的第一步,减少后续步骤失败的概率
  2. 使用同步调用而非异步消息,减少延迟
  3. 在库存扣减时加预扣机制(类似TCC的Try),给一个短暂的锁定期

但如果业务真的要求强一致的立即扣减,那Saga不适合,应该用TCC或者分布式锁+本地事务的方案。选型要匹配业务需求,不能硬套。


互动话题

你在实际项目中用过Saga模式吗?遇到过什么坑?是用的Seata Saga还是自己实现的?欢迎在评论区分享你的经验,我会在评论区回复讨论。


参考资料

Seata Saga官方文档