🎯 什麼情境該想到我
當你「多步驟流程需要一個中央物件維護狀態、決定下一步,並處理分支與並行」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:維護一個含多步驟、且順序可能依中途結果而定的流程狀態。
做法要點:
- 用一個中央的流程管理器元件。
- 為每個流程實例(process instance)保存狀態。
- 每收到一則訊息就更新狀態,並決定要觸發哪些下一步(可分支、可並行後再合流)。
- 比路由單更靈活,但流程知識集中在管理器裡。
★★ 這個模式今天的名字:Saga(編排式)/Orchestration
★ 兩種分散式流程協調,這一頁講的是「編排」:
① ★ 編排 Orchestration(=流程管理器)
一個中央協調者持有狀態,主動呼叫/發命令給各服務
→ ★ 流程一目瞭然、好查進度;★ 但協調者知道所有參與者(耦合集中)
② ★ 協同 Choreography
沒有中央協調者,各服務訂閱事件、自己決定要做什麼
→ ★ 服務間耦合低;★★ 但「整個流程是什麼」不存在於任何一處程式碼裡
★★ 判準:這個流程需要被「查詢」和「人工介入」嗎?
需要(訂單處理、KYC 審核、退款)→ 編排。你需要一個能回答「這筆到哪了」的地方。
不需要(純粹的事件反應)→ 協同。★ 實務上多數業務流程需要編排,因為客服會問「我的退款到哪了」。
★ 現成的工具(強烈建議不要自己寫)
| 工具 | 特性 |
|---|---|
| Temporal | ★ 程式碼即流程(Go SDK 很成熟),自動持久化、重放、逾時、重試 |
| AWS Step Functions | 狀態機 JSON/ASL,託管 |
| Camunda/Zeebe | BPMN,適合有流程建模需求的組織 |
| ★ 自己寫 | 一張狀態表 + 一個 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,和狀態同一交易
})
}★★ 三個關鍵設計:
transition是純函式(狀態 + 事件 → 新狀態 + 要發的命令)→ 可以用表格測試窮舉所有轉移。SELECT ... FOR UPDATE或樂觀鎖——兩個事件同時到達會互相覆寫狀態。- ★★ 發出的命令要走 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要寫成純函式,才能用表格測試窮舉所有狀態轉移。
🔗 相關工具
- 路由單 Routing Slip(簡單線性流程的輕量替代)
- 訊息代理器 Message Broker(同為中央化的協調樞紐)
- 聚合器 Aggregator(並行分支合流時常需彙整)
- 企業整合模式
- 路由單 —— 無狀態、線性的輕量版
- 管道與過濾器 —— 固定拓撲
- 聚合器、散發-聚集 —— 單一回合的收斂
- 命令訊息 —— 協調者發出的是命令
- 工具-分散式事務的取捨 —— ★ Saga 與補償的完整討論
- 工具-Transactional-Outbox-Pattern —— 狀態與命令的原子性
- 中介者模式 —— 行程內的同一想法