【架构实战】Saga分布式事务:长事务的拆解与补偿设计

Saga分布式事务:长事务的拆解与补偿设计

一、一笔"消失的退款"引发的架构反思

2020年双11,用户申请退款一个包含3件商品的订单------总金额586元。系统流程是这样的:

复制代码
1. 退款审批通过
2. 退款到支付账户    成功(586元已退回)
3. 返还库存          成功(库存+3)
4. 回退优惠券        失败(优惠券已过期,接口报错)
5. 回退积分          未执行(流程中断)

用户收到了退款,但优惠券和积分没退回,客诉电话打爆了。

更严重的是------退款和返还库存已经执行了,优惠券回退失败后,没有回滚前面的操作

这就是长事务的问题:在微服务架构中,一个业务操作横跨多个服务,每个服务独立数据库,传统的ACID事务无法跨服务生效。Saga模式正是为解决这类问题而生的。


二、Saga模式核心原理

2.1 什么是Saga

Saga是Hector Garcia-Molina和Kenneth Salem在1987年提出的长事务解决方案。核心理念:将一个跨多个服务的分布式事务拆解为一系列本地事务,每个本地事务有对应的补偿事务,用于回滚

复制代码
Saga事务 = T1 → T2 → T3 → ... → Tn
补偿链   = C1 ← C2 ← C3 ← ... ← Cn

其中:
  Ti:正向事务(执行业务操作)
  Ci:补偿事务(撤销Ti的效果)

与ACID事务的本质区别

维度 ACID事务 Saga事务
范围 单数据库内 跨多个服务/数据库
隔离性 强隔离(Serializable等) 无隔离保证(需业务处理)
回滚方式 自动ROLLBACK 手动执行补偿事务
锁持有时间 毫秒/秒级 分钟/小时级
一致性保证 强一致 最终一致

2.2 两种协调模式

编排式(Choreography)------去中心化

复制代码
服务A ──发布事件──→ Event Bus ──订阅──→ 服务B
                      │
                      └──订阅──→ 服务C
                      
服务A失败 → 发布补偿事件 → 各服务自行补偿

优点 :松耦合,简单流程实现快

缺点:流程分散难追踪,循环依赖风险,修改流程需要改多个服务

编排式(Orchestration)------中心化

复制代码
          ┌─────────────────┐
          │  Saga编排器     │
          │  (Orchestrator) │
          └────┬───┬───┬────┘
               │   │   │
          ┌────▼─┐─▼───▼────┐
          │ 服务A │ 服务B │ 服务C│
          └───────┴───────┴──────┘

优点 :流程集中管理,账本清晰,灵活编排

缺点:编排器是单点,服务间仍存耦合

选型建议:对于复杂业务流程(如退款涉及5个以上服务),优先选用编排式。


三、实战案例:退款系统的Saga实现

3.1 业务场景

退款流程涉及4个微服务:

复制代码
步骤1:退款审批服务 ── 审批通过,标记退款单状态
步骤2:支付网关服务 ── 调用第三方退款接口,退还资金
步骤3:库存服务     ── 归还已售库存
步骤4:营销服务     ── 退回优惠券和积分

任何一个步骤失败,都需要补偿前面已执行的步骤。

3.2 Saga编排器实现

java 复制代码
// Saga事务状态机
public enum SagaStatus {
    STARTED,
    APPROVAL_COMPLETED,        // 审批完成
    REFUND_COMPLETED,          // 退款完成
    INVENTORY_RESTORED,        // 库存已归还
    COUPON_RESTORED,           // 优惠券已归还
    COMPLETED,                 // 全部完成
    // 补偿状态
    COMPENSATING_REFUND,       // 正在补偿退款
    COMPENSATING_INVENTORY,    // 正在补偿库存
    FAILED                     // 失败
}

// Saga编排器核心类
@Component
public class RefundSagaOrchestrator {
    
    @Autowired
    private RefundApprovalService approvalService;
    @Autowired
    private PaymentGatewayService paymentService;
    @Autowired
    private InventoryService inventoryService;
    @Autowired
    private MarketingService marketingService;
    
    @Autowired
    private SagaLogRepository sagaLogRepository;
    
    /**
     * 编排退款Saga事务
     */
    public void executeRefundSaga(RefundRequest request) {
        String sagaId = UUID.randomUUID().toString();
        SagaLog sagaLog = SagaLog.create(sagaId, "REFUND", request.getRefundId());
        sagaLogRepository.save(sagaLog);
        
        try {
            // T1: 退款审批
            sagaLog.appendStep("APPROVAL", SagaStepStatus.RUNNING);
            approvalService.approve(request);
            sagaLog.appendStep("APPROVAL", SagaStepStatus.COMPLETED);
            
            // T2: 执行退款(关键步骤)
            sagaLog.appendStep("REFUND", SagaStepStatus.RUNNING);
            paymentService.refund(request);
            sagaLog.appendStep("REFUND", SagaStepStatus.COMPLETED);
            
            // T3: 归还库存
            sagaLog.appendStep("INVENTORY", SagaStepStatus.RUNNING);
            inventoryService.restore(request);
            sagaLog.appendStep("INVENTORY", SagaStepStatus.COMPLETED);
            
            // T4: 回退营销资源
            sagaLog.appendStep("MARKETING", SagaStepStatus.RUNNING);
            marketingService.restoreCouponAndPoints(request);
            sagaLog.appendStep("MARKETING", SagaStepStatus.COMPLETED);
            
            // 全部完成
            sagaLog.complete();
            
        } catch (SagaStepException e) {
            // 触发补偿流程
            logger.error("Saga事务失败,开始补偿: sagaId={}, failedStep={}", 
                         sagaId, e.getStepName());
            sagaLog.setFailedStep(e.getStepName());
            compensate(sagaLog, request);
        }
        
        sagaLogRepository.save(sagaLog);
    }
    
    /**
     * 补偿逻辑:从失败步骤的前一步开始,逆序补偿
     */
    private void compensate(SagaLog sagaLog, RefundRequest request) {
        List<SagaStep> completedSteps = sagaLog.getCompletedSteps();
        Collections.reverse(completedSteps);  // 逆序
        
        for (SagaStep step : completedSteps) {
            try {
                switch (step.getStepName()) {
                    case "MARKETING":
                        // 补偿:扣回已发放的优惠券和积分
                        marketingService.compensateRestore(request);
                        sagaLog.appendStep("MARKETING_COMPENSATE", SagaStepStatus.COMPLETED);
                        break;
                        
                    case "INVENTORY":
                        // 补偿:重新扣减已归还的库存
                        inventoryService.compensateRestore(request);
                        sagaLog.appendStep("INVENTORY_COMPENSATE", SagaStepStatus.COMPLETED);
                        break;
                        
                    case "REFUND":
                        // 补偿:调用支付网关冲正(最关键的补偿)
                        paymentService.compensateRefund(request);
                        sagaLog.appendStep("REFUND_COMPENSATE", SagaStepStatus.COMPLETED);
                        break;
                        
                    case "APPROVAL":
                        // 补偿:回退审批状态
                        approvalService.compensateApprove(request);
                        sagaLog.appendStep("APPROVAL_COMPENSATE", SagaStepStatus.COMPLETED);
                        break;
                }
            } catch (Exception e) {
                // 补偿也失败 → 进入人工处理
                logger.error("补偿失败,需人工介入: sagaId={}, step={}", 
                             sagaLog.getSagaId(), step.getStepName());
                sagaLog.setStatus(SagaStatus.MANUAL_REQUIRED);
                alertService.sendAlert("Saga补偿失败需人工处理", sagaLog);
                return;  // 停止补偿
            }
        }
        
        sagaLog.setStatus(SagaStatus.COMPENSATED);
    }
}

