非同步編排選型:Queues vs Workflows vs DO | Cloudflare 完整教學
走完 Workflows 的深水區後,一個更上層的問題浮上檯面:面對一個非同步需求,我到底該選 Queues、Workflows,還是 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 當佇列、用錯工具、過度工程,以及怎麼避開
核心概念
選型的關鍵,是先把三者的「本質差異」看清楚,再讓需求去對應。我們先攤開一張六維度對照表,再走一棵決策樹,最後統一術語。
一、六維度完整對照表
| 面向 | Queues | Workflows | Durable Objects(+ Alarms) |
|---|---|---|---|
| 核心用途 | 大量獨立訊息的緩衝、解耦、扇出 | 單一長時、多步驟的持久化流程 | 有狀態的強一致協調 + 每實體自我排程 |
| 狀態 | 無狀態(訊息本身即狀態) | 有(每步驟回傳值自動持久化) | 有(自帶 SQLite,計算與儲存共置) |
| 執行時長 | 單次 consumer CPU 最多 5 分鐘 | 步驟內 CPU 最多 5 分鐘;整體可達數月 | 單請求 CPU 預設 30 秒;Alarm handler 最多 15 分鐘 |
| 重試模型 | 整批或單則自動重試,超限進 DLQ | 逐步驟重試,已成功步驟不重跑 | Alarm 至少執行一次,失敗指數退避重試 |
| 觸發方式 | 程式化 send / sendBatch | 程式化 create / createBatch | Worker 呼叫(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 做原子操作
三個要注意的細節:
- Q1 放最前面是有意的。 「有狀態 + 強一致協調」是最容易被誤用其他工具硬湊、卻也最該用 DO 的訊號。一旦命中,別繞路。
- 「排程」不是單獨的一個岔路。 定時觸發可以是 Cron Trigger(全域排程)、也可以是 DO Alarm(每實體排程)、還可以是 Workflow 的
sleepUntil(流程內的絕對時間點)。它是「掛在某個原語上的能力」,而非獨立選項。要「每個實體有自己的計時器」用 DO Alarm;要「流程走到這裡停到某時刻」用 Workflow sleep;要「全站每天凌晨跑一次」用 Cron。 - Q4 才是真實世界的常態。 真正的系統很少只用一個原語,下面的組合模式會把這點講透。
三、關鍵術語
先統一這一篇會反覆用到的名詞:
| 術語 | 說明 |
|---|---|
| 非同步編排(orchestration) | 把工作從請求-回應週期解耦、交給背景依某種順序與保證完成 |
| 扇出(fan-out) | 一個來源產生大量下游任務並分派出去(Queues 的強項) |
| 削峰 / 緩衝(buffering) | 用佇列吸收突發流量,讓下游以穩定速率消費 |
| 持久化執行(durable execution) | Workflows 靠重播與步驟快取,讓長流程崩潰後從中斷處續跑 |
| 強一致協調(strong-consistency coordination) | 同一實體的操作被序列化,消除併發衝突(DO 的單實例保證) |
| DLQ(Dead Letter Queue) | 超過 max_retries 的訊息轉入的死信佇列,防止靜默丟失 |
| Alarm | DO 每實例獨立的計時器,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/catch 再 ack/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 入門》見。