PAYATHON 2026

Saga pattern 中服务完成事务却未通知下一服务如何处理

支付阿杰

结论

Saga 的下一步应由 coordinator 决定并持久化,不应让 make payment service 直接通知 update inventory service。

既然 coordinator 已确认 Making payment 成功,支付服务随后宕机就不该影响后续流程。Coordinator 应在同一个本地数据库事务中完成以下操作:

  1. 将支付步骤标记为 SUCCEEDED。
  2. 将下一条 UpdateInventory 命令写入 outbox。
  3. 由后台发布器把命令投递到消息队列。
  4. 如果没有收到库存处理结果,继续重试。

在队列后部署多个服务实例可以提高可用性和吞吐量,但无法取代 coordinator 的状态恢复、超时检查和重试机制。系统的正确性依赖持久化状态机、Transactional Outbox、Inbox 去重和幂等处理,而不是实例数量。

为什么不能依赖当前服务通知下一个服务

如果支付服务按下面的顺序处理:

提交支付事务
    ↓
发送 UpdateInventory 消息

进程可能恰好在两个操作之间宕机。支付已经成功,消息却没有发出,这就是典型的 dual-write problem。

反过来也会出问题:

发送 UpdateInventory 消息
    ↓
提交支付事务

如果消息发送成功,但支付事务随后回滚,库存服务就可能处理一笔实际并未完成的支付。

数据库提交和消息发送通常无法组成可靠的原子操作。捕获异常、增加实例或设置超时,都不能彻底消除这个故障窗口。

在 orchestrated Saga 中,参与者只需处理 coordinator 发来的命令,并把执行结果可靠地返回 coordinator。下一步执行什么,应由 coordinator 的 Saga 状态机决定。

推荐的执行流程

以订单 Saga 为例,可以定义这些状态:

ORDER_CREATED
PAYMENT_PENDING
PAYMENT_SUCCEEDED
INVENTORY_PENDING
INVENTORY_SUCCEEDED
DELIVERY_PENDING
COMPLETED
COMPENSATING
FAILED

正常流程如下:

Coordinator -> PaymentService: MakePayment
PaymentService -> Coordinator: PaymentSucceeded
Coordinator -> InventoryService: UpdateInventory
InventoryService -> Coordinator: InventoryUpdated
Coordinator -> DeliveryService: DeliverOrder

这里的箭头表示持久化消息,不是同步 HTTP 调用链。

Coordinator 收到 PaymentSucceeded 后,应在一个本地数据库事务中更新 Saga 状态,同时创建下一步消息:

BEGIN;

UPDATE saga_instance
SET current_state = 'INVENTORY_PENDING',
    updated_at = CURRENT_TIMESTAMP,
    version = version + 1
WHERE saga_id = :saga_id
  AND current_state = 'PAYMENT_PENDING';

INSERT INTO outbox_message (
    message_id,
    saga_id,
    message_type,
    payload,
    status,
    created_at
) VALUES (
    :message_id,
    :saga_id,
    'UpdateInventory',
    :payload,
    'PENDING',
    CURRENT_TIMESTAMP
);

COMMIT;

后台 outbox publisher 查询尚未发布的记录,并将其发送到消息队列:

public void publishPendingMessages() {
    List<OutboxMessage> messages = outboxRepository.findPublishableMessages();

    for (OutboxMessage message : messages) {
        try {
            messageBroker.publish(
                message.getMessageType(),
                message.getMessageId(),
                message.getPayload()
            );

            outboxRepository.markPublished(message.getMessageId());
        } catch (Exception e) {
            outboxRepository.scheduleRetry(message.getMessageId());
        }
    }
}

如果 publisher 在消息发送成功后、更新 PUBLISHED 状态前宕机,同一条消息可能会再次发送。因此,系统应按 at-least-once delivery 设计,不能假设消息只会投递一次。

服务端必须进行 Inbox 去重

每个参与服务都应使用稳定的 messageId 或业务操作 ID 去重。同一个逻辑命令在重试时不能生成新 ID,否则系统无法识别重复消息。

库存服务可以在同一个本地事务中完成去重、库存更新和结果事件写入:

BEGIN;

