12.2 Saga 编排

响应式 Saga 分布式事务模式完整指南:编排式与协同式两种实现方式深度对比、事务补偿操作幂等设计原则与有限重试策略、Saga 状态机持久化日志与故障恢复机制、响应式链逆序补偿回滚与并发陷阱防范、与 R2DBC 事务和事件总线的整合实践。

Saga 解决什么问题

分布式系统中,一个业务操作涉及多个服务。传统 ACID 事务无法跨越服务边界——Saga 用本地事务 + 补偿实现最终一致性。

示例:下单流程

1. 创建订单(order-service)    ✅ 成功
2. 扣减库存(inventory-service)  ✅ 成功
3. 扣款(payment-service)       ❌ 失败
4. 补偿:恢复库存(按补偿单号幂等) ✅ 补偿成功
5. 补偿:取消订单(状态=CANCELLED) ✅ 补偿成功

最终状态:订单取消,库存恢复,没有扣款。系统回到操作前的一致状态。

编排式 Saga(Orchestration)

由中央协调器(Saga Orchestrator)控制流程:

import java.util.function.Supplier;

@Service
public class OrderSagaOrchestrator {
    private final OrderService orderService;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;

    public Mono<Void> executeOrderSaga(OrderRequest request) {
        // 补偿动作存 Supplier,失败时才订阅;直接存 Mono 会变成「先执行、后保存」
        List<Supplier<Mono<Void>>> compensations = new ArrayList<>();

        return orderService.create(request)
            .doOnNext(order -> compensations.add(
                () -> orderService.cancel(order.id())))
            .flatMap(order ->
                inventoryService.deduct(order.symbol(), order.quantity())
                    .doOnNext(inv -> compensations.add(
                        () -> inventoryService.restore(order.symbol(), order.quantity())))
                    .flatMap(inv ->
                        paymentService.charge(order.userId(), order.totalAmount())
                            .doOnNext(payment -> compensations.add(
                                () -> paymentService.refund(payment.id())))
                    )
            )
            .then()
            .onErrorResume(err -> executeCompensations(compensations)
                .then(Mono.error(err)));  // 补偿成功时仍向上游报告原始失败;补偿失败则抛出 CompensationFailedException
    }

    private Mono<Void> executeCompensations(List<Supplier<Mono<Void>>> compensations) {
        List<Throwable> failures = new ArrayList<>();
        // 逆序执行补偿;串行,不并行
        return Flux.fromIterable(compensations.reversed())
            .concatMap(supplier -> supplier.get()
                .doOnError(failures::add)
                .onErrorResume(e -> Mono.empty()))  // 单步失败不阻断其余补偿
            .then(Mono.defer(() -> failures.isEmpty()
                ? Mono.empty()
                : Mono.error(new CompensationFailedException(failures))));
    }
}

关键点:

  • 正向操作按顺序执行
  • 每个成功步骤记录补偿操作
  • 失败时逆序执行补偿(先补偿后执行的步骤)
  • 补偿也有失败可能,需要日志记录人工介入

注意上面的 compensations 只存在内存里:进程在扣款成功后崩溃,补偿列表会一起消失,重启后没有任何线索。生产实现要在每个正向步骤完成时把「已执行步骤 + 对应补偿动作」写进 saga_logs(与业务写在同一本地事务里),恢复时从日志重建并继续补偿;内存列表只能作为单进程内的快速路径。

协同式 Saga(Choreography)

没有中央协调器,各服务通过事件驱动完成 Saga:

OrderService:
    创建订单 → 发布 OrderCreated Event

InventoryService(订阅 OrderCreated):
    扣减库存 → 发布 InventoryDeducted Event
    或库存不足 → 发布 InventoryInsufficient Event

PaymentService(订阅 InventoryDeducted):
    扣款 → 发布 PaymentProcessed Event
    或扣款失败 → 发布 PaymentFailed Event

OrderService(订阅 PaymentProcessed):
    更新订单状态 = CONFIRMED

OrderService(订阅 PaymentFailed):
    取消订单 → 发布 OrderCancelled Event

InventoryService(订阅 OrderCancelled):
    恢复库存

协同式更松散,但需要事件总线支持(第 12.1 节)。

补偿设计原则

原则说明示例
幂等性补偿可安全重复执行补偿流水以 (saga_id, step) 唯一键去重;quantity + 1 执行两次会加两次,不幂等
可观测补偿操作记录日志Saga 执行日志表
异步友好补偿也是响应式inventoryService.restore() 返回 Mono
有限重试补偿失败重试 N 次3 次后告警人工处理

Saga 状态机

复杂 Saga 需要显式状态机:

public enum SagaState {
    STARTED,
    ORDER_CREATED,
    INVENTORY_DEDUCTED,
    PAYMENT_PROCESSED,
    COMPLETED,
    COMPENSATING,
    COMPENSATED,
    FAILED;

    public boolean isTerminal() {
        return this == COMPLETED || this == COMPENSATED || this == FAILED;
    }
}

@Component
public class SagaStateMachine {
    private final SagaLogRepository logRepo;

    public Mono<Void> transition(String sagaId, SagaState from, SagaState to) {
        return logRepo.save(new SagaLog(sagaId, from, to, Instant.now(), null))
            .then();
    }

    public Mono<SagaState> getCurrentState(String sagaId) {
        return logRepo.findLatestBySagaId(sagaId)
            .map(SagaLog::toState);
    }
}

Saga 日志持久化到数据库,支持故障恢复后状态重建。注意上面的 transition 只是追加日志,没有校验当前状态:并发或重复投递可能写出两条互相矛盾的转移。生产实现应把期望的起始状态作为条件写进更新,例如 UPDATE saga_state SET to_state = :to WHERE saga_id = :id AND to_state = :from,影响行数为 0 就说明状态已被别的执行者改过;或者在 append-only 日志表上对 (saga_id, seq) 加唯一约束、用版本号做乐观锁。冲突时应拒绝并让调用方重读状态,而不是覆盖。

响应式 Saga 的陷阱

陷阱 1:补偿顺序

// 错误:并行执行补偿
cancelOrder().subscribe();     // 异步,可能晚于 restoreInventory
restoreInventory().subscribe();

// 正确:顺序执行
restoreInventory()
    .then(cancelOrder())
    .subscribe();

陷阱 2:忽略补偿失败

// 错误:补偿失败被吞掉
.onErrorResume(err -> compensation().onErrorResume(e -> Mono.empty()));

// 正确:补偿失败记录告警,并且仍然把原始失败上抛
.onErrorResume(err -> compensation()
    .doOnError(compErr -> alert("Compensation failed: " + compErr))
    .onErrorResume(compErr -> Mono.empty())
    .then(Mono.error(err)));

补偿失败被 onErrorResume 变成完成信号后,调用方会以为 Saga 成功;必须显式把原始异常重新抛出,或者返回一个明确的 SagaFailedException。

陷阱 3:状态不一致窗口

正向操作和补偿之间有时间窗口。在此期间查询状态,可能看到不一致。

解决:Saga 状态查询接口

@GetMapping("/sagas/{sagaId}")
public Mono<SagaStatus> getSagaStatus(@PathVariable String sagaId) {
    return sagaStateMachine.getCurrentState(sagaId)
        .switchIfEmpty(Mono.error(new SagaNotFoundException(sagaId)))
        .map(state -> new SagaStatus(sagaId, state, state.isTerminal()));
}

eventd v2 的 Saga 实现

eventd v2 使用编排式 Saga(代码集中,易于调试):

OrderSagaOrchestrator(eventd-api)
    → OrderService(R2DBC 事务)
    → InventoryService(HTTP 调用)
    → PaymentService(HTTP 调用)
        失败 → 补偿链(逆序)
            → refundPayment()
            → restoreInventory()
            → cancelOrder()

Saga 日志写入 saga_logs 表,支持运营后台查询:

CREATE TABLE saga_logs (
    id BIGSERIAL PRIMARY KEY,
    saga_id VARCHAR(64) NOT NULL,
    from_state VARCHAR(32),
    to_state VARCHAR(32) NOT NULL,
    occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    error_msg TEXT
);

-- 按 Saga 倒序取最新状态
CREATE INDEX idx_saga_logs_saga_id ON saga_logs (saga_id, occurred_at DESC);

对应的日志记录:

public record SagaLog(
    String sagaId, SagaState from, SagaState to,
    Instant occurredAt, String errorMsg
) {
    public SagaState toState() { return to; }
}

小结

模式控制复杂度调试适用
编排式中央协调器中易(单点)流程固定、步骤少
协同式事件驱动高难(分散)流程多变、服务多
要素实现
补偿逆序、幂等、响应式
日志Saga 状态机持久化
恢复启动时重放未完成的 Saga(每步必须幂等,否则重放会重复扣款/加库存)
告警补偿失败通知人工

下一节 12.3 eventd v2 全链路集成 :Netty → WebFlux → R2DBC → Cache 端到端可运行架构。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南