🎯 什麼情境該想到我

當你「多步驟流程需要一個中央物件維護狀態、決定下一步,並處理分支與並行」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:維護一個含多步驟、且順序可能依中途結果而定的流程狀態。

做法要點:

  • 用一個中央的流程管理器元件。
  • 為每個流程實例(process instance)保存狀態。
  • 每收到一則訊息就更新狀態,並決定要觸發哪些下一步(可分支、可並行後再合流)。
  • 比路由單更靈活,但流程知識集中在管理器裡。

★★ 這個模式今天的名字:Saga(編排式)/Orchestration

★ 兩種分散式流程協調,這一頁講的是「編排」:
① ★ 編排 Orchestration(=流程管理器)
   一個中央協調者持有狀態,主動呼叫/發命令給各服務
   → ★ 流程一目瞭然、好查進度;★ 但協調者知道所有參與者(耦合集中)
② ★ 協同 Choreography
   沒有中央協調者,各服務訂閱事件、自己決定要做什麼
   → ★ 服務間耦合低;★★ 但「整個流程是什麼」不存在於任何一處程式碼裡

★★ 判準:這個流程需要被「查詢」和「人工介入」嗎?
需要(訂單處理、KYC 審核、退款)→ 編排。你需要一個能回答「這筆到哪了」的地方。
不需要(純粹的事件反應)→ 協同。

★ 實務上多數業務流程需要編排,因為客服會問「我的退款到哪了」。

★ 現成的工具(強烈建議不要自己寫)

工具特性
Temporal★ 程式碼即流程(Go SDK 很成熟),自動持久化、重放、逾時、重試
AWS Step Functions狀態機 JSON/ASL,託管
Camunda/ZeebeBPMN,適合有流程建模需求的組織
★ 自己寫一張狀態表 + 一個 cron ——★ 簡單流程可行,見下

★★ 自己寫流程引擎是最常見的「重新發明輪子」。
你會依序遇到:狀態持久化 → 逾時 → 重試 → 補償 → 版本化(流程改了,跑到一半的怎麼辦)
→ 查詢介面 → 人工介入 → 然後你就寫了一個爛的 Temporal。
★ 簡單線性流程可以自己寫;有分支、補償、長時間等待 → 用現成的。

★ 自己寫的最小可行版本

// ★ 狀態表 —— 這是流程管理器的本體
type Saga struct {
    SagaID    string     `db:"saga_id"`
    Type      string     `db:"type"`         // "order_fulfilment"
    State     string     `db:"state"`        // ★ 明確的狀態機
    Data      []byte     `db:"data"`         // 累積的流程資料
    Version   int        `db:"version"`      // ★★ 樂觀鎖,防併發覆寫
    NextRetry *time.Time `db:"next_retry"`   // ★ 逾時/重試的基準
    UpdatedAt time.Time  `db:"updated_at"`
}
 
// ★ 每次事件到達:載入 → 轉移 → 存回(同一個交易)
func handle(ctx context.Context, ev Event) error {
    return inTx(ctx, func(tx Tx) error {
        s, err := loadForUpdate(tx, ev.SagaID)      // ★★ SELECT ... FOR UPDATE
        if err != nil { return err }
        next, cmds := transition(s, ev)             // ★ 純函式:好測、可窮舉
        if err := save(tx, next); err != nil { return err }
        return enqueueOutbox(tx, cmds)              // ★★ 命令走 Outbox,和狀態同一交易
    })
}

★★ 三個關鍵設計:

  1. transition 是純函式(狀態 + 事件 → 新狀態 + 要發的命令)→ 可以用表格測試窮舉所有轉移。
  2. SELECT ... FOR UPDATE 或樂觀鎖——兩個事件同時到達會互相覆寫狀態。
  3. ★★ 發出的命令要走 Outbox,和狀態更新在同一個交易裡
    (否則「狀態改了但命令沒發」或反之,見 工具-Transactional-Outbox-Pattern)。

★★ 補償(Saga 的核心):它不是回滾

★ 已扣款 → 庫存不足 → 要「補償」
  ✅ 發一筆退款(★ 新的交易,帳上會有兩筆紀錄)
  ❌ 不是「取消那筆扣款」(★ 交易已經 commit 了,無法回滾)

三個必須接受的現實:

① ★★ 補償本身也會失敗
   退款 API 掛了 → ★ 你需要「補償的重試」,而且最終要能人工介入
   → ★ 一定要有「需要人工處理」的終態,不要無限重試

② ★★ 有些動作不可補償
   已寄出的實體信件、已發送的通知、已呼叫的第三方不可逆 API
   → ★ 設計時要把不可補償的動作「排在最後」(見 [[工具-分散式事務的取捨]])

③ ★ 中間狀態對外可見
   「已扣款但未出貨」這個狀態會被使用者看到
   → ★★ 這是產品設計問題:要在 UI 上怎麼呈現?

★★ 版本化:最容易忘記的一個問題

★ 流程改了(新增一個步驟),但有 500 筆 Saga 正跑到一半
→ ① 新舊流程並存(Saga 記錄自己是哪個版本)← ★ 正確做法
   ② 強迫舊的走新流程 → ★ 狀態機可能沒有對應的轉移 ⇒ 卡死
   ③ 把舊的全部標成失敗 → 業務不會接受

★ 所以 Saga 表要有 FlowVersion 欄位,而 transition 要依版本分派。
Temporal 有內建的 versioning 機制處理這件事——這是「不要自己寫」的一個具體理由。

★ 兄弟辨析

差別
路由單★ 無狀態、線性、不能分支或補償
管道與過濾器固定拓撲、無中央狀態
聚合器★ 只解決「收齊」,不解決多回合與分支
散發-聚集單一回合的「發出去再收回」
中介者模式★ 行程內的同一想法

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 中央化容易變複雜,可能成為瓶頸與單點。

  • 每個流程實例的狀態需要持久化。

  • 順序固定的簡單流程,用路由單即可,不必動用流程管理器。

  • ★★ 不要自己寫流程引擎。你會依序遇到持久化、逾時、重試、補償、版本化、查詢、人工介入——然後寫出一個爛的 Temporal。簡單線性流程才自己寫。

  • ★★ 補償不是回滾。已 commit 的交易無法回滾,只能用新交易抵銷,帳上會有兩筆。

  • ★★ 補償本身也會失敗。必須有「需要人工處理」的終態,不要無限重試。

  • ★★ 有些動作不可補償(已寄出的信、已呼叫的第三方不可逆 API)。設計時把它們排在最後。

  • ★★ 發出的命令必須和狀態更新在同一交易裡(走 Outbox),否則狀態與命令會不一致。

  • ★★ 併發事件會互相覆寫狀態。要用 SELECT ... FOR UPDATE 或樂觀鎖。

  • ★★ 流程版本化最容易忘記:改了流程,跑到一半的 500 筆怎麼辦?Saga 要記自己的 FlowVersion。

  • ★ 中間狀態(已扣款未出貨)對外可見,這是產品設計問題。

  • transition 要寫成純函式,才能用表格測試窮舉所有狀態轉移。

🔗 相關工具