3.3 各服务的本地事务与补偿

java 复制代码
// 支付网关服务:正向操作 + 补偿操作
@Service
public class PaymentGatewayService {
    
    @Autowired
    private PaymentRepository paymentRepo;
    @Autowired
    private AlipayClient alipayClient;
    
    /**
     * 正向操作:调用第三方退款接口
     */
    @Transactional
    public void refund(RefundRequest request) {
        String transactionId = request.getRefundId();
        
        // 幂等检查
        if (paymentRepo.existsByTransactionId(transactionId, "REFUND")) {
            logger.info("退款已处理: transactionId={}", transactionId);
            return;
        }
        
        // 调用支付宝退款接口
        AlipayTradeRefundRequest aliReq = new AlipayTradeRefundRequest();
        aliReq.setOutTradeNo(request.getOrderId());
        aliReq.setRefundAmount(request.getAmount().toString());
        aliReq.setOutRequestNo(request.getRefundId());  // 幂等键
        
        AlipayTradeRefundResponse resp = alipayClient.execute(aliReq);
        
        if (!resp.isSuccess()) {
            throw new SagaStepException("REFUND", "退款调用失败: " + resp.getSubMsg());
        }
        
        // 记录退款成功
        PaymentRecord record = PaymentRecord.builder()
                .transactionId(transactionId)
                .orderId(request.getOrderId())
                .amount(request.getAmount())
                .type("REFUND")
                .build();
        paymentRepo.save(record);
    }
    
    /**
     * 补偿操作:调用冲正接口(或人工处理)
     */
    @Transactional
    public void compensateRefund(RefundRequest request) {
        String transactionId = "CP-" + request.getRefundId();
        
        // 幂等检查
        if (paymentRepo.existsByTransactionId(transactionId, "REFUND_COMPENSATE")) {
            return;
        }
        
        // 重新发起收款(退款的对冲操作)
        // 注意:不是所有支付渠道都支持冲正,此处需根据渠道评估
        try {
            AlipayTradeCreateRequest aliReq = new AlipayTradeCreateRequest();
            aliReq.setOutTradeNo(request.getRefundId());
            aliReq.setTotalAmount(request.getAmount().toString());
            aliReq.setSubject("退款冲正");
            
            AlipayTradeCreateResponse resp = alipayClient.execute(aliReq);
            
            if (!resp.isSuccess()) {
                throw new RuntimeException("冲正失败: " + resp.getSubMsg());
            }
            
            PaymentRecord record = PaymentRecord.builder()
                    .transactionId(transactionId)
                    .orderId(request.getOrderId())
                    .amount(request.getAmount())
                    .type("REFUND_COMPENSATE")
                    .build();
            paymentRepo.save(record);
            
        } catch (Exception e) {
            throw new SagaStepException("REFUND_COMPENSATE", "冲正失败: " + e.getMessage());
        }
    }
}

// 库存服务:正向操作 + 补偿操作
@Service
public class InventoryService {
    
    @Autowired
    private InventoryRepository inventoryRepo;
    @Autowired
    private InventoryLogRepository logRepo;
    
    /**
     * 正向操作:归还库存
     */
    @Transactional
    public void restore(RefundRequest request) {
        String logId = "INV-" + request.getRefundId();
        
        // 幂等检查
        if (logRepo.existsById(logId)) {
            return;
        }
        
        // 归还每个SKU的库存
        for (OrderItem item : request.getItems()) {
            Inventory inv = inventoryRepo.findBySkuId(item.getSkuId());
            inv.setAvailable(inv.getAvailable() + item.getQuantity());
            inventoryRepo.save(inv);
        }
        
        // 记录日志
        logRepo.save(InventoryLog.builder()
                .logId(logId)
                .refundId(request.getRefundId())
                .type("RESTORE")
                .build());
    }
    