INSERT INTO inbox_message (
    message_id,
    received_at
) VALUES (
    :message_id,
    CURRENT_TIMESTAMP
)
ON CONFLICT (message_id) DO NOTHING;

如果插入没有影响任何记录,说明这条消息已经处理过。此时可以直接确认消息,不再重复扣减库存。

如果是第一次处理,则继续执行:

UPDATE inventory
SET available_quantity = available_quantity - :quantity
WHERE product_id = :product_id
  AND available_quantity >= :quantity;

INSERT INTO outbox_message (
    message_id,
    saga_id,
    message_type,
    payload,
    status,
    created_at
) VALUES (
    :result_message_id,
    :saga_id,
    'InventoryUpdated',
    :result_payload,
    'PENDING',
    CURRENT_TIMESTAMP
);

COMMIT;

inbox_message、业务数据更新和结果 outbox 必须放在同一个本地事务中。否则,仍可能出现业务操作已经完成,但去重记录或结果消息没有保存的情况。

除了消息级去重,业务表最好也设置唯一约束。例如,每个 Saga 只允许生成一次支付记录:

CREATE UNIQUE INDEX uk_payment_saga_operation
ON payment_record (saga_id, operation_type);

幂等不能只依赖应用程序先查询再更新,因为多个实例可能同时消费重复消息。数据库唯一约束或原子条件更新才是最后一道防线。

Coordinator 宕机后如何恢复

Coordinator 的状态不能只保存在内存中。每个 Saga 实例至少需要持久化以下信息:

  • sagaId
  • 当前状态
  • 已完成步骤
  • 下一步动作
  • 重试次数
  • 下次重试时间
  • 超时时间
  • 状态版本号
  • 最近一次错误
  • 补偿状态

Coordinator 重启后,可以扫描长时间没有进展的 Saga:

SELECT saga_id, current_state, retry_count
FROM saga_instance
WHERE current_state NOT IN ('COMPLETED', 'FAILED')
  AND next_retry_at <= CURRENT_TIMESTAMP;

恢复时应根据持久化状态继续驱动流程,不能从头执行整个 Saga:

public void recover(SagaInstance saga) {
    switch (saga.getCurrentState()) {
        case PAYMENT_PENDING ->
            republishMakePayment(saga);

        case INVENTORY_PENDING ->
            republishUpdateInventory(saga);

        case DELIVERY_PENDING ->
            republishDeliverOrder(saga);

        case COMPENSATING ->
            continueCompensation(saga);

        default ->
            scheduleManualReview(saga);
    }
}

如果 coordinator 已经记录了 PAYMENT_SUCCEEDED 或 INVENTORY_PENDING,就应重发 UpdateInventory,而不是再次创建支付。

多个 coordinator 实例同时扫描时,需要防止它们并发推进同一个 Saga。常见做法包括:

  • 使用数据库行锁,例如 SELECT ... FOR UPDATE SKIP LOCKED
  • 使用乐观锁和 version 字段
  • 使用带过期时间的 lease
  • 按 sagaId 对消息分区,尽量保证同一 Saga 按顺序消费

即使已经使用分区和锁,处理逻辑也要保持幂等。消费者重平衡、网络超时和进程崩溃都可能造成重复投递。

超时、重试和补偿分别解决什么问题

超时不等于业务失败。它通常只表示 coordinator 没有及时收到结果,可能的原因包括:

  • 服务尚未处理命令
  • 服务已经处理,但结果消息丢失或延迟
  • 消费者处理完成后宕机,消息被重新投递
  • coordinator 收到结果后,在确认消息前宕机
  • 下游服务暂时不可用

因此,第一次超时时通常应重发同一个幂等命令,而不是立即执行补偿:

超时
  -> 使用相同 operationId 重试
  -> 指数退避并增加随机抖动
  -> 达到重试上限后进入人工处理或补偿策略

重试策略可以这样实现:

Duration retryDelay(int attempt) {
    long seconds = Math.min(300, 1L << Math.min(attempt, 8));
    long jitter = ThreadLocalRandom.current().nextLong(0, 10);
    return Duration.ofSeconds(seconds + jitter);
}

是否补偿取决于业务语义,不能只看是否发生了技术超时。例如,库存更新最终失败时可以发起退款;但在”支付结果未知”时,不应直接退款,而要先通过幂等重试或查询支付状态来确认实际结果。

补偿操作本身也必须幂等,而且同样可能失败。因此,补偿也是一个需要持久化、重试和监控的流程:

CancelDelivery
ReleaseInventory
RefundPayment
CancelOrder

Saga 提供的是 eventual consistency,不保证所有服务在同一时刻处于完全一致的状态。

已经发生”支付成功但没有任何可靠记录”怎么办

如果支付服务已经提交本地事务,却没有把结果可靠地写入 outbox,coordinator 也不知道支付成功,那么系统无法只根据消息队列推断真实状态。

此时只能通过 reconciliation 恢复:

  1. Coordinator 使用稳定的 paymentOperationId 重试 MakePayment。
  2. 支付服务发现该操作已经成功,返回原来的支付结果。
  3. 或者由 reconciliation job 查询支付记录,把缺失的 PaymentSucceeded 事件重新写入 outbox。
  4. 无法自动确认的记录进入人工处理队列。

例如:

@Transactional
public PaymentResult makePayment(MakePayment command) {
    return paymentRepository
        .findByOperationId(command.operationId())
        .map(PaymentResult::fromExisting)
        .orElseGet(() -> createPaymentAndResultEvent(command));
}

如果系统没有稳定的操作 ID、支付业务记录或其他可核对的数据,就没有通用的自动恢复办法。这类情况需要人工核账,也是 greenfield system 在设计阶段应避免的缺口。

题设中,coordinator 已经收到支付事务结束通知,因此不属于这种不确定状态。Coordinator 应直接根据自己的持久化状态发布库存命令。

多实例和 centralized queue 应如何定位

建议在消息队列后部署多个无状态服务实例,它们可以解决:

  • 单个实例宕机后的自动接管
  • 水平扩容
  • 消费积压
  • 滚动升级期间的可用性

但多实例不能单独保证:

  • 数据库提交后消息一定发送
  • 消息绝不重复
  • Saga 状态一定恢复
  • 多个步骤严格按照业务顺序执行
  • 补偿操作一定成功

消息队列也不应是唯一的状态来源。队列负责传输,服务数据库保存业务事实,coordinator 数据库保存 Saga 的执行状态。

较完整的职责划分如下:

组件主要职责
CoordinatorSaga 状态机、步骤推进、超时、重试、补偿和恢复
Message broker命令与事件的持久化传输、消费者竞争和故障转移
Participant service幂等执行本地事务,持久化处理结果
Outbox publisher将本地数据库中的待发送消息可靠地发布到 broker
Inbox消费端去重,防止重复执行业务操作
Reconciliation job检查长期不一致或缺失事件,并重新驱动流程
Monitoring system监控积压、重试、超时、死信和停滞 Saga

Greenfield system 的建议

设计新系统时,可以遵循以下原则:

  1. 将 Saga 明确定义为持久化状态机,不要用一串同步 HTTP 调用模拟分布式事务。
  2. 只由 coordinator 决定下一步,参与服务不要同时承担编排职责。
  3. 使用 Transactional Outbox 解决数据库更新和消息发送之间的原子性问题。
  4. 使用 Inbox、唯一约束和稳定的 operation ID 实现幂等。
  5. 按 at-least-once delivery 设计,不追求跨数据库和消息系统的虚假 “exactly once”。
  6. Coordinator 和参与服务都可以部署多个实例,但必须通过锁、版本控制或唯一约束保证并发安全。
  7. 对临时故障自动重试,对永久业务失败执行补偿,对无法判断的状态进行 reconciliation。
  8. 为每个 Saga 保留完整的状态转换和关联 ID,便于审计与排障。
  9. 监控停滞时间、重试次数、outbox 积压、死信消息和补偿失败。
  10. 不要把消息进入 dead-letter queue 视为流程结束。死信必须能够重新驱动,或进入人工处理机制。

目标不是避免所有重复消息,而是让每个业务动作在重复投递、进程崩溃和网络超时的情况下,都能得到相同的最终结果。只要 Saga 状态和待发送命令已经可靠落盘,任何单个进程实例宕机都只会延迟流程,不会让流程永久中断。

备注:内容仅供参考。