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、不可補償副作用、狀態查詢和最終一致性的使用者體驗。