一個轉帳 workflow 跑到第三步才失敗。錢已經離開 A 銀行,C 確認信還沒寄出,而你不能對 A 的系統說一句「當作沒發生」——一筆已 commit 的交易,沒有 undo 鍵。Cloudflare 替 Workflows 補的這塊,正是這個「跑了一半、收不回來」的洞。
Cloudflare Workflows 的 saga 式 rollback
durable execution 解決的是「程式中途掛掉怎麼接續」:每個 step 的結果都被持久化,機器重新啟動、worker 換手、process crash 之後,workflow 從上次的進度 replay 回來,已完成的 step 不會重跑。但 durable 只保證「每一步最終會跑完一次」,它不保證「整條 workflow 語意上是一個原子操作」。當第 N 步成功、把錢從 A 銀行扣走、寫進對方系統,然後第 N+1 步失敗時,durable execution 會忠實地把「A 已扣款」這個事實保留下來——它本來就是設計來保留事實的。問題是,這個事實此刻是個半成品。Cloudflare 這次替 Workflows 加上 saga 式的 rollback,補的就是 durable execution 天生補不了的那一段:外部副作用的回收。
這篇是一個關於「收不回來的副作用」的故事。先看那個經典的失敗模式長什麼樣、為什麼 retry 救不了它;再看 saga pattern 怎麼替每一步配一個補償動作、失敗時反向執行;然後是這套機制怎麼和 Workflows 既有的 durable execution、replay、idempotency 接在一起;最後是它落到開發者手上時,多了哪些責任、又少了哪些保證。先從下面這個可以一步步推的 saga stepper 開始——按 forward 推進工作流,在第三步觸發失敗,再看補償動作如何反向把已生效的副作用一個個拆回去。
按 forward 推進工作流、在 step 3 觸發失敗、再 unwind 補償 · 3 步 + 反向補償
互動圖表
三步轉帳第三步失敗,saga 補償按 step 開始的順序反向執行——先補 step2、再補 step1,而不是按完成順序,因為啟動順序才是被持久化的穩定依據。
錢已經離開 A 銀行——retry 為什麼救不了半成品
失敗的場景很具體。Cloudflare 用一個跨行轉帳當例子:step 1 從 A 銀行扣款、step 2 對 B 銀行入帳、step 3 寄出確認。中間任何一步失敗,前面已完成的步驟就會把整條交易卡在一個半成品狀態——原文的說法是「if one step fails, it may leave earlier work from completed steps in an inconsistent or partial state」。錢扣了、沒到帳,這不是「還沒做完」,是「做了一半、而且做掉的那一半生效了」。
關鍵在於 step 1 一旦成功就無法簡單撤銷。Cloudflare 的描述是「Once the debit succeeds at Bank A, the transaction is committed and the money has left its system. As the orchestrator of the transaction, you cannot simply 'undo' the operation in Bank A's system.」你是這筆交易的 orchestrator,但 A 銀行的帳本不歸你管——對它而言那筆扣款是一筆已 commit 的合法交易,沒有 rollback API 讓你把它當作沒發生過。這跟資料庫 transaction 的 rollback 是兩回事:DB 的 rollback 靠的是還沒 commit 的 undo log,而這裡的副作用早就跨出了你的事務邊界,落在別人的系統裡。
retry 在這裡幫不上忙,甚至會把事情弄得更糟。durable execution 的 retry 是針對「同一步沒成功、再試一次」設計的;但 step 3 失敗時,問題不在 step 3 本身要不要再試,而在 step 1 和 step 2 已經生效的副作用要不要收回。重試 step 3 一萬次也改變不了「A 的錢已經出去了」這個事實。你需要的不是「往前再推一次」,而是「往回把已生效的動作一個個抵銷掉」——這是一個方向相反的操作,durable execution 原本的詞彙裡沒有它。
值得停下來想清楚 durable execution 到底承諾了什麼、又沒承諾什麼。它承諾的是「at-least-once 而且事實上 exactly-once 地把每一步跑完」:機器掛了、worker 漂移了、process 被 OOM kill 了,workflow 都能從持久化記錄接續回來,已完成的 step 讀結果而不重跑。這是一個關於「執行」的保證——它讓你不必自己寫 checkpoint、不必擔心一個長流程跑到一半斷電後從頭再來。但它對「跨系統的副作用是否語意一致」完全沉默。錢扣了、貨發了、第三方 API 已經回了 200,這些在 durable execution 眼裡都只是「某一步成功了」的事實記錄,它沒有義務、也沒有能力判斷這些事實合在一起是不是一個合理的最終狀態。
這也是為什麼這個洞拖到現在才補:它不是 bug,是 durable execution 抽象的邊界。持久化每一步、保證最終一致地把每一步跑完一次,這個保證本身是對的;它只是不延伸到「整條 workflow 是一個可復原的單元」。傳統上開發者怎麼補這一段?多半是手寫——在 workflow 外層包一個大的 try/catch,catch 到失敗後,自己按記憶把前面做過的事一件件反做回去。問題是這段「反做」邏輯散落在錯誤處理路徑上、跟正向邏輯離得很遠,又通常缺乏 durable execution 的 retry 與持久化保護:如果反做到一半又崩潰,前面反做過的部分會不會再反做一次?這段善後程式碼自己有沒有 durable?多數手寫版本對這些問題沒有好答案。要把這層補上,需要一個和「前進」對稱、而且同樣 durable 的概念。
saga pattern——替每一步配一個反向動作
saga 的想法不新,但 Cloudflare 給它一個乾淨的定義:每個會產生副作用的操作,都成對地帶一個「補償邏輯」——「This pairing of an operation and its compensation logic is called the saga pattern.」step 1 扣款,它的補償就是把錢退回去;step 2 入帳,它的補償就是把入帳沖銷掉。補償不是「undo」那種乾淨的時光倒流,它是另一個正向的業務操作,只是語意上抵銷前一個。退款本身也是一筆要 commit 的交易,只是它讓帳對得回來。
在 Workflows 裡,這對「操作 + 補償」被綁在同一個 step 上。補償邏輯作為 metadata 掛在 step.do() 上,跟著那一步走,而不是放在一個全域的 error handler 裡。Cloudflare 特別強調這個設計選擇:rollback 不是「a separate global error handler」,而是「metadata attached to the durable unit of work Workflows already understands」。這句話是整個機制的支點——rollback 不是事後補的災難處理,它是 durable execution 已經在追蹤的那個工作單元的一部分。寫起來大致是這樣:
// 每個 step 把自己的補償邏輯帶在身上
await step.do("debit-bank-a", () => bankA.debit(from, amount), {
rollback: async ({ output }) => bankA.credit(from, amount, output.id),
});
// rollback 拿得到原始 output、觸發失敗的 error、以及 stepContext
// 補償可以配自己的 retry / timeout 策略:
await step.do("credit-bank-b", () => bankB.credit(to, amount), {
rollback: async ({ output }) => {
if (output === undefined) return; // 這一步可能根本沒成功過
await bankB.debit(to, amount);
},
rollbackConfig: {
retries: { limit: 10, delay: '30 seconds', backoff: 'exponential' },
timeout: '2 minutes',
},
});
觸發補償的時機很講究:不是「任何一步報錯就 unwind」。Cloudflare 的規則是——如果你的程式碼自己 catch 了錯誤並繼續往下走,workflow 就繼續,補償不啟動;只有當 workflow 本身要 terminal 地失敗時,補償才開始。原文是「Rollback starts when the Workflow itself is about to fail terminally.」這個區分讓 rollback 不會被中途的、被妥善處理掉的錯誤誤觸發。換句話說,rollback 是「workflow 認輸」這一刻的善後機制,不是每一步的 try/catch。
還有一個容易被忽略的細節:失敗的那一步自己也可能需要補償。如果 step 3 在失敗前已經產生了部分副作用、而它登記了 rollback handler,那它一樣是 rollback-eligible 的。所以 unwind 的範圍是「所有開始過或完成過、且登記了 rollback 的 step」,包含正在失敗的那一個。補償 handler 收到的 output 因此可能是 undefined——那一步也許根本沒跑成功、沒有產出。Cloudflare 明說「Rollback handlers receive output, but must handle output === undefined.」這個 undefined 不是 edge case,是補償邏輯必須正面處理的常態輸入。
反向執行的順序——為什麼是 step-start order 而不是完成順序
補償反向執行聽起來理所當然,但「反向於什麼」這個問題比想像中微妙。step 之間可以並行:step A 先開始、step B 後開始,但 B 可能先完成。如果用「完成順序的反向」來 unwind,順序會依賴執行時的時序,而時序是不穩定的——同一條 workflow 跑兩次,完成順序可能不同。Cloudflare 的選擇是用 step 開始的順序:「Completion order can differ from start order, so Workflows uses reverse step-start order instead of reverse completion order.」補償一律按「step 被啟動的順序」反向走,而這個啟動順序是被持久化下來的、穩定的事實。
用 start order 還有一層語意上的道理。後啟動的 step 通常建立在先啟動的 step 之上——step 2 的入帳依賴 step 1 的扣款先發生。要拆解這種依賴,自然要從最後堆上去的那塊開始拆:先補 step 2、再補 step 1,跟疊盤子反過來收是同一個直覺。如果反過來、先補 step 1 再補 step 2,就可能在 step 1 的前提已經被抹掉之後,才去執行依賴那個前提的 step 2 補償,邏輯上站不住。reverse start order 不只是「一個穩定的順序」,它還是「尊重依賴方向」的那個順序。
要特別說明的是,「補償」和「資料庫 rollback」是兩種完全不同的東西,名字相近容易混淆。DB transaction 的 rollback 靠的是還沒 commit 的 undo log,撤銷之後外界根本看不到任何痕跡,彷彿那段交易從未發生。saga 的補償做不到這種乾淨——step 1 的扣款已經 commit、已經被對方系統看見、可能已經觸發了對方的通知與帳務,補償能做的只是再發一筆語意相反的交易把帳對回來。對外界而言,發生的是「扣款,然後退款」兩筆事件,而不是「什麼都沒發生」。這個差別在審計、對帳、以及使用者體驗上都是實的:saga 給的是最終一致,不是原子性。下面這張圖把「Workflows 為每個 step 持久化了什麼」攤開——正是這份記錄讓 replay 之後還知道該補哪些、按什麼順序補。
點任一欄看 Workflows 替每個 step 持久化了什麼 · 5 個欄位
每個 step 的持久化記錄——replay 與 unwind 都讀這份
點上方任一欄看細節
started · 啟動順序
記錄 step 是否啟動、以及啟動的先後。並行的 step 完成順序不定,但啟動順序是穩定且持久的——這就是 unwind 反向遍歷的依據。
completed · 是否完成
標記這一步有沒有跑完。replay 看到 completed 就直接讀結果跳過;unwind 用它判斷哪些步驟進入 rollback-eligible 集合。
returned · 回傳值
step body 的回傳被持久化。replay 觸及一個 completed 的 step.do() 時讀這份結果、不重跑;補償 handler 也以它為 output(可能是 undefined)。
skip-on-resume · 跳過旗標
workflow 之後 resume 時,這一步該被跳過而不是重複執行。這是 durable execution「已完成步驟不重跑」這個保證的落點。
rollback registered · 補償旗標
rollback 機制新增的記錄:這一步有沒有掛補償邏輯。Workflows 只需要替「有 rollback 且 eligible」的 step 重建 handler,不必重建全部。
接回 durable execution——replay、idempotency、與補償的重建
rollback 要能在崩潰之後仍然正確執行,靠的是它和 durable execution 共用同一套底層機制。Workflows 對每一步記錄的,原文列得很清楚:「Workflows records that the step started, whether it completed, what it returned, and whether it should be skipped instead of repeated if the Workflow resumes later.」啟動了沒、完成了沒、回傳了什麼、resume 時該不該跳過——rollback 在這份記錄上多加了一欄:這一步有沒有登記補償。這一欄就是 unwind 的索引。
replay 的語意決定了補償不會被重複觸發。當 replay 走到一個已完成的 step.do(),Workflows 直接讀持久化的結果、不重跑 step body——「When replay reaches a completed step.do(), Workflows reads the persisted result instead of running the step body again.」這對 rollback 的意義是:崩潰後重新啟動、workflow replay 到失敗點,那些已完成的正向 step 不會再執行一次(避免又扣一次錢),而 Workflows 知道哪些 step 完成過、哪些登記了補償,於是只需要替「有 rollback 且 eligible」的那些 step 重建 handler,而不是把整條 workflow 的 handler 全部重建。replay 把狀態還原到失敗的那一刻,unwind 從那裡接手。
但 replay 的「不重跑」只保證正向 step 不被重複執行,它管不到補償本身。補償在執行中也可能崩潰、可能 retry——而 retry 意味著同一個補償動作可能跑超過一次。所以 Cloudflare 把一個責任明白地推回給開發者:補償函式必須 idempotent。原文是「Rollback functions should be idempotent, just like regular Workflow steps. If you refund a charge, use the payment provider's idempotency key.」退款這種補償,要用支付方提供的 idempotency key,讓「退兩次」在對方系統裡折疊成「退一次」。這不是 Workflows 幫你保證的,是你寫補償時要自己守的不變式。
把這幾件事串起來:補償和正向 step 共用同一套機器——同樣的 retry、timeout、lifecycle event、log、以及最後一筆被記錄下來的結果。補償不是一個跑在框架外的特殊路徑,它就是一個方向相反的 durable step。也正因如此,它繼承了 durable step 的所有保證與所有責任,包括那條「請自己保證 idempotent」。下面這張圖把崩潰後的恢復路徑畫出來:replay 還原、定位失敗、只重建有補償的 step、反向執行。
給開發者的後果——多了什麼保證,又多了什麼責任
對寫 workflow 的人來說,這套機制改變的不只是「多一個選項」。它把一個原本散落在各處、靠人記得手寫的善後邏輯,收斂成一個跟著 step 走的 metadata。把補償綁在 step.do() 上、而不是放在一個全域 error handler 裡,意味著當你新增、刪除、調整一個 step 時,它的補償就在旁邊——不容易發生「改了正向邏輯卻忘了同步改某個遠處的 undo 分支」這種漂移。Cloudflare 也談到他們刻意避開了 fluent 風格(step.do(...).rollback(...))和 builder 風格(step.saga().do().rollback().run())的 API,理由各不相同——下面這張表把三種設計的取捨並排,點 header 可排序。
點欄位標頭排序 · 3 種 API 形態 × 4 個面向
| API 形態 | 寫法骨架 | Cloudflare 的取捨 | 結論 |
|---|---|---|---|
| metadata on step | step.do(name, fn, { rollback }) |
補償是掛在「durable 工作單元」上的 metadata,不是另一個全域 error handler;補償就在正向邏輯旁邊。 | 採用 |
| fluent | step.do(...).rollback(...) |
會牽動 promise pipelining——在 future 值尚未完全回到呼叫端前就對它呼叫方法,讓 step 時序變得難以預測。 | 否決 |
| builder | step.saga().do().rollback().run() |
每個 step 都要收一個 .run(),忘了收很容易、又難用工具察覺;連單步的簡單情況都長得像一串設定鏈。 |
否決 |
但保證的另一面是責任。這套機制把幾件事明白地放回開發者肩上。第一是 idempotency:補償可能 retry、可能跑超過一次,框架不替你去重,要 idempotent key 你得自己帶。第二是 output === undefined 的處理:補償拿到的 output 可能是空的,因為登記補償的那一步也許根本沒成功——你的補償邏輯得能優雅地對「沒有東西可補」這件事收手,而不是對著 undefined 拋例外。第三是補償本身的失敗:補償是個會 retry、會 timeout 的 durable step,所以它也會失敗,你得替它配 rollbackConfig 的 retry 與 timeout 策略,並接受「補償也可能補不成功」這個現實。
還有一個語意上的提醒值得記住:rollback 只在 workflow 要 terminal 失敗時才啟動,被你 catch 並繼續的錯誤不會觸發它。這把「workflow 認輸」和「某一步出錯但被處理掉」清楚地分開了——好處是補償不會被中途的小錯誤誤觸發,代價是你得清楚自己哪些錯誤是 catch 後繼續、哪些是讓它往上冒到 terminal。哪些副作用該配補償、哪些業務上其實可以容忍半成品(例如一封沒寄出的確認信通常不需要 unwind 前面的轉帳),這個判斷框架不替你做。saga 給的是執行反向動作的可靠機制,不是替你想清楚「什麼該被反向」。
把有沒有 rollback 的兩條結局並排看,最能說明這套機制到底買到了什麼。沒有補償時,step 3 失敗留下的是一個「扣了款、沒到帳」的不一致殘局,需要有人事後手動對帳、手動退款,而且這個善後本身不 durable——對帳腳本跑到一半掛了,沒人知道退到哪裡。有補償時,同樣的失敗會走進一段 durable 的 unwind:每個補償都帶 retry 與 timeout、每一步都被記錄、最終帳對得回來。代價是你要為每個有副作用的 step 多寫一個 idempotent 的補償、並接受最終狀態是「兩筆相反交易」而非「無痕」。
What changes:durable execution 保證每一步最終會跑完,saga rollback 補上「跑了一半時把已生效的副作用反向收回」——但它把 idempotency、undefined output、補償自身的失敗,明白地留給你守。