Workflows 持久化執行入門:崩潰也不重來 | Cloudflare 完整教學
上一篇《Queues 批次、重試與 DLQ》,我們把訊息佇列的容錯練到了生產級。但 Queues 有個天生的邊界:訊息即狀態,無法跨步驟保存進度,也不能睡眠等待。當一件工作需要「跨越多個步驟、每步獨立保存結果、就算中途崩潰也能從斷點續跑」時,你需要的是另一種更強的原語——Cloudflare Workflows 提供的 durable execution(持久化執行)。這一篇,我們就從零寫出第一個 Workflow,搞懂它為什麼「崩潰也不用重來」。
前言
先給 durable execution(持久化執行) 一個精準的定義:它是一種讓程式「每完成一步就把進度存下來,崩潰後能從斷點繼續、而非從頭重跑」的執行模型。Cloudflare Workflows 就是把這種能力做成平台內建原語的產品——你用 TypeScript 寫一個工作流程 class,把每個有副作用的動作包成一個「步驟(step)」,Workflows 就替你記住「哪些步驟做完了、各自的結果是什麼」。之後不管 Worker 因為部署、崩潰、還是機器回收而重啟,它都能把已完成步驟的結果從持久化儲存讀回來,讓程式看起來像從沒中斷過一樣繼續往下跑。
打個比方。傳統的 async function 像一位沒有筆記本的廚師:他把整份訂單(切菜、下鍋、擺盤)全記在腦子裡,只要中途被打斷(廚房跳電、他下班了),記憶清空,整份餐點只能從切菜重做一遍——如果「下鍋」這步已經耗掉真材實料(等於已經扣款、已經寄信),重做就是災難。而 Workflows 像一位隨手在流程卡上打勾的廚師:每完成一步就在卡上記下「這步做完了、產出是這個」。就算換了一位廚師接手(Worker 重啟),他拿起同一張卡,看到前三步已打勾,便直接從第四步接著做,不會把已經下鍋的料重炒一次。這張「會自動打勾、還能保存到明天」的流程卡,就是 durable execution 的精髓。
再對齊一組容易混淆的概念:Workflows 不是要取代 Queues。上一篇的 Queues 擅長「大量彼此獨立的訊息,越快消化越好」;而 Workflows 擅長「單一件事、有先後順序、要保住中間進度、可能跑很久」。兩者是互補的工具,選錯會很痛苦——這一篇會把界線劃清楚。
讀完你會掌握:
- durable execution 是什麼——為什麼「崩潰後從斷點續跑」是它與一般 function 的根本差異,以及它替你解決了什麼難題
WorkflowEntrypoint+run(event, step)——一個 Workflow 的最小骨架長什麼樣、event.payload與step各是什麼env.MY_WORKFLOW.create()——如何在 wrangler.jsonc 宣告 workflows binding,並從 fetch / queue / cron 觸發實例、查詢狀態- Workflows vs Queues——單一長流程 vs 大量獨立訊息的心智模型與選型準則
- 適用場景——多步驟業務流程、ETL、AI pipeline 為何天生適合 Workflows
本篇是 Workflows 系列的首篇,聚焦「入門骨架與核心心智」。
step.do的重試設定、step.sleep睡眠、step.waitForEvent等待外部事件這些步驟細節,留給下一篇專門展開。
核心概念
在動手前,先把三件事講透:durable execution 的崩潰恢復到底怎麼運作、Workflows 與 Queues 的根本差別、以及貫穿全文的關鍵術語。
一、durable execution:崩潰恢復是怎麼發生的
想像一個「處理訂單」的流程,包含四步:驗證庫存 → 扣款 → 建立出貨單 → 寄送通知。用傳統寫法,這四步是一個連續的 async function,所有中間變數都在記憶體裡。
現在假設在「扣款」成功之後、「建立出貨單」之前,執行它的 Worker 崩潰了(部署重啟、逾時、機器回收都可能)。傳統寫法的下場是:記憶體清空,整個 function 從頭再跑一次——於是扣款會被執行第二次,客戶被重複扣款。你當然可以自己去 D1 建一張狀態表,每步都寫入「我做到哪了」,重跑時先讀表跳過已完成步驟……但那等於要你手刻一套斷點續傳引擎,又臭又長又容易出錯。
Workflows 把這件苦差事變成平台能力。它的運作原理是這樣:
run(event, step) 開始執行
│
├─ step.do('驗證庫存') ──► 執行 ──► 結果持久化保存 ✓
│
├─ step.do('扣款') ──► 執行 ──► 結果持久化保存 ✓
│
├─ step.do('建立出貨單') ──► 執行中... ✗ Worker 崩潰!
│
▼
─────────────── Worker 重啟,Workflows 重新執行 run() ───────────────
│
├─ step.do('驗證庫存') ──► 已完成,直接讀回快取結果(不重跑)⏩
│
├─ step.do('扣款') ──► 已完成,直接讀回快取結果(不重跑)⏩
│
├─ step.do('建立出貨單') ──► 從這裡真正接續執行 ▶
│
└─ step.do('寄送通知') ──► 執行 ──► 結果持久化保存 ✓ ──► 完成
關鍵在於:當 Worker 重啟、Workflows 重新執行你的 run() 時,它會再次從頭讀你的程式碼,依序遇到每一個 step.do()。但對於「已經完成過」的步驟,它不會真的再執行一次那段函式,而是直接把上次持久化保存的回傳值取出來、當作這次的結果往下傳。只有還沒完成的步驟才會真正執行。這個「重播程式碼、但跳過已完成步驟」的機制,就是崩潰恢復的核心——已經扣過的款不會再扣,已經寄過的信不會再寄。
這也解釋了為什麼步驟的邊界很重要:只有被 step.do() 包起來的動作,其結果才會被持久化、才享有「不重跑」的保護。寫在步驟外面的程式碼(例如直接在 run() 裡呼叫一次外部 API)則會在每次重播時重新執行——這正是新手最常踩的坑,我們會在後面專門講。
二、Workflows vs Queues:單一長流程 vs 大量獨立訊息
上一篇的 Queues 和這一篇的 Workflows 都是非同步工具,但心智模型截然不同。一句話總結:Queues 處理「大量彼此獨立的訊息」,Workflows 處理「單一但跨越多步驟的長流程」。
| 面向 | Queues(佇列) | Workflows(工作流程) |
|---|---|---|
| 心智模型 | 一條輸送帶:海量訊息湧入,快速消化 | 一條有記憶的流水線:一件事、有步驟順序 |
| 單位 | 一則訊息(message) | 一個實例(instance) |
| 訊息/步驟關係 | 訊息之間彼此獨立、無順序 | 步驟之間有先後、共享持久化狀態 |
| 狀態保存 | 無(訊息即狀態,處理完就沒了) | 有(每步驟結果自動持久化) |
| 崩潰恢復 | 整則訊息重投、handler 從頭重跑 | 逐步驟恢復,已完成步驟不重跑 |
| 等待能力 | 不支援(訊息送達即處理) | 可睡眠數天、可等待外部事件 |
| 觸發方式 | env.QUEUE.send() / sendBatch() | env.MY_WORKFLOW.create() |
| 典型場景 | 背景影像處理、日誌落地、十萬筆推播扇出 | 訂單流程、ETL、AI 生成→審核→發布 |
判斷準則很直觀:
- 你的任務是「很多份、各自獨立、越快消化越好」→ 用 Queues(高吞吐、fire-and-forget)。
- 你的任務是「一份、有先後順序、要保住中間進度、可能很長」→ 用 Workflows(持久化、可續跑)。
而且兩者常常搭配使用:一個經典組合是 Queue → Workflow——大量請求先進 Queue 緩衝削峰,由 consumer 逐則呼叫 create() 啟動一個 Workflow 實例去跑後續的多步驟長流程。Queue 負責「扛住流量、解耦」,Workflow 負責「保住進度、跑完長流程」,各司其職。
三、關鍵術語
先記下貫穿全文的名詞,後面看程式碼就不會卡:
| 術語 | 說明 |
|---|---|
| Durable Execution(持久化執行) | 每完成一步就保存進度、崩潰後從斷點續跑的執行模型 |
| Workflow(工作流程) | 繼承 WorkflowEntrypoint 的 class,定義整段流程邏輯 |
| Instance(實例) | 一次 Workflow 執行,有唯一 id 與獨立的持久化狀態 |
WorkflowEntrypoint | 所有 Workflow class 要繼承的基底類別 |
run(event, step) | Workflow 的進入方法,event 帶入參、step 是建步驟的工具 |
| Step(步驟) | 用 step.do() 定義的最小可持久化、可重試執行單元 |
event.payload | create() 時傳入的 params,即這個實例的輸入資料 |
| binding(綁定) | 在 wrangler.jsonc 宣告後注入 env 的 MY_WORKFLOW 物件 |
實作範例
我們用一個新使用者上手(onboarding)流程走一遍最小可運作的 Workflow:建立使用者記錄 → 準備歡迎資料 → 寄送歡迎信。這是一個典型的多步驟業務流程,每一步都有副作用(寫 DB、寄信),正好凸顯「崩潰後不重跑」的價值。
提醒:本篇聚焦入門骨架,所以刻意不觸碰
step.sleep、step.waitForEvent與重試設定——這些留到下一篇。這裡只用最基本的step.do(名稱, 函式)兩參數形式。
1. wrangler.jsonc:宣告 workflows binding
Workflows 的綁定寫在頂層的 workflows 陣列裡。每個項目要指定三件事:name(Cloudflare 端的 workflow 名稱)、binding(注入 env 的變數名,慣例大寫)、class_name(對應你程式裡的 WorkflowEntrypoint class 名稱,必須完全一致)。
// wrangler.jsonc
{
"name": "onboarding-worker",
"main": "src/index.ts",
"compatibility_date": "2025-01-01",
"observability": { "enabled": true }, // ★ 生產環境務必開,才看得到每個實例的執行軌跡
"workflows": [
{
"name": "user-onboarding", // Cloudflare 端的 workflow 名稱
"binding": "USER_ONBOARDING", // ★ 注入 env 的變數名(下面程式用 env.USER_ONBOARDING)
"class_name": "UserOnboardingWorkflow" // ★ 必須與程式裡 export 的 class 名稱一模一樣
}
],
"d1_databases": [
{ "binding": "DB", "database_name": "app-db", "database_id": "your-db-id" }
]
}
三個欄位裡,binding 與 class_name 最容易搞錯:binding 決定你在程式裡怎麼呼叫它(env.USER_ONBOARDING),class_name 則是 Cloudflare 用來找到你那個 class 的鑰匙——名字對不上,部署時就會報找不到 class。
2. Env 型別:讓 binding 有型別可用
在 TypeScript 裡,env 上的 workflow binding 型別是 Workflow(由 cloudflare:workers 提供)。宣告好型別,create()、get() 這些方法才有自動補全。
// src/index.ts
import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from "cloudflare:workers";
// 這個 Workflow 的輸入參數(= create() 時傳的 params = event.payload)
interface OnboardingParams {
userId: string;
email: string;
planType: "free" | "pro";
}
interface Env {
USER_ONBOARDING: Workflow<OnboardingParams>; // ★ workflows binding 的型別是 Workflow
DB: D1Database;
}
3. Workflow 定義:WorkflowEntrypoint + run(event, step)
這是本篇的核心。一個 Workflow 就是一個繼承 WorkflowEntrypoint<Env, Params> 的 class,並實作 async run(event, step) 方法。event.payload 是你 create() 時傳進來的參數;step 是用來建立「可持久化步驟」的工具。把每個有副作用的動作包進 step.do(),它的回傳值就會被自動持久化。
// src/index.ts(承上)
export class UserOnboardingWorkflow extends WorkflowEntrypoint<Env, OnboardingParams> {
// run() 是 Workflow 的進入點。event 帶入參,step 用來建立持久化步驟。
async run(event: WorkflowEvent<OnboardingParams>, step: WorkflowStep): Promise<void> {
const { userId, email, planType } = event.payload; // ★ create() 傳入的 params 在這裡取得
// ── 步驟 1:建立使用者記錄 ──
// step.do 的回傳值會被「自動序列化並持久化」。崩潰重播時,這步不會重跑,
// 而是直接把下面回傳的 user 物件從快取讀回來。
const user = await step.do("create user record", async () => {
return await this.env.DB
.prepare(
"INSERT INTO users (id, email, plan, created_at) VALUES (?, ?, ?, ?) RETURNING id, email"
)
.bind(userId, email, planType, new Date().toISOString())
.first<{ id: string; email: string }>();
});
// ── 步驟 2:準備歡迎資料 ──
// 純計算也可以包成一步,好處是把「決定性的產出」固定下來、可被後續步驟安全依賴。
const welcome = await step.do("build welcome payload", async () => {
return {
to: user!.email,
subject: planType === "pro" ? "歡迎加入 Pro 方案!" : "歡迎加入!",
body: `嗨,你的帳號 ${user!.id} 已經開通。`,
};
});
// ── 步驟 3:寄送歡迎信 ──
// 這步有「對外副作用」(寄信),最需要持久化保護:一旦寄成功並保存,
// 就算下一行崩潰,重播時也絕不會把同一封信再寄一次。
await step.do("send welcome email", async () => {
await sendEmail(welcome.to, welcome.subject, welcome.body);
});
// run() 正常返回 → 實例狀態變為 complete
}
}
// 示意用的寄信函式(實務可接 Email Worker / 外部 API)
async function sendEmail(to: string, subject: string, body: string): Promise<void> {
await fetch("https://mail.example.com/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to, subject, body }),
});
}
三個必須內化的設計點:
- 每個
step.do('名稱', 函式)是一個持久化單元:第一參數是步驟名稱(崩潰重播時用來對應「這步做過沒」,所以必須是確定性的靜態字串,別用Date.now());第二參數是實際要跑的 async 函式,它的回傳值會被自動序列化保存。 - 狀態靠回傳值傳遞:步驟 2、3 用到的
user、welcome都是前面步驟的回傳值。這不只是寫法習慣——正因為這些值被持久化了,崩潰重播時它們才能被正確地讀回、讓後續步驟接續使用。 - 副作用一定要在步驟內:寄信這個動作寫在
step.do('send welcome email')裡,才享有「只執行一次」的保證。如果把sendEmail(...)直接寫在run()裡、沒包進 step,每次崩潰重播都會重寄一次。
4. 觸發實例:env.MY_WORKFLOW.create()
定義好 Workflow 後,要有東西去啟動實例。觸發的核心 API 是 env.USER_ONBOARDING.create({ id, params }),可以在任何進入點呼叫。下面示範最常見的 HTTP fetch 觸發,並附上查詢狀態的端點。
// src/index.ts(承上)——Worker 的預設進入點
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// 【觸發】POST /onboard → 啟動一個 onboarding 實例
if (request.method === "POST" && url.pathname === "/onboard") {
const body = await request.json<OnboardingParams>();
const instance = await env.USER_ONBOARDING.create({
// ★ id 是實例的唯一識別碼。用業務主鍵(如 userId)天然去重;不給則自動產生 UUID。
id: `onboard-${body.userId}`,
params: body, // ★ 這個物件會變成 run() 裡的 event.payload
});
// create() 立即回傳 handle,實例在背景非同步執行。回傳追蹤 id 給前端。
return Response.json(
{ status: "started", instanceId: instance.id },
{ status: 202 }
);
}
// 【查狀態】GET /status/:id → 用 id 取回實例並查目前狀態
if (request.method === "GET" && url.pathname.startsWith("/status/")) {
const id = url.pathname.replace("/status/", "");
const instance = await env.USER_ONBOARDING.get(id); // 靠 id 重新取回同一個實例
const status = await instance.status(); // queued/running/waiting/complete/errored...
return Response.json(status);
}
return new Response("Not Found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
create() 的兩個要點:
id是實例的身分證:它唯一標識這個實例。強烈建議用業務主鍵(訂單編號、使用者 ID)當id,這樣同一筆業務重複觸發時會因 id 衝突而天然去重,避免同一件事跑出兩個實例;若省略id,Cloudflare 會自動配一個 UUID。create()是非阻塞的:它啟動實例後立即回傳一個 handle,實例本身在 Cloudflare 網路上背景執行。所以你的 HTTP 端點可以馬上回202 Accepted加一個追蹤id,前端再拿這個 id 去輪詢/status/——這就是標準的「非同步任務 + 進度查詢」體驗。
觸發不限於 HTTP。同一個 create() 也能寫在 Queue consumer(Queue → Workflow 削峰後啟動長流程)或 Cron scheduled handler(定時觸發 ETL)裡:
// 其他觸發來源示意 —— 同樣是呼叫 create()
// (A) Queue consumer:訊息進來後啟動一個 Workflow 實例
async function fromQueue(batch: MessageBatch<OnboardingParams>, env: Env) {
for (const msg of batch.messages) {
await env.USER_ONBOARDING.create({ id: `onboard-${msg.body.userId}`, params: msg.body });
msg.ack();
}
}
// (B) Cron:每日定時觸發(適合 ETL、報表等排程長流程)
async function fromCron(event: ScheduledEvent, env: Env) {
await env.USER_ONBOARDING.create({
id: `daily-${event.scheduledTime}`,
params: { userId: "batch", email: "ops@example.com", planType: "free" },
});
}
5. 適用場景:什麼任務天生適合 Workflows
有了骨架,回頭看「什麼時候該用 Workflows」就很清楚了。凡是符合「多步驟、有順序、要保住中間進度、崩潰不能從頭重來」的任務,都是它的主場:
- 多步驟業務流程:訂單處理(驗證 → 扣款 → 出貨 → 通知)。每步都有副作用,絕不能因崩潰而重複扣款——這正是持久化執行的招牌場景。
- ETL / 資料管線:擷取(Extract)→ 轉換(Transform)→ 載入(Load)。每一階段耗時且可能失敗,持久化讓失敗只需重跑該階段,而非整條管線從頭。
- AI pipeline:AI 生成 → (下一篇會講的)人工審核 → 發布。多步驟、可能要等待,且每步的 AI 輸出寶貴、不該因中斷而重算重花費用。
這三類的共通點是:流程有明確的步驟邊界、中間狀態有保存價值、且從頭重跑代價高昂。反過來,若你的任務是「一則則獨立、無順序、消化完就丟」的高吞吐訊息,那是 Queues 的主場,別硬套 Workflows。
常見錯誤與最佳實踐
Workflows 的心智模型和一般函式不同,新手最容易在「重播(replay)」這件事上摔跤。以下四個坑幾乎人人踩過。
坑一:把副作用寫在 step.do() 外面。
這是最致命的坑。只有包在 step.do() 裡的動作才會被持久化、才只執行一次;寫在步驟外的程式碼會在每次崩潰重播時重新執行。
// ❌ 錯誤:寄信寫在步驟外 —— 每次重播都會重寄一次
async run(event: WorkflowEvent<OnboardingParams>, step: WorkflowStep) {
await sendEmail(event.payload.email, "歡迎", "..."); // 崩潰重播 → 重複寄信!
await step.do("save record", async () => { /* ... */ });
}
// ✅ 正確:所有副作用都包進 step.do,享有「只執行一次」保證
async run(event: WorkflowEvent<OnboardingParams>, step: WorkflowStep) {
await step.do("save record", async () => { /* ... */ });
await step.do("send welcome email", async () => {
await sendEmail(event.payload.email, "歡迎", "...");
});
}
坑二:在步驟外寫非決定性程式碼。
因為 run() 會被整段重播,任何寫在步驟外的非決定性邏輯(Date.now()、Math.random()、隨機分支)在重播時可能算出不同的值,導致程式走上和第一次不同的路徑,狀態就對不上了。需要「當下時間」「隨機值」這類非決定性結果時,把它包進步驟——這樣它只算一次、結果被固定持久化下來。
// ❌ 錯誤:步驟外用 Date.now(),重播時值會變,後續判斷可能走岔
const now = Date.now();
if (now % 2 === 0) { /* 重播時 now 不同,分支可能翻轉 */ }
// ✅ 正確:把非決定性結果包進步驟,固定成持久化的值
const now = await step.do("capture timestamp", async () => Date.now());
// 之後不管重播幾次,now 都是第一次執行時保存的那個值
坑三:以為 Workflow 像一般函式一樣「跑一次到底」。
要時時記住:run() 可能被執行很多次(每次崩潰恢復都會重播一遍),但已完成的步驟會被跳過。所以別在步驟之間放「假設只執行一次」的邏輯(例如就地累加一個計數器、就地開一個外部交易)。把每一個有意義的動作都劃進步驟邊界,讓「哪些做過、哪些沒做」完全由 Workflows 依步驟名稱來裁決,你的程式才會在重播下保持正確。
坑四:步驟名稱用了非確定性字串。
Workflows 靠步驟名稱來判斷「這步在上次執行中是否已完成」。如果名稱本身是動態的(如 `step-${Date.now()}`),重播時名稱對不上,已完成的步驟就會被誤判成沒做過而重跑。步驟名稱要嘛是靜態字串,要嘛基於確定性的值(例如迴圈中用穩定的檔案 key):
// ❌ 錯誤:名稱含非確定性值,重播對不上
await step.do(`process ${Date.now()}`, async () => { /* ... */ });
// ✅ 正確:靜態名稱,或基於確定性資料的名稱
await step.do("process report", async () => { /* ... */ });
for (const key of fileKeys) { // fileKeys 來自前一步的持久化回傳值
await step.do(`process file ${key}`, async () => { /* ... */ }); // 確定性
}
最佳實踐小結:記牢這幾條——所有副作用一律包進 step.do(),享有只執行一次保證;非決定性程式碼(時間、隨機)也包進步驟,固定成持久化值;步驟名稱用確定性字串,別讓重播對不上;心裡永遠假設 run() 會被重播多次,把每個有意義的動作都劃進步驟邊界;用業務主鍵當實例 id 做天然去重;打開 observability,才看得到每個實例的步驟軌跡與失敗點。守住這幾條,你寫的 Workflow 才能真正享受到「崩潰也不重來」的紅利。
小結
上一篇《Queues 批次、重試與 DLQ》,我們把訊息佇列的容錯練到生產級,也點出了 Queues 的天生邊界:訊息即狀態,無法跨步驟保存進度、不能睡眠等待。這一篇,我們踏進 Cloudflare Workflows 的世界,補上這塊拼圖:
- durable execution(持久化執行)——每完成一步就保存進度、崩潰後從斷點續跑,而非從頭重來。它把「斷點續傳」從你要手刻的苦差事,變成平台內建能力。
WorkflowEntrypoint+run(event, step)——一個 Workflow 就是繼承WorkflowEntrypoint<Env, Params>的 class,在run()裡把每個有副作用的動作包成step.do(),回傳值自動持久化。env.MY_WORKFLOW.create()——在 wrangler.jsonc 的workflows陣列宣告 binding,再從 fetch / queue / cron 呼叫create({ id, params })觸發實例,用業務主鍵當id天然去重,並可用get(id).status()查進度。- Workflows vs Queues——單一長流程 vs 大量獨立訊息。前者有步驟、有順序、保狀態、可續跑;後者高吞吐、fire-and-forget。兩者互補,常以 Queue → Workflow 搭配。
- 適用場景——多步驟業務流程、ETL、AI pipeline,共通點是「有步驟邊界、中間狀態有保存價值、從頭重跑代價高」。
至此,你已經能寫出並觸發第一個 Workflow,也理解了它「崩潰也不重來」的底層魔法。但我們刻意還沒碰 step 的深水區——步驟該怎麼設定重試?怎麼讓流程睡上七天再繼續?怎麼暫停等待一個外部的人工審核事件? 這些讓 Workflows 真正強大的能力,下一篇《Workflows:steps、重試與 sleep》,我們會一一拆開。
想先查閱官方對 Workflows 與 durable execution 的完整說明,可以隨時參考 Cloudflare Workflows 官方文件。持久化執行的第一道門就此打開,我們下一篇《Workflows:steps、重試與 sleep》見。