跳到內容

Queues:把工作推離熱路徑

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24

第 3 篇的結論是「waitUntil 是 best-effort,需要保證送達就用 Queues」。這一篇處理 Queues。

它的定位很單純:把非關鍵路徑的工作從請求裡搬出去,並且保證它最終會被處理。

三件必須先講清楚的事:

  1. 計費單位是「每 64 KB 的讀、寫或刪」,所以一則典型訊息大約算三次操作,而每次重試再加一次讀。這個模型和其他產品都不一樣。
  2. attempts 從 1 開始,不是 0。 而且進到 DLQ 之後會重置回 1 —— 我實測過。
  3. 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 端的欄位:bindingqueuedelivery_delayremote。 Consumer 端的欄位:queuetypemax_batch_sizemax_batch_timeoutmax_retriesdead_letter_queuemax_concurrencyvisibility_timeout_msretry_delay

⚠️ 官方的 configure-queues 文件頁漏掉了 delivery_delayretry_delay,而且它的範例用了 max_batch_timeout: 30max_retries: 10(都不是預設值),容易讓人誤以為那是預設。

另外有些資料說 visibility_timeout 只能在 CLI 設定 —— 但 wrangler 的 JSON schema 裡 consumer 確實有 visibility_timeout_ms。以 schema 為準(node_modules/wrangler/config-schema.json)。

await env.CLICKS.send(body, { contentType, delaySeconds });
await env.CLICKS.sendBatch(messages, { delaySeconds });
const m = await env.CLICKS.metrics();

實測 producer 的介面:

Terminal window
$ curl -s localhost:8787/metrics
{
"metrics": { "backlogCount": 0, "backlogBytes": 0 },
"producerProto": ["metrics", "send", "sendBatch", "constructor"]
}

metrics() 是 2026-04-28 才加的。 官方文件說它回傳 backlogCountbacklogBytesoldestMessageTimestamp —— 我在本機只拿到前兩個(佇列是空的,oldestMessageTimestamp 可能因此不存在)。production 上要自己確認第三個欄位。

它的用途是背壓判斷

const { backlogCount } = await env.CLICKS.metrics();
if (backlogCount > 100_000) {
// Shed load, alert, or switch to a degraded path.
}
Terminal window
$ 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

Terminal window
$ 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

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 則):

Terminal window
$ curl -s "localhost:8787/sendbatch?n=12"
{"sentBatch":12}
$ curl -s localhost:8787/seen
batches: 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: True
  • max_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 決定。

實測:max_retries: 2retry_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 不是無限期的保險箱

這是 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 操作數。

FreePaid
操作數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 invocation250(僅 push)
Consumer wall clock15 分鐘
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 拉取:

Terminal window
npx wrangler queues consumer http add my-queue
POST /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_readqueues_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 完整可用 —— 上面所有實測都是本機跑的。

Terminal window
# 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/

Terminal window
cd examples/ch19-queues && npm install && npm run dev
Terminal window
B=localhost:8787
curl -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+2
sleep 6 && curl -s "$B/seen"
curl -s "$B/clear"
curl -s "$B/send?n=2&fail=1" # 觀察 attempts 1 -> 2 -> 3 -> DLQ
sleep 15 && curl -s "$B/seen"

練習:把 max_retries 改成 0,看訊息會不會直接進 DLQ(答案:會,因為 1 次原始嘗試就已經用完)。


第 6 篇留下一個未解的取捨:Workers Cache 命中時 Worker 不執行,所以 waitUntil 的點擊記錄不會發生。第 14–17 篇用 DO 解決了即時計數。這一篇處理的是「需要保證不掉」的那一類事件。

事件機制為什麼
點擊計數(趨勢)Workers Cache + DO掉幾筆可接受,要的是即時(第 6、15 篇)
計費事件(超出方案的用量)Queues絕對不能掉,可以慢
Webhook 通知(租戶設定的 callback)Queues外部端點會掛,需要重試與 DLQ

Queues 用在「不能掉、可以慢」的事情上。 這正好是 waitUntil 和 DO alarm 都給不了的組合。

// apps/api — enqueue
await env.WEBHOOKS.send({
tenantId, event: "link.created", payload, attempt: 0,
} satisfies WebhookMsg);
apps/consumers
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


  1. 一則典型訊息算三次操作(寫、讀、刪),每次重試 +1。批次不會讓它變便宜。
  2. **attempts 從 1 開始,而且進 DLQ 之後重置。**要保留原始次數就自己塞進 body。
  3. **Queues 用在「不能掉、可以慢」的事情上。**能掉的用 waitUntil,要即時的用 DO。


下一篇20. Cron Triggers:排程任務 —— 本機測試的正確方式已經換了,而且官方文件對「cron 數量是 per Worker 還是 per account」自相矛盾。