Workflows 持久化執行入門:崩潰也不重來 | Cloudflare 完整教學

2026/08/31
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.payloadstep 各是什麼
  • 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.payloadcreate() 時傳入的 params,即這個實例的輸入資料
binding(綁定)在 wrangler.jsonc 宣告後注入 envMY_WORKFLOW 物件

實作範例

我們用一個新使用者上手(onboarding)流程走一遍最小可運作的 Workflow:建立使用者記錄 → 準備歡迎資料 → 寄送歡迎信。這是一個典型的多步驟業務流程,每一步都有副作用(寫 DB、寄信),正好凸顯「崩潰後不重跑」的價值。

提醒:本篇聚焦入門骨架,所以刻意觸碰 step.sleepstep.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" }
  ]
}

三個欄位裡,bindingclass_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 用到的 userwelcome 都是前面步驟的回傳值。這不只是寫法習慣——正因為這些值被持久化了,崩潰重播時它們才能被正確地讀回、讓後續步驟接續使用。
  • 副作用一定要在步驟內:寄信這個動作寫在 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》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →