Queues:把工作推離熱路徑
這篇要解決的問題
Section titled “這篇要解決的問題”第 3 篇的結論是「waitUntil 是 best-effort,需要保證送達就用 Queues」。這一篇處理 Queues。
它的定位很單純:把非關鍵路徑的工作從請求裡搬出去,並且保證它最終會被處理。
三件必須先講清楚的事:
- 計費單位是「每 64 KB 的讀、寫或刪」,所以一則典型訊息大約算三次操作,而每次重試再加一次讀。這個模型和其他產品都不一樣。
attempts從 1 開始,不是 0。 而且進到 DLQ 之後會重置回 1 —— 我實測過。- Queues 自 2026-02-04 起在 Free 方案可用。 2025 年以前的教學會說它需要付費方案,那已經不對了。
{ "queues": { "producers": [ { "binding": "CLICKS", "queue": "ch19-clicks" } ], "consumers": [ { "queue": "ch19-clicks", "max_batch_size": 5, // default 10, max 100 "max_batch_timeout": 2, // default 5 seconds, max 60 "max_retries": 2, // default 3, max 100 "dead_letter_queue": "ch19-clicks-dlq", "retry_delay": 1 // seconds } ] }}Producer 端的欄位:binding、queue、delivery_delay、remote。
Consumer 端的欄位:queue、type、max_batch_size、max_batch_timeout、max_retries、dead_letter_queue、max_concurrency、visibility_timeout_ms、retry_delay。
⚠️ 官方的
configure-queues文件頁漏掉了delivery_delay和retry_delay,而且它的範例用了max_batch_timeout: 30和max_retries: 10(都不是預設值),容易讓人誤以為那是預設。另外有些資料說
visibility_timeout只能在 CLI 設定 —— 但 wrangler 的 JSON schema 裡 consumer 確實有visibility_timeout_ms。以 schema 為準(node_modules/wrangler/config-schema.json)。
Producer API
Section titled “Producer API”await env.CLICKS.send(body, { contentType, delaySeconds });await env.CLICKS.sendBatch(messages, { delaySeconds });const m = await env.CLICKS.metrics();實測 producer 的介面:
$ curl -s localhost:8787/metrics{ "metrics": { "backlogCount": 0, "backlogBytes": 0 }, "producerProto": ["metrics", "send", "sendBatch", "constructor"]}metrics() 是 2026-04-28 才加的。 官方文件說它回傳 backlogCount、backlogBytes 和 oldestMessageTimestamp —— 我在本機只拿到前兩個(佇列是空的,oldestMessageTimestamp 可能因此不存在)。production 上要自己確認第三個欄位。
它的用途是背壓判斷:
const { backlogCount } = await env.CLICKS.metrics();if (backlogCount > 100_000) { // Shed load, alert, or switch to a degraded path.}contentType 預設是 "json"
Section titled “contentType 預設是 "json"”$ curl -s localhost:8787/contenttypes{ "json": "accepted", "text": "accepted", "bytes": "accepted", "v8": "accepted", "invalid": "THREW: TypeError: Unsupported queue message content type: nope"}四種都可用。預設從 "v8" 改成了 "json",原因是 pull consumer「cannot decode the v8 content type as it is specific to the Workers runtime」。
所以:如果你打算用 pull consumer(HTTP 拉取),不要用 v8。
delaySeconds 的邊界
Section titled “delaySeconds 的邊界”$ curl -s localhost:8787/delay{ "delaySeconds=0": "accepted", "delaySeconds=1": "accepted", "delaySeconds=86400": "accepted", "delaySeconds=86401": "THREW: Error: Unknown Internal Error (15000)", "delaySeconds=-1": "THREW: Error: Unknown Internal Error (15000)"}範圍是 0 到 86400(24 小時)。超出範圍的錯誤訊息(Unknown Internal Error (15000))完全沒有幫助 —— 自己在送出前驗證。
delaySeconds: 0 會明確覆蓋佇列層級的 delivery_delay。
Consumer API 與批次
Section titled “Consumer API 與批次”async queue(batch: MessageBatch<ClickMsg>, env, ctx): Promise<void> { for (const m of batch.messages) { try { await handle(m.body); m.ack(); } catch { m.retry(); } }}實測批次行為(max_batch_size: 5,送 12 則):
$ curl -s "localhost:8787/sendbatch?n=12"{"sentBatch":12}
$ curl -s localhost:8787/seenbatches: 3 ch19-clicks size=5 attempts=[1,1,1,1,1] ch19-clicks size=5 attempts=[1,1,1,1,1] ch19-clicks size=2 attempts=[1,1]
messageProto: ['retry', 'ack', 'constructor']batchProto: ['retryAll', 'ackAll', 'constructor']timestampIsDate: Truemax_batch_size被確實遵守:12 則 → 5 + 5 + 2。attempts從 1 開始。 這很容易寫錯 ——if (m.attempts > 3)和if (m.attempts >= 3)的意思差很多。message.timestamp是真的Date。- 逐則的
ack()/retry()優先於ackAll()/retryAll()。
批次是由兩個條件先到先觸發的:湊滿 max_batch_size,或等到 max_batch_timeout 秒。所以低流量時延遲由 timeout 決定、高流量時由 batch size 決定。
🎯 重試與 DLQ 的完整流程
Section titled “🎯 重試與 DLQ 的完整流程”實測:max_retries: 2、retry_delay: 1、設定了 DLQ,送兩則會失敗的訊息。
t+4s ch19-clicks n=2 attempts=[1,1]t+8s ch19-clicks n=2 attempts=[2,2]t+12s ch19-clicks n=2 attempts=[3,3] ch19-clicks-dlq n=2 attempts=[1,1] ← 進 DLQ,attempts 重置t+24s (不再變化)三個結論:
① max_retries: 2 代表總共 3 次嘗試。 1 次原始 + 2 次重試。命名容易誤導。
② 🔴 進到 DLQ 之後 attempts 重置為 1。
這代表 DLQ 的 consumer 看不出這則訊息重試過幾次。如果你需要那個資訊,必須在原始 consumer 裡自己塞進訊息裡:
if (m.attempts >= MAX_ATTEMPTS) { await env.DLQ.send({ ...m.body, _failedAfter: m.attempts, _lastError: String(err) }); m.ack(); // ack so it does not ALSO go to the configured DLQ return;}m.retry();③ 沒設定 DLQ 的話,重試用完的訊息會被永久刪除。 官方原文:「Without a DLQ configured, messages that reach the retry limit are deleted permanently.」
這和第 17 篇 alarm 的「重試耗盡後靜默消失」是同一類問題 —— 差別在 Queues 給了你 DLQ 這個逃生口,而 alarm 沒有。
⚠️ DLQ 會不會自動建立,官方文件沒有說。 我在本機是預先宣告了兩個佇列。部署前確認 DLQ 真的存在,不要假設 wrangler 會幫你建。
DLQ 的 consumer 要自己寫。沒有 consumer 的 DLQ 訊息會在保留期(預設 4 天)之後被刪掉 —— 也就是說 DLQ 不是無限期的保險箱。
💰 計費:每 64 KB 一次操作
Section titled “💰 計費:每 64 KB 一次操作”這是 Queues 最需要理解的部分,因為它和其他所有產品都不同。
官方定義:
「An operation is counted for each 64 KB of data that is written, read, or deleted.」
「a 65 KB message and a 127 KB message would both incur two operation charges when written, read, or deleted.」
所以:
| 事件 | 操作數 |
|---|---|
| 送出一則 < 64 KB 的訊息 | 1(寫) |
| 被 consumer 讀取 | 1(讀) |
| 被 ack 刪除 | 1(刪) |
| 典型訊息小計 | 3 |
| 每次重試 | +1(讀) |
| 進 DLQ | +1(寫) |
| 過期未讀 | 只有寫 + 刪 = 2 |
再引用兩句:
「Operations are per message, not per batch. A batch of 10 messages (the default batch size), if processed, would incur 10x write, 10x read, and 10x delete operations.」
「Each retry incurs a read operation. A batch of 10 messages that is retried would incur 10 operations for each retry.」
批次不會讓你比較便宜。 批次省的是 Worker invocation 次數(第 1 篇:請求數也要錢),不是 Queues 操作數。
| Free | Paid | |
|---|---|---|
| 操作數 | 10,000 / 日 | 含 100 萬 / 月,之後 $0.40 / 百萬 |
| 訊息保留 | 24 小時,不可調整 | 預設 4 天,可調到 14 天 |
沒有流量費(egress)。
一個實際的推論:Free 方案每日 10,000 次操作 ≈ 每天約 3,300 則訊息。這對開發和小專案夠用,但要注意每次重試都在吃這個額度。
| 項目 | 值 |
|---|---|
| 佇列數 / 帳號 | 10,000 |
| 訊息大小 | 128 KB(含約 100 bytes 的內部中繼資料) |
sendBatch 單次 | 100 則 / 256 KB |
| Consumer 批次上限 | 100 則 |
| 批次等待上限 | 60 秒 |
| 每佇列吞吐量 | 5,000 則/秒 |
| Backlog 上限 | 25 GB |
| 併發 consumer invocation | 250(僅 push) |
| Consumer wall clock | 15 分鐘 |
| Consumer CPU | 預設 30 s,可設定到 5 min |
| 重試次數上限 | 100 |
超過吞吐量時 send() / sendBatch() 會 throw;超過 backlog 會回 Storage Limit Exceeded。
沒有順序保證。 官方沒有承諾任何排序語意 —— 需要順序就自己在訊息裡放序號,或改用 Durable Object(第 14 篇的單執行緒特性)。
Pull consumer:不用 Worker 也能消費
Section titled “Pull consumer:不用 Worker 也能消費”除了 push(Worker consumer),Queues 也支援 HTTP 拉取:
npx wrangler queues consumer http add my-queuePOST /accounts/{account}/queues/{queue_id}/messages/pull { "visibility_timeout_ms": 6000, "batch_size": 50 }
POST /accounts/{account}/queues/{queue_id}/messages/ack { "acks": [{"lease_id": "..."}], "retries": [{"lease_id": "...", "delay_seconds": 600}] }batch_size預設 5,最大 100。visibility_timeout_ms預設 30 秒,最大 12 小時。- 只支援
text/json/bytes,不支援v8。 - API token 需要
queues_read和queues_write兩個權限 —— 因為 ack 本身是寫入。 - lease 逾時後才 ack 仍然會被接受:「if a consumer acknowledges a message by its lease ID after the visibility timeout is reached, Queues will still accept that acknowledgment」。
什麼時候用 pull? 消費端不在 Cloudflare 上(既有的 Kubernetes worker、外部系統),或你需要自己控制消費速率。
Queues 在 wrangler dev 完整可用 —— 上面所有實測都是本機跑的。
# Producer 和 consumer 在不同 Worker 時wrangler dev -c producer/wrangler.jsonc -c consumer/wrangler.jsonc --persist-to .wrangler/state兩個本機差異:
- 不支援
wrangler dev --remote。 官方原文:「Queues does not support Wrangler remote mode」。 - consumer 併發(
max_concurrency)本機不模擬。
完整程式碼:
examples/ch19-queues/
cd examples/ch19-queues && npm install && npm run devB=localhost:8787curl -s "$B/metrics" # metrics() 與 producer 介面curl -s "$B/contenttypes" # 四種 contentType + 無效值curl -s "$B/delay" # delaySeconds 的邊界curl -s "$B/clear"curl -s "$B/sendbatch?n=12" # max_batch_size 5 -> 5+5+2sleep 6 && curl -s "$B/seen"curl -s "$B/clear"curl -s "$B/send?n=2&fail=1" # 觀察 attempts 1 -> 2 -> 3 -> DLQsleep 15 && curl -s "$B/seen"練習:把 max_retries 改成 0,看訊息會不會直接進 DLQ(答案:會,因為 1 次原始嘗試就已經用完)。
接進 LinkForge
Section titled “接進 LinkForge”第 6 篇留下一個未解的取捨:Workers Cache 命中時 Worker 不執行,所以 waitUntil 的點擊記錄不會發生。第 14–17 篇用 DO 解決了即時計數。這一篇處理的是「需要保證不掉」的那一類事件。
三條事件路徑,各用不同機制
Section titled “三條事件路徑,各用不同機制”| 事件 | 機制 | 為什麼 |
|---|---|---|
| 點擊計數(趨勢) | Workers Cache + DO | 掉幾筆可接受,要的是即時(第 6、15 篇) |
| 計費事件(超出方案的用量) | Queues | 絕對不能掉,可以慢 |
| Webhook 通知(租戶設定的 callback) | Queues | 外部端點會掛,需要重試與 DLQ |
Queues 用在「不能掉、可以慢」的事情上。 這正好是 waitUntil 和 DO alarm 都給不了的組合。
Webhook 派送
Section titled “Webhook 派送”// apps/api — enqueueawait env.WEBHOOKS.send({ tenantId, event: "link.created", payload, attempt: 0,} satisfies WebhookMsg);async queue(batch: MessageBatch<WebhookMsg>, env: Env): Promise<void> { for (const m of batch.messages) { const endpoint = await getEndpoint(env, m.body.tenantId); try { const res = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json", "x-signature": await sign(m.body, env) }, body: JSON.stringify(m.body.payload), signal: AbortSignal.timeout(10_000), });
if (res.ok) { m.ack(); continue; }
// 4xx (except 429) will never succeed — do not waste retries. if (res.status >= 400 && res.status < 500 && res.status !== 429) { await recordPermanentFailure(env, m.body, res.status); m.ack(); continue; }
// 5xx / 429: retry with a longer delay each time. m.retry({ delaySeconds: Math.min(60 * 2 ** (m.attempts - 1), 3600) }); } catch (err) { m.retry({ delaySeconds: Math.min(60 * 2 ** (m.attempts - 1), 3600) }); } }}四個決策:
① 4xx 直接 ack,不重試。 對方回 400 代表 payload 有問題,重試 5 次只是浪費 5 次讀取操作(=錢)。只有 5xx 和 429 值得重試。
② 自己算退避,用 m.attempts - 1。 因為 attempts 從 1 開始 —— 第一次重試時 attempts 是 1,指數應該是 0。這個 off-by-one 是實測那條「attempts 從 1 開始」的直接應用。
③ AbortSignal.timeout(10_000)。 沒有 timeout 的 fetch 會吃掉 consumer 的 15 分鐘 wall clock,讓整批訊息卡住。
④ DLQ 的 consumer 只做一件事:記錄並通知租戶。
// DLQ consumer — attempts is reset to 1 here, so the original count must// have been carried in the message body.async queue(batch: MessageBatch<WebhookMsg>, env: Env): Promise<void> { for (const m of batch.messages) { await env.DB.prepare( "INSERT INTO webhook_failures (tenant_id, event, payload, failed_at) VALUES (?,?,?,?)", ).bind(m.body.tenantId, m.body.event, JSON.stringify(m.body.payload), Date.now()).run(); m.ack(); }}以每月 100 萬次 webhook、其中 5% 需要一次重試計:
| 項目 | 操作數 |
|---|---|
| 寫入 | 1,000,000 |
| 讀取 | 1,000,000 |
| 刪除 | 1,000,000 |
| 重試的額外讀取 | 50,000 |
| 合計 | 3,050,000 |
含 100 萬免費額度後 = 2,050,000 × $0.40/百萬 ≈ $0.82 / 月。
對照:如果 webhook payload 超過 64 KB,每個數字都要乘 2。所以 payload 應該只放 id 和事件類型,讓對方自己來拉完整資料 —— 這既省錢也是比較好的 webhook 設計。
// A cron (chapter 20) checks the backlog every minute.const { backlogCount, backlogBytes } = await env.WEBHOOKS.metrics();if (backlogCount > 50_000) { console.log(JSON.stringify({ event: "queue_backlog_high", backlogCount, backlogBytes }));}25 GB 的 backlog 上限一旦撞到,send() 就會開始失敗 —— 那是使用者可見的故障。提早告警。
本篇交付物:apps/consumers 的 webhook consumer(含 4xx/5xx 分流、指數退避、timeout)、DLQ consumer、webhook_failures 資料表、背壓告警。
① 以為 attempts 從 0 開始
從 1 開始。所有退避計算都要 -1。
② 以為 DLQ 裡看得到原本的重試次數
attempts 重置為 1。要自己把它塞進訊息 body。
③ 沒設定 DLQ
重試用完的訊息會被永久刪除。
④ 以為 DLQ 是永久保險箱
沒有 consumer 的話,保留期到了就刪。
⑤ 假設 wrangler 會自動建 DLQ
官方文件沒說。部署前自己確認。
⑥ 對 4xx 也重試
浪費操作數(=錢),而且永遠不會成功。
⑦ 以為批次比較便宜
操作是 per message 不是 per batch。批次省的是 Worker 請求數。
⑧ 訊息 payload 太大
超過 64 KB 就是雙倍操作數。只放 id。
⑨ consumer 裡的 fetch 沒有 timeout
會吃掉 15 分鐘的 wall clock,整批卡住。
⑩ 期待順序保證
沒有。要順序就自己放序號或改用 DO。
⑪ 還在說 Queues 需要付費方案
2026-02-04 起 Free 方案可用。
⑫ 打算用 pull consumer 卻選了 v8 contentType
pull consumer 解不開 v8。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- 一則典型訊息算三次操作(寫、讀、刪),每次重試 +1。批次不會讓它變便宜。
- **
attempts從 1 開始,而且進 DLQ 之後重置。**要保留原始次數就自己塞進 body。 - **Queues 用在「不能掉、可以慢」的事情上。**能掉的用
waitUntil,要即時的用 DO。
- Queues 設定 與 JavaScript API
- Batching, retries and delays
- Dead letter queues
- Pull consumers
- 限制 · 計費
node_modules/wrangler/config-schema.json—— 設定欄位最權威的來源
下一篇:20. Cron Triggers:排程任務 —— 本機測試的正確方式已經換了,而且官方文件對「cron 數量是 per Worker 還是 per account」自相矛盾。