Saga pattern 中服务完成事务却未通知下一服务如何处理
结论
Saga 的下一步应由 coordinator 决定并持久化,不应让 make payment service 直接通知 update inventory service。
既然 coordinator 已确认 Making payment 成功,支付服务随后宕机就不该影响后续流程。Coordinator 应在同一个本地数据库事务中完成以下操作:
- 将支付步骤标记为
SUCCEEDED。 - 将下一条
UpdateInventory命令写入 outbox。 - 由后台发布器把命令投递到消息队列。
- 如果没有收到库存处理结果,继续重试。
在队列后部署多个服务实例可以提高可用性和吞吐量,但无法取代 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 恢复:
- Coordinator 使用稳定的
paymentOperationId重试MakePayment。 - 支付服务发现该操作已经成功,返回原来的支付结果。
- 或者由 reconciliation job 查询支付记录,把缺失的
PaymentSucceeded事件重新写入 outbox。 - 无法自动确认的记录进入人工处理队列。
例如:
@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 的执行状态。
较完整的职责划分如下:
| 组件 | 主要职责 |
|---|---|
| Coordinator | Saga 状态机、步骤推进、超时、重试、补偿和恢复 |
| Message broker | 命令与事件的持久化传输、消费者竞争和故障转移 |
| Participant service | 幂等执行本地事务,持久化处理结果 |
| Outbox publisher | 将本地数据库中的待发送消息可靠地发布到 broker |
| Inbox | 消费端去重,防止重复执行业务操作 |
| Reconciliation job | 检查长期不一致或缺失事件,并重新驱动流程 |
| Monitoring system | 监控积压、重试、超时、死信和停滞 Saga |
Greenfield system 的建议
设计新系统时,可以遵循以下原则:
- 将 Saga 明确定义为持久化状态机,不要用一串同步 HTTP 调用模拟分布式事务。
- 只由 coordinator 决定下一步,参与服务不要同时承担编排职责。
- 使用 Transactional Outbox 解决数据库更新和消息发送之间的原子性问题。
- 使用 Inbox、唯一约束和稳定的 operation ID 实现幂等。
- 按 at-least-once delivery 设计,不追求跨数据库和消息系统的虚假 “exactly once”。
- Coordinator 和参与服务都可以部署多个实例,但必须通过锁、版本控制或唯一约束保证并发安全。
- 对临时故障自动重试,对永久业务失败执行补偿,对无法判断的状态进行 reconciliation。
- 为每个 Saga 保留完整的状态转换和关联 ID,便于审计与排障。
- 监控停滞时间、重试次数、outbox 积压、死信消息和补偿失败。
- 不要把消息进入 dead-letter queue 视为流程结束。死信必须能够重新驱动,或进入人工处理机制。
目标不是避免所有重复消息,而是让每个业务动作在重复投递、进程崩溃和网络超时的情况下,都能得到相同的最终结果。只要 Saga 状态和待发送命令已经可靠落盘,任何单个进程实例宕机都只会延迟流程,不会让流程永久中断。
备注:内容仅供参考。
