1. 题目
用户提交订单后,系统要在库存、支付、配送和优惠券四个服务中完成履约。任一步骤可能因业务规则或网络故障失败,系统不能回滚已经提交的远程数据库事务。请设计一套 Saga,让订单最终进入 CONFIRMED 或 CANCELLED,并能解释每个局部事务和补偿动作。
2. 约束与澄清
- 每个服务只对自己的数据库做本地 ACID 事务。
- 流程允许最终一致,但不能无限占用库存或支付授权。
- 重试可能重复执行步骤;服务必须使用幂等键和状态机保护。
- 需要区分可重试的技术失败、不可重试的业务拒绝和需要人工处理的补偿失败。
3. 核心思路
Saga 把一个长事务拆成一系列有序的局部事务 T1 ... Tn。每个步骤成功后记录进度并触发下一步;若后续步骤失败,则按逆序执行已完成步骤的补偿 Ck ... C1。补偿不是数据库回滚,而是新的业务操作,例如释放库存、撤销支付授权、取消配送和返还优惠券。
编排式 Saga 由一个持久化协调器保存状态和下一动作,适合需要明确流程、超时和人工介入的履约链路。事件驱动的 choreography 可以减少中心协调,但流程可见性和循环控制更难。无论模式如何,步骤和补偿都应携带 sagaid、stepid、版本和幂等键。
4. 参考实现
start(order):
saga = create_saga(order.id, state="RESERVE_STOCK")
dispatch(saga, "ReserveStock")
on_step_result(saga_id, step_id, result):
saga = load_and_lock(saga_id)
require result.version == saga.version + 1
if result.success:
saga.completed_steps.append(step_id)
saga.version += 1
next = next_step(saga)
persist(saga)
dispatch(next) if next else finish_confirmed(saga)
else if result.business_rejection:
saga.state = "COMPENSATING"
persist(saga)
dispatch(compensation_for_last_completed(saga))
else:
schedule_retry_or_timeout(saga, step_id)
on_compensation_result(saga_id, step_id, result):
record_attempt(saga_id, step_id, result)
if result.success:
dispatch(previous_compensation(saga))
else:
mark_manual_intervention(saga, reason=result.error)5. 一致性与正确性
协调器状态必须持久化,否则进程崩溃会丢失下一步。每个命令和事件使用幂等键;消费者先记录已处理的 step_id,再提交业务变化,避免重试造成双扣库存。步骤完成和发布下一命令之间也有发送失败窗口,应使用 Outbox、可靠队列或 CDC 保证命令最终可见。
补偿顺序通常与成功步骤相反,但不要求每个动作都能严格撤销;有些动作只能做业务上的反向调整,例如退款而非撤销已发出的通知。状态查询应返回当前步骤、已完成步骤、重试次数和人工原因,避免把“处理中”误报为成功。
6. 追问与陷阱
- 不要把 Saga 描述为跨数据库的原子回滚;它提供的是可恢复的最终一致性。
- 补偿动作也会失败,必须有重试、死信、告警和人工接管,而不是无限自动循环。
- 仅靠全局分布式锁会放大故障域,无法解决远程服务已经提交后的业务撤销。
- 需要明确库存预留和支付授权的 TTL,超时后由定时任务或事件驱动释放资源。
7. 延伸阅读
可以比较编排式与 choreography:前者集中记录状态、顺序和超时,后者通过事件解耦服务但更难追踪全链路。还应讨论“不可补偿的副作用”、业务语义锁、版本冲突、审计日志,以及何时把流程改回单体数据库事务。
8. 面试评分点
能拆分局部事务
应明确列出库存、支付、配送和优惠券步骤,说明每个服务只提交自己的本地事务。
能设计补偿状态机
应展示成功路径、失败路径和逆序补偿,并区分业务拒绝、技术重试和人工接管。
能处理幂等与消息可靠性
应使用 sagaid、stepid、幂等键和 Outbox/可靠队列,覆盖协调器崩溃与命令发送窗口。
能说明业务边界
应承认补偿不是回滚,讨论 TTL、不可补偿副作用、状态查询和最终一致性的用户体验。