    /**
     * 补偿操作:重新扣减库存
     */
    @Transactional
    public void compensateRestore(RefundRequest request) {
        String logId = "INV-CP-" + request.getRefundId();
        
        if (logRepo.existsById(logId)) {
            return;
        }
        
        for (OrderItem item : request.getItems()) {
            Inventory inv = inventoryRepo.findBySkuId(item.getSkuId());
            if (inv.getAvailable() < item.getQuantity()) {
                // 库存不足(补偿期间有新订单消耗了库存)
                throw new SagaStepException("INVENTORY_COMPENSATE", 
                    "补偿扣减失败,SKU=" + item.getSkuId() + " 库存不足");
            }
            inv.setAvailable(inv.getAvailable() - item.getQuantity());
            inventoryRepo.save(inv);
        }
        
        logRepo.save(InventoryLog.builder()
                .logId(logId)
                .refundId(request.getRefundId())
                .type("COMPENSATE")
                .build());
    }
}

3.4 Saga日志(事务账本)设计

sql 复制代码
-- Saga事务日志主表
CREATE TABLE saga_log (
    saga_id VARCHAR(64) PRIMARY KEY,
    saga_type VARCHAR(50) NOT NULL,       -- 事务类型:REFUND, CREATE_ORDER
    business_id VARCHAR(64) NOT NULL,     -- 业务ID
    status VARCHAR(30) NOT NULL,          -- STARTED, COMPLETED, COMPENSATING, FAILED
    failed_step VARCHAR(50),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    completed_at TIMESTAMP,
    INDEX idx_business (business_id),
    INDEX idx_status (status)
);

-- Saga步骤明细表
CREATE TABLE saga_step (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    saga_id VARCHAR(64) NOT NULL,
    step_name VARCHAR(50) NOT NULL,       -- APPROVAL, REFUND, INVENTORY, MARKETING
    step_type VARCHAR(20) NOT NULL,       -- FORWARD, COMPENSATE
    status VARCHAR(20) NOT NULL,          -- RUNNING, COMPLETED, FAILED
    request_data TEXT,                    -- 请求快照
    response_data TEXT,                   -- 响应数据
    error_message TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_saga (saga_id)
);
java 复制代码
// Saga日志实体
@Data
public class SagaLog {
    private String sagaId;
    private String sagaType;
    private String businessId;
    private SagaStatus status;
    private String failedStep;
    private List<SagaStep> steps = new ArrayList<>();
    
    public static SagaLog create(String sagaId, String sagaType, String businessId) {
        SagaLog log = new SagaLog();
        log.sagaId = sagaId;
        log.sagaType = sagaType;
        log.businessId = businessId;
        log.status = SagaStatus.STARTED;
        return log;
    }
    
    public void appendStep(String stepName, SagaStepStatus stepStatus) {
        steps.add(SagaStep.builder()
                .sagaId(this.sagaId)
                .stepName(stepName)
                .stepStatus(stepStatus)
                .timestamp(System.currentTimeMillis())
                .build());
    }
    
    public List<SagaStep> getCompletedSteps() {
        return steps.stream()
            .filter(s -> s.getStepStatus() == SagaStepStatus.COMPLETED 
                      && !s.getStepName().contains("COMPENSATE"))
            .collect(Collectors.toList());
    }
}

四、Saga的关键工程挑战

4.1 问题1:补偿也失败怎么办?

场景:退款已执行,补偿库存时发现库存不足(补偿期间新订单消耗了库存)。

解决方案

复制代码
方案A:重试 + 延时
├── 补偿失败后,间隔递增重试(1s, 5s, 30s, 5min)
├── 超过最大重试次数后标记人工处理
└── 发送告警通知运维

方案B:预占 + 超时释放
├── 正向操作时预占资源而非实际扣减
├── 补偿时释放预占即可,不会失败
└── 资源预占有超时机制,防止长时间占用

方案C:降级补偿
├── 无法完美补偿时,记录差异
├── 后续通过对账系统修复
└── 例如:库存不足 → 记录待补货,后续采购后自动处理
java 复制代码
// 带重试的补偿执行器
@Component
public class RetryCompensationExecutor {
    
    private static final int[] RETRY_DELAYS = {1000, 5000, 30000, 300000};  // 毫秒
    
    @Autowired
    private AlertService alertService;
    
    public void executeWithRetry(Runnable compensation, String sagaId, String stepName) {
        for (int i = 0; i < RETRY_DELAYS.length; i++) {
            try {
                compensation.run();
                logger.info("补偿成功: sagaId={}, step={}, retry={}", sagaId, stepName, i);
                return;
            } catch (Exception e) {
                logger.warn("补偿失败,准备重试: sagaId={}, step={}, retry={}", 
                            sagaId, stepName, i);
                
                if (i < RETRY_DELAYS.length - 1) {
                    try {
                        Thread.sleep(RETRY_DELAYS[i]);
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                    }
                }
            }
        }
        
        // 所有重试都失败 → 人工介入
        logger.error("补偿最终失败,需人工介入: sagaId={}, step={}", sagaId, stepName);
        alertService.sendCriticalAlert(
            String.format("Saga补偿失败需人工介入: sagaId=%s, step=%s", sagaId, stepName)
        );
    }
}

4.2 问题2:空补偿(T未执行,C被调用)

场景:Try操作超时(实际未执行),但Saga编排器超时判定失败,触发Cancel。Cancel调用了一个不存在的Try结果。

java 复制代码
// 补偿操作必须处理空补偿
@Transactional
public void compensateRefund(RefundRequest request) {
    String transactionId = request.getRefundId();
    
    // 检查正向操作是否已执行
    PaymentRecord forwardRecord = paymentRepo.findByTransactionId(transactionId);
    
    if (forwardRecord == null) {
        // 空补偿:正向操作未执行,记录空补偿日志,直接返回成功
        logger.info("空补偿: 正向操作未执行, transactionId={}", transactionId);
        paymentRepo.save(PaymentRecord.builder()
                .transactionId("NULL-" + transactionId)
                .type("NULL_COMPENSATION")
                .remark("空补偿:正向操作未执行")
                .build());
        return;  // 返回成功,不抛异常
    }
    
    // 正常补偿逻辑...
}

4.3 问题3:悬挂(C比T先到)

场景:Try操作网络延迟严重,Cancel先执行完毕,后来Try才到达。

java 复制代码
@Transactional
public void refund(RefundRequest request) {
    String transactionId = request.getRefundId();
    
    // 检查是否已执行过补偿
    PaymentRecord compensateRecord = paymentRepo.findByTransactionId("CP-" + transactionId);
    if (compensateRecord != null) {
        // 悬挂:补偿已执行,拒绝正向操作
        logger.warn("悬挂检测: 补偿已执行,拒绝正向操作, transactionId={}", transactionId);
        throw new IllegalStateException("补偿已执行,正向操作被拒绝");
    }
    
    // 空补偿检查
    PaymentRecord nullRecord = paymentRepo.findByTransactionId("NULL-" + transactionId);
    if (nullRecord != null) {
        logger.warn("空补偿已记录,拒绝正向操作, transactionId={}", transactionId);
        throw new IllegalStateException("空补偿已记录,正向操作被拒绝");
    }
    
    // 正常正向逻辑...
}

4.4 问题4:部分失败状态的恢复

java 复制代码
// 定时任务:扫描未完成的Saga,尝试恢复
@Scheduled(fixedDelay = 60000)  // 每分钟
public void recoverStuckSagas() {
    // 查询超过10分钟仍未完成的Saga
    List<SagaLog> stuckSagas = sagaLogRepository.findStuckSagas(10);
    
    for (SagaLog sagaLog : stuckSagas) {
        logger.warn("发现滞留Saga: sagaId={}", sagaLog.getSagaId());
        
        if (sagaLog.getFailedStep() != null) {
            // 有失败的步骤,重新触发补偿
            RefundRequest request = sagaLogRepository.loadRequest(sagaLog.getSagaId());
            compensate(sagaLog, request);
        } else {
            // 状态待定,可能网络超时,检查各步骤实际执行状态
            checkAndRecover(sagaLog);
        }
    }
}

五、Saga的隔离性策略

Saga不提供ACID的I(隔离性),可能产生脏读、不可重复读、幻读等并发问题。

5.1 语义锁(Semantic Lock)

java 复制代码
// 在业务层面加语义锁,防止并发修改
@Transactional
public void restore(RefundRequest request) {
    // SELECT ... FOR UPDATE 行级锁
    Inventory inv = inventoryRepo.findBySkuIdForUpdate(item.getSkuId());
    // 此时其他Saga不能修改此库存

    inv.setAvailable(inv.getAvailable() + item.getQuantity());
    inventoryRepo.save(inv);
}

5.2 交换式更新(Commutative Update)

java 复制代码
// 将操作设计为可交换的(顺序无关)
// 错误:SET available = 100(覆盖)
// 正确:SET available = available + 1(原子加法)

@Transactional
public void restore(RefundRequest request) {
    // 原子操作,多个Saga并发执行不影响结果
    inventoryRepo.atomicAdd(item.getSkuId(), item.getQuantity());
}

5.3 重读值(Reread Value)

java 复制代码
// 在补偿或更新时,重新读取当前值,基于最新状态决策
@Transactional
public void compensateRestore(RefundRequest request) {
    Inventory inv = inventoryRepo.findBySkuId(item.getSkuId());
    
    // 重新检查:补偿时库存可能已变化
    if (inv.getAvailable() < item.getQuantity()) {
        // 降级处理,记录差异
        shortageLogRepository.save(ShortageLog.builder()
                .skuId(item.getSkuId())
                .shortQuantity(item.getQuantity())
                .refundId(request.getRefundId())
                .status("PENDING")
                .build());
        return;
    }
    
    inv.setAvailable(inv.getAvailable() - item.getQuantity());
    inventoryRepo.save(inv);
}

六、总结

Saga模式的核心是一句话:把长事务拆成小事务,每个小事务带个"后悔药"(补偿)

Saga vs TCC vs 事务消息 选型对比

方案 复杂度 一致性 隔离性 适用场景
Saga编排式 最终一致 复杂业务流程,服务较多
Saga编排式 最终一致 简单流程,2-3个服务参与
TCC 准实时 支付、资金等强一致性要求
事务消息 最终一致 异步解耦、削峰填谷

落地检查清单

  1. 每个正向操作必须有对应的补偿操作
  2. 补偿操作必须支持空补偿(正向未执行时也能返回成功)
  3. 补偿操作必须支持防悬挂(补偿已执行时拒绝正向操作)
  4. 所有操作必须幂等(通过唯一事务ID保证)
  5. Saga日志必须完整记录每一步的状态(正向和补偿)
  6. 补偿失败时要有完善的重试和告警机制
  7. 定时扫描滞留Saga,自动恢复或告警

务实的建议

Saga模式的复杂度不低,但它解决的是跨服务事务这个"无解问题"中的最佳折中方案。不要试图在所有场景都用Saga------对于2-3个服务参与的简单流程,事务消息可能足够。对于强一致性要求(如资金扣减),考虑TCC。只有复杂的、多步骤的长业务流程,Saga才是最合适的选择。

架构的价值在于用可控的复杂度换可靠的一致性,而不是追求技术上的完美。


作者:架构实战团队

日期:2026-07-02

标签:#Saga #分布式事务 #长事务 #补偿 #最终一致性 #微服务