非同步編排選型:Queues vs Workflows vs DO | Cloudflare 完整教學

2026/09/02
非同步編排選型:Queues vs Workflows vs DO | Cloudflare 完整教學

走完 Workflows 的深水區後,一個更上層的問題浮上檯面:面對一個非同步需求,我到底該選 QueuesWorkflows,還是 Durable Objects?這三者不是互相取代的競品,而是各有專精的原語——Queues 擅長大量獨立訊息與簡單重試,Workflows 擅長單一長時多步的持久化流程,Durable Objects 搭配 Alarms 擅長有狀態協調與自我排程。這一篇,我們用一張對照表、一棵決策樹、幾段可執行骨架,把「該用哪個、又怎麼串成一條管線」徹底講清楚,替 CF-4 這條非同步主線收尾。

前言

前面幾篇,我們把 Cloudflare 的非同步工具箱逐一拆開過了:《Queues 生產者與消費者》教我們怎麼把訊息送進佇列、怎麼 ack/retry、怎麼設 DLQ;《Workflows 持久化執行入門》與《Workflows:steps、重試與 sleep》教我們怎麼把一件長事情切成一個個持久化步驟、怎麼重試、怎麼睡上七天;更早的《Durable Objects 與 Alarms》教我們怎麼用有狀態的單實例做強一致協調、怎麼讓每個物件擁有自己的計時器。工具都學過了,但真正上戰場時,最先卡住人的往往不是「怎麼用」,而是「該用哪個」。

這一篇的定位就是「選型」——不教新 API,而是把三個你已經認識的原語放在同一張桌上比較,幫你在架構設計的第一步就選對工具。所謂非同步編排(asynchronous orchestration),指的是「把工作從當下的請求-回應週期中解耦出去、交給背景以某種順序與保證去完成」。Cloudflare 提供了三種性質截然不同的編排原語,選錯了,輕則多寫很多膠水程式碼,重則撞上吞吐瓶頸或一致性錯誤。

打個比方。這三個原語就像廚房裡的三種設備:Queues出餐傳送帶——大量餐點各自獨立、源源不絕地送出去,某一盤掉了就重送那一盤,不影響其他;Workflows一張多道工序的料理食譜卡——同一道菜要「醃 → 靜置一夜 → 煎 → 擺盤」,每一步做完打個勾,中途斷電了也能從打勾處接著做、不用重醃;Durable Objects 則是一位盯著單一鍋子的專責主廚——這鍋湯全球只有他一個人能碰,他記得鍋裡現在幾分熟、還設了鬧鐘提醒自己何時該關火。傳送帶、食譜卡、專責主廚,三者服務的是完全不同的需求。

讀完你會掌握:

  • 三者的完整對照維度——用途、狀態、時長、重試、觸發、成本六個面向的一表看懂
  • 一棵可以照著走的決策樹——從「有沒有狀態」「是不是長流程」「要不要協調」三個關鍵岔路推出答案
  • 各自的最適場景與骨架——同一個「訂單處理」需求,分別用 Queues / Workflows / DO 寫出可執行骨架
  • 三種真正合理的組合模式——Queue 觸發 Workflow、Workflow 扇出到 Queue、Workflow 中呼叫 DO
  • 最常見的反模式——把 DO 當佇列、用錯工具、過度工程,以及怎麼避開

核心概念

選型的關鍵,是先把三者的「本質差異」看清楚,再讓需求去對應。我們先攤開一張六維度對照表,再走一棵決策樹,最後統一術語。

一、六維度完整對照表

面向QueuesWorkflowsDurable Objects(+ Alarms)
核心用途大量獨立訊息的緩衝、解耦、扇出單一長時、多步驟的持久化流程有狀態的強一致協調 + 每實體自我排程
狀態無狀態(訊息本身即狀態)有(每步驟回傳值自動持久化)有(自帶 SQLite,計算與儲存共置)
執行時長單次 consumer CPU 最多 5 分鐘步驟內 CPU 最多 5 分鐘;整體可達數月單請求 CPU 預設 30 秒;Alarm handler 最多 15 分鐘
重試模型整批或單則自動重試,超限進 DLQ逐步驟重試,已成功步驟不重跑Alarm 至少執行一次,失敗指數退避重試
觸發方式程式化 send / sendBatch程式化 create / createBatchWorker 呼叫(RPC / fetch),或 setAlarm 自我喚醒
等待能力不支援(只有 delaySeconds 延遲投遞)sleep 數天、waitForEvent 等外部事件可用 Alarm 自排未來喚醒,但無內建多步等待語意
併發模型高扇出,可平行消費每實例獨立、多實例可並行單實例、單執行緒,天然序列化
成本特性按訊息操作計費,睡眠不適用sleep/waiting 期間不計費、不佔並行Hibernation / 閒置不計費,按請求與活躍時長計費
典型延遲秒級毫秒級(立即啟動)毫秒級(就近邊緣)

一句話濃縮這張表:Queues 管「多」,Workflows 管「長」,Durable Objects 管「一致」。 當你的痛點是「訊息太多要削峰、要解耦、要扇出」,想 Queues;當痛點是「一件事很長、很多步、要能中斷續跑」,想 Workflows;當痛點是「同一個東西被很多請求同時碰、必須不出亂子」,想 Durable Objects。

二、選型決策樹

把上面的直覺變成可以照著走的流程。面對任何一個非同步需求,依序問下面幾個問題:

需求進來
  │
  ├─ Q1. 這件事需要「跨請求記住狀態」且「同一實體必須序列化處理、強一致」嗎?
  │        (例如:同一個庫存不能超賣、同一個聊天室廣播、同一把 key 精確限流)
  │        │
  │        ├─ 是 ──► Durable Objects
  │        │          └─ 還需要「未來某時刻自己醒來做事」? ──► 加上 Alarms
  │        │
  │        └─ 否 ──▼
  │
  ├─ Q2. 這是「一件事、分很多步」,而且需要
  │        「睡很久 / 等外部事件 / 逐步驟重試而不重跑已完成步驟」嗎?
  │        │
  │        ├─ 是 ──► Workflows
  │        │
  │        └─ 否 ──▼
  │
  ├─ Q3. 這是「很多筆彼此獨立的任務」,只需要
  │        「緩衝削峰 / 解耦生產與消費 / 簡單重試 / 扇出」嗎?
  │        │
  │        ├─ 是 ──► Queues
  │        │
  │        └─ 否 ──▼
  │
  └─ Q4. 都沾一點? ──► 用「組合」:通常 Queue 當入口、
           觸發 Workflow 跑流程、流程中呼叫 DO 做原子操作

三個要注意的細節:

  1. Q1 放最前面是有意的。 「有狀態 + 強一致協調」是最容易被誤用其他工具硬湊、卻也最該用 DO 的訊號。一旦命中,別繞路。
  2. 「排程」不是單獨的一個岔路。 定時觸發可以是 Cron Trigger(全域排程)、也可以是 DO Alarm(每實體排程)、還可以是 Workflow 的 sleepUntil(流程內的絕對時間點)。它是「掛在某個原語上的能力」,而非獨立選項。要「每個實體有自己的計時器」用 DO Alarm;要「流程走到這裡停到某時刻」用 Workflow sleep;要「全站每天凌晨跑一次」用 Cron。
  3. Q4 才是真實世界的常態。 真正的系統很少只用一個原語,下面的組合模式會把這點講透。

三、關鍵術語

先統一這一篇會反覆用到的名詞:

術語說明
非同步編排(orchestration)把工作從請求-回應週期解耦、交給背景依某種順序與保證完成
扇出(fan-out)一個來源產生大量下游任務並分派出去(Queues 的強項)
削峰 / 緩衝(buffering)用佇列吸收突發流量,讓下游以穩定速率消費
持久化執行(durable execution)Workflows 靠重播與步驟快取,讓長流程崩潰後從中斷處續跑
強一致協調(strong-consistency coordination)同一實體的操作被序列化,消除併發衝突(DO 的單實例保證)
DLQ(Dead Letter Queue)超過 max_retries 的訊息轉入的死信佇列,防止靜默丟失
AlarmDO 每實例獨立的計時器,setAlarm 排程、alarm() 觸發

實作範例

理論講完,我們用同一個需求——電商訂單處理,分別用三種原語各寫一段可執行骨架,你就能直觀感受到「同一件事,選不同工具會長成完全不同的樣子」。最後再示範把它們串起來的組合模式。

以下骨架都以 TypeScript 撰寫,匯入來自 cloudflare:workers。重點在「結構」,細節 API 前幾篇都講過。

1. 用 Queues:當訂單是「大量獨立訊息」

如果你的訂單處理是「每筆訂單各自獨立、彼此無關,只要背景把它處理掉、失敗能重試」,那 Queues 是最輕的解。生產者把訂單塞進佇列先回應使用者,消費者批次處理:

interface Env {
  ORDER_QUEUE: Queue<OrderMessage>;
}
interface OrderMessage {
  orderId: string;
  amount: number;
}

// 生產者:HTTP 進來,先入列再立刻回應(削峰、解耦)
export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const order = await req.json<OrderMessage>();
    // 非阻塞入列,使用者不必等背景處理完
    ctx.waitUntil(env.ORDER_QUEUE.send(order));
    return Response.json({ status: "accepted", orderId: order.orderId });
  },

  // 消費者:Cloudflare 主動推送批次進來
  async queue(batch: MessageBatch<OrderMessage>, env: Env): Promise<void> {
    for (const msg of batch.messages) {
      try {
        await processOrder(msg.body); // 你的處理邏輯
        msg.ack();                    // 成功:移出佇列
      } catch (err) {
        // 失敗:單則指數退避重試(超過 max_retries 後進 DLQ)
        msg.retry({ delaySeconds: 30 });
      }
    }
  },
} satisfies ExportedHandler<Env>;

適用訊號:訂單彼此獨立、處理是「一次搞定」而非多步長流程、要吸收突發下單潮、失敗重試邏輯簡單。注意每則訊息要個別 try/catchack/retry,否則一則出錯會拖累整批重試;生產環境務必設 DLQ,否則超限訊息會被靜默丟棄。

2. 用 Workflows:當訂單是「一件長事、很多步」

如果訂單處理是「扣款 → 等物流建單 → 通知使用者 → 隔幾天追蹤評價」這種多步、要等、要逐步驟重試而不重跑的長流程,那就該用 Workflows。同一筆訂單是「一個實例」,每步獨立持久化:

import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";

interface OrderParams {
  orderId: string;
  userId: string;
  amount: number;
}

export class OrderWorkflow extends WorkflowEntrypoint<Env, OrderParams> {
  async run(event: WorkflowEvent<OrderParams>, step: WorkflowStep): Promise<void> {
    const { orderId, amount } = event.payload;

    // 步驟 1:扣款(暫時性錯誤重試,業務錯誤立即放棄)
    const charge = await step.do(
      "扣款",
      { retries: { limit: 3, delay: "5 seconds", backoff: "exponential" } },
      async () => {
        const res = await fetch("https://pay.example.com/charge", {
          method: "POST", body: JSON.stringify({ orderId, amount }),
        });
        if (res.status === 402) throw new NonRetryableError("餘額不足");
        if (!res.ok) throw new Error(`扣款失敗 ${res.status}`);
        return res.json<{ txnId: string }>();
      }
    );

    // 步驟 2:等物流系統回報建單完成(最多等 24 小時,期間不計費)
    await step.waitForEvent("等待物流建單", {
      type: "shipment-created", timeout: "24 hours",
    });

    // 步驟 3:通知使用者
    await step.do("通知使用者", async () => {
      await sendEmail(event.payload.userId, `訂單 ${orderId} 已出貨`);
    });

    // 步驟 4:睡 3 天後追蹤評價(睡眠不計費、不佔並行額度)
    await step.sleep("等 3 天", "3 days");
    await step.do("邀評", async () => {
      await sendReviewInvite(event.payload.userId, orderId);
    });
  }
}

適用訊號:單筆訂單本身就是一條需要跨越數天、包含等待與多次外部呼叫的流程;要「防重複扣款」(步驟冪等);某一步失敗只想重跑那一步。這是 Queues 做不到的——Queues 沒有跨步驟狀態、沒有 sleep、沒有 waitForEvent

3. 用 Durable Objects:當訂單牽涉「強一致的共享狀態」

如果你的痛點不是「處理一筆訂單」,而是「這個熱門商品全球同時湧入下單,庫存絕對不能超賣」,那核心需求是強一致協調,該用 DO。每個商品一個 DO,單實例序列化保證不超賣,再用 Alarm 做超時釋放:

import { DurableObject } from "cloudflare:workers";

// 每個商品一個 DO 實例(env.INVENTORY.getByName(`sku-${skuId}`))
export class Inventory extends DurableObject<Env> {
  // RPC:原子扣庫存,單執行緒序列化,天然防超賣
  async reserve(orderId: string, qty: number): Promise<boolean> {
    const row = this.ctx.storage.sql
      .exec<{ stock: number }>("SELECT stock FROM inv LIMIT 1").one();
    if (row.stock < qty) return false; // 庫存不足,拒絕

    this.ctx.storage.transactionSync(() => {
      this.ctx.storage.sql.exec("UPDATE inv SET stock = stock - ?", qty);
      this.ctx.storage.sql.exec(
        "INSERT INTO holds (orderId, qty, expireAt) VALUES (?, ?, ?)",
        orderId, qty, Date.now() + 15 * 60 * 1000
      );
    });
    // 設 Alarm:15 分鐘後若未付款,自動釋放這筆保留
    await this.ctx.storage.setAlarm(Date.now() + 15 * 60 * 1000);
    return true;
  }

  // Alarm handler:自我排程觸發,回補逾時未付款的庫存
  async alarm(): Promise<void> {
    const now = Date.now();
    const expired = this.ctx.storage.sql
      .exec<{ orderId: string; qty: number }>(
        "SELECT orderId, qty FROM holds WHERE expireAt <= ?", now
      ).toArray();
    for (const h of expired) {
      this.ctx.storage.sql.exec("UPDATE inv SET stock = stock + ?", h.qty);
      this.ctx.storage.sql.exec("DELETE FROM holds WHERE orderId = ?", h.orderId);
    }
  }
}

適用訊號:多個請求同時操作同一份共享狀態、必須強一致(防超賣、精確計數、限流);需要每個實體有自己的計時器做超時釋放或到期清理。這是 Queues 與 Workflows 都不擅長的——它們沒有「同一實體全球單一副本、序列化處理」的保證。

4. 組合模式:把三者串成一條真實管線

真實系統往往三者都要。最經典的一條電商下單管線,剛好把三種組合都用上:

export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const order = await req.json<OrderMessage>();
    // 【組合 1:Queue 當入口】高吞吐削峰,先入列再回應
    ctx.waitUntil(env.ORDER_QUEUE.send(order));
    return Response.json({ status: "accepted" });
  },

  async queue(batch: MessageBatch<OrderMessage>, env: Env): Promise<void> {
    for (const msg of batch.messages) {
      // 【組合 2:Queue → Workflow】每筆訂單啟動一個長流程實例
      await env.ORDER_WORKFLOW.create({ id: msg.body.orderId, params: msg.body });
      msg.ack();
    }
  },
} satisfies ExportedHandler<Env>;

// Workflow 內部:
export class OrderWorkflow extends WorkflowEntrypoint<Env, OrderParams> {
  async run(event: WorkflowEvent<OrderParams>, step: WorkflowStep): Promise<void> {
    // 【組合 3:Workflow → DO】在步驟裡呼叫 DO 做強一致扣庫存
    const reserved = await step.do("扣庫存", async () => {
      const stub = this.env.INVENTORY.getByName(`sku-${event.payload.skuId}`);
      return await stub.reserve(event.payload.orderId, event.payload.qty);
    });
    if (!reserved) throw new NonRetryableError("庫存不足");

    // ...扣款、等物流、通知...(略)

    // 【組合 4:Workflow → Queue】最後扇出大量通知,交回 Queues
    await step.do("扇出通知", async () => {
      await this.env.NOTIFY_QUEUE.sendBatch(
        buildNotifications(event.payload) // 可能上萬筆,交給高吞吐的 Queues
      );
    });
  }
}

看懂這條管線,你就抓到組合的精髓:Queue 補「吞吐」、Workflow 補「持久化長流程」、DO 補「強一致」。每一層都是為了補上前一層缺的那塊能力,而不是慣性堆疊。

常見錯誤與最佳實踐

選型出錯往往不是不會用 API,而是把工具用在它不擅長的地方。以下是最高頻的幾個坑。

坑一:把 Durable Objects 當佇列用。

看到 DO 能收訊息、能 Alarm 排程、能重試,就想「那我把所有背景任務都丟給一個 DO 排隊處理」。大錯。DO 是單實例、單執行緒的,同一個 DO 一次只處理一個事件——這是它強一致的來源,卻讓它天生不適合高扇出的獨立工作。把上萬筆獨立任務灌進同一個 DO,它會變成序列化瓶頸,撞上單實例 QPS 上限。高扇出、彼此獨立的任務,一律用 Queues。

// ❌ 反模式:把大量獨立任務塞進單一 DO 排隊(序列化瓶頸)
const q = env.TASK_QUEUE_DO.getByName("global-queue");
for (const task of tenThousandTasks) await q.enqueue(task); // 全部卡在一個實例

// ✅ 正確:用 Queues,可平行消費、自帶 DLQ、為高吞吐而生
await env.TASK_QUEUE.sendBatch(tenThousandTasks.map((body) => ({ body })));

坑二:用 Queues / Cron 手刻長流程的狀態機。

「試用 7 天後提醒」硬用 Cron 每天輪詢一次全表、比對日期;或用 Queues 的 delaySeconds 硬湊多步等待,自己維護「現在走到第幾步」的狀態。這等於在重造 Workflows 的持久化執行輪子——又費工又易錯。只要是「一件事、多步、要等、要逐步驟重試」,就用 Workflows,step.sleep 一行解決「等 7 天」,不必輪詢、不必自建計時基礎設施。

坑三:純無狀態工作硬套 Durable Objects,徒增延遲與複雜度。

DO 的每次呼叫都要路由到那個唯一實例所在的位置,對「無狀態、可就地處理」的請求是白白多一跳。純無狀態轉換直接用 Workers;需要全球分散讀取用 KV/R2;高扇出獨立請求用 Queues。DO 只在「需要強一致的共享狀態協調」時才划算。

坑四:過度工程——一個原語能解決卻硬串三層。

看完組合模式很容易走向另一個極端:什麼都 Queue → Workflow → DO 串一遍。判準很簡單:每多加一個原語,都必須是為了補上前一個缺的那塊能力(吞吐、持久化、強一致)。 如果你的需求「一筆獨立任務、一次處理完、失敗重試」——那就只是 Queues,別包成 Workflow;如果「單一長流程、沒有共享狀態競爭」——那就只是 Workflows,別硬塞 DO。

坑五:把「排程」當成獨立選型,選錯排程原語。

三種排程能力服務不同粒度:Cron Trigger 是全站級的「每天凌晨跑一次」;DO Alarm 是每實體級的「這個商品的保留 15 分鐘後釋放」;Workflow sleep/sleepUntil 是流程內的「這條流程走到這裡停到某時刻」。要「每個實體有自己的計時器」卻用 Cron 輪詢全表,就是選錯了層級。

最佳實踐小結:先問「有沒有強一致的共享狀態」(有 → DO),再問「是不是長多步流程」(是 → Workflows),再問「是不是大量獨立訊息」(是 → Queues);排程能力掛在對的原語上(全站 Cron、每實體 DO Alarm、流程內 Workflow sleep);組合只在「單一原語補不齊」時才用,每一層都要有明確的能力理由;Queues 記得個別 ack 與設 DLQ、Workflows 記得步驟冪等、DO 記得按實體分片別做成單一全域瓶頸。

小結

上一篇《Workflows:steps、重試與 sleep》,我們把單一 Workflow 內部的 step.do 重試、step.sleep 睡眠、step.waitForEvent 等待練成生產級。這一篇,我們拉高到全局視角,把 Cloudflare 三大非同步編排原語放在同一張桌上做選型:

  • Queues 管「多」——大量獨立訊息的緩衝、削峰、解耦、扇出;無狀態、高扇出、簡單重試、自帶 DLQ。訊號:很多筆彼此無關的任務。
  • Workflows 管「長」——單一長時、多步驟的持久化流程;逐步驟重試不重跑、可睡數天、可等外部事件。訊號:一件事分很多步、要等、要能中斷續跑。
  • Durable Objects(+ Alarms)管「一致」——有狀態的強一致協調 + 每實體自我排程;單實例序列化消除併發衝突。訊號:同一實體被同時操作、必須不出亂子。
  • 決策樹——先問「要不要強一致共享狀態」(DO)、再問「是不是長多步流程」(Workflows)、再問「是不是大量獨立訊息」(Queues),交錯就用組合。
  • 組合模式——Queue 當入口削峰、觸發 Workflow 跑長流程、流程中呼叫 DO 做原子操作、最後扇出交回 Queue;每一層補一塊能力。

至此,CF-4 這條非同步編排主線(Queues、Workflows、Durable Objects Alarms 的選型與組合)正式收尾。你已經能在架構設計的第一步就選對工具、也知道怎麼把它們串成一條真實管線。接下來,我們要離開「編排」這一層,踏進更上層的 AI 能力——下一篇《Workers AI 入門》,我們會看 Cloudflare 如何讓你在邊緣直接跑推論模型,把前面學到的編排能力,接上真正的智慧。

想深入查閱官方對三者的完整能力與限制,可以隨時參考 Cloudflare Workflows 官方文件。把「選型」這一步想清楚了,你後面的每一個架構決策都會走得更穩,我們下一篇《Workers AI 入門》見。

BenZ Software Developer

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

本週主打

AI 自動化入門包

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

看看這個產品 →