背景任務佇列設計 是做什麼的?
一個背景任務系統從第一天起就必須針對三種失敗模式進行設計:任務遺失、重複執行和無節制的重試。選錯基礎元件,你可能要花費接下來一整年的時間來解決這三個問題。在 BullMQ、Sidekiq、SQS 和 Temporal 的文件中反覆出現的業界共識是:至少一次 (at-least-once) 是唯一誠實的投遞語意 — 讓每個處理器都具備冪等性 (idempotent),並停止追尋「恰好一次」(exactly-once)。(Caduh — Queues 101)
快速跳轉到你的問題:
- 任務在重試時被執行了兩次 → 冪等性
- Worker 在處理任務時崩潰,任務消失 → 可靠提取 / 可見性超時
- 死信佇列 (DLQ) 快滿了,警報在響 → 錯誤分類 + DLQ
- Redis 在積壓期間發生 OOM → 記憶體衛生
- 「這個應該用 Temporal 嗎?」 → 佇列 vs 工作流程
- 重試風暴影響下游系統 → 退避 + 抖動
- 並發超出速率限制 → 並發 + 限制器
何時使用
- 在 Node、Ruby、Python 或 Go 中設計一個新的背景任務系統。
- 從
setTimeout/cron/ 自製佇列遷移到真正的訊息代理 (broker)。 - Worker 崩潰導致任務遺失。
- 生產環境中的重試導致重複的副作用。
- 在 BullMQ (Node + Redis)、Sidekiq (Ruby + Redis)、RQ / Celery / Dramatiq (Python)、Asynq (Go + Redis)、SQS (託管式) 或 Temporal (持久化執行) 之間做選擇。
核心能力
選擇符合工作的基礎元件
| 你擁有的是 | 選擇 | 原因 |
|---|---|---|
| Node 上的短暫、有副作用的任務 (發送郵件、調整圖片尺寸) | BullMQ | 原生支援 Redis,成熟的 DLQ,流程模式,單節點典型吞吐量 ~5k 任務/秒 |
| Ruby 上的相同情況 | Sidekiq (Pro 版本以獲得可靠性) | 相同架構;Pro 版本的 super_fetch 透過 LMOVE 指令提供可靠提取 (Sidekiq wiki) |
| 需要外部 API 扇形展開 (fan-out)、持續數小時到數天的多步驟工作流程 | Temporal | 持久化執行;從事件歷史中重播 (Temporal docs) |
| AWS 原生環境、簡單的扇形展開、不想運行 Redis | SQS + Lambda | 託管式「至少一次」語意;內建可見性超時和 DLQ (AWS SQS DLQ) |
| 跨區域、多租戶隔離 | Temporal 或 SQS | 單一 Redis 無法乾淨地支撐多區域場景 |
| 需要在資料庫、郵件和支付之間實現「恰好一次」 | 都不要 — 設計「至少一次」+ 冪等處理器 | 代理內部的「恰好一次」無法跨系統延伸 (Caduh) |
隱藏的判斷標準:復原故事的複雜度為何? 一個帶有重試機制的簡單 BullMQ 佇列足以處理「發送收據」任務。一個包含 12 個步驟、需要暫停 24 小時等待 Webhook 的購買工作流程,是一個 Temporal 工作流程,而不是 12 個鏈接的佇列任務。
冪等性
這是必須強制執行的屬性。每個流行的佇列文件都會首先提到這一點。來自 BullMQ 的冪等任務頁面:「任務無論是在第一次嘗試就成功完成,還是初始失敗後重試成功,都不應對系統的最終狀態產生影響。」(BullMQ idempotent-jobs)
實用模式:
// 1. 冪等性金鑰應源自業務事件,而非佇列本身。
// 如果有上遊 event_id (例如 Stripe event.id, Shopify hook id),請使用它。
const job = await queue.add(
'send-receipt',
{ orderId, amount },
{
jobId: `send-receipt:${orderId}`, // BullMQ 根據佇列中的 jobId 進行去重。
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 86400 },
}
);
// 2. 在處理器內部,也應在副作用發生的位置進行去重。
async function processSendReceipt(job) {
const { orderId, amount } = job.data;
// 如果已經發送過,就不做任何事。
const sent = await db.queryOne(
'INSERT INTO email_sends (idempotency_key, order_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id',
[`send-receipt:${orderId}`, orderId]
);
if (!sent) return { skipped: 'already-sent' };
await emailProvider.send({ to: ..., subject: ..., body: render(amount) });
}
jobId 可以在重複任務仍處於佇列中時防止重複添加。資料庫的唯一約束則可以防止跨 Worker 崩潰、重試和重播時的副作用重複。兩者都是必需的;僅靠 jobId 是不夠的,因為 BullMQ 會移除已完成的任務,導致去重的視窗消失。
可靠提取與可見性超時
當一個 Worker 拉取任務後崩潰時,會發生什麼事?
| 系統 | 預設行為 | 復原機制 |
|---|---|---|
| BullMQ | 任務有 lockDuration (預設 30 秒)。過期後,停滯任務檢查器會將其重新排入佇列 (BullMQ production) | Worker 必須在 lockDuration 內延長鎖定或完成任務;否則任務會被執行兩次 |
| Sidekiq OSS | 彈出操作 (POP) 非原子性;如果 Worker 在彈出和處理之間死亡,任務會遺失 | 升級到 Sidekiq Pro 的 super_fetch (Redis 6.2+ 使用 LMOVE 移到私有的工作佇列) |
| Sidekiq Pro super_fetch | 使用 LMOVE 移到每個行程的處理清單。心跳在 60 秒後過期;孤兒檢查會掃描並重新排隊 (Sidekiq wiki) | 內建 |
| SQS | 可見性超時 (預設 30 秒,最長 12 小時)。如果未刪除,訊息會在超時後重新出現 | 設定超時時間 > 處理器的 p99 延遲;對於長時間任務,使用 ChangeMessageVisibility 進行延長 |
| Temporal | 活動 (Activity) 具有心跳和重試策略。工作流程歷史在 Worker 死亡後依然存活 | 從歷史重播;你無需考慮這個問題 |
陷阱:lockDuration / 可見性超時短於處理器的 p99 延遲 → 任務會執行兩次。時間長於可接受的復原時間 → 崩潰的工作會等待太久。請先測量你的 p99,將超時時間設定為約 p99 的 3 倍,並讓長時間運行的處理器透過心跳來延長鎖定。
錯誤分類與死信佇列
並非所有錯誤都應該無限期重試。根據 SQS 文件:「設定最大接收次數... 和重新驅動策略,以便在超出閾值時將訊息發送到 DLQ。」(AWS SQS DLQ)
// 在拋出前進行分類。永久錯誤不應重試。
class PermanentError extends Error { constructor(m: string) { super(m); this.name = 'PermanentError'; } }
class TransientError extends Error { constructor(m: string) { super(m); this.name = 'TransientError'; } }
async function process(job) {
try {
const user = await db.user(job.data.userId);
if (!user) throw new PermanentError('user-not-found'); // → 立即進入 DLQ
if (user.suspended) throw new PermanentError('user-suspended');
await externalApi.call(user);
} catch (e) {
if (e instanceof PermanentError) {
// 立即移至 DLQ — 重試無濟於事。
job.discard(); // BullMQ: 防止重試
throw e;
}
throw e; // 網路/超時錯誤 → 根據重試策略重試
}
}
DLQ 還需要:
- 一個針對數量增長的警報 (不僅僅是非零 — 「DLQ 永遠有 3 個東西」沒問題;「DLQ 每分鐘增長 100 個」是火警)。
- 一個重播工具 (CLI 或管理員 UI),讓人類操作員修復上遊錯誤、重播死信任務並清空 DLQ。
- 儀表板 (參見
grafana-dashboard-builder)。
退避與抖動
僅靠指數退避是不夠的。如果 1000 個任務都在 12:00:00 因為下游服務短暫故障而失敗,純指數退避會導致所有 1000 個任務分別在 12:00:01、12:00:03、12:00:07 同時重試 — 這形成了一個「驚群效應」(thundering herd),可能會讓正在恢復的下游服務再次癱瘓。請加上全抖動 (full jitter):
// AWS 風格的「全抖動」: delay = random(0, exp_backoff)
const baseDelay = Math.min(2 ** attempt * 1000, 30_000);
const delay = Math.random() * baseDelay;
對於 BullMQ 推薦的基線設定,going-to-production 文件建議使用 retryStrategy 將重試間隔設定在 1 秒到 20 秒之間。(BullMQ production)
並發與限制器
每個 Worker 的並發設定允許從單個行程中並行運行 N 個任務。多個 Worker 會放大這個數字。如果沒有佇列級別的限制器,你無法限制對下游的聚合呼叫量:
// BullMQ — 對這個佇列上的所有 Worker 進行全局速率限制。
const queue = new Queue('outbound-emails', {
connection,
limiter: { max: 100, duration: 1000 }, // 每秒最多 100 封郵件,跨整個服務集群
});
正確的形態:大量的並發 Worker + 一個用於遵守下游合約限制的佇列限制器。Sidekiq 有等效的速率限制器;SQS 則使用 Lambda 並發數或每訊息速率來控制。
記憶體衛生
BullMQ 生產環境中最被強調的一個陷阱是:將 Redis 實例的 maxmemory-policy 設定為 noeviction。任何其他策略 (allkeys-lru, volatile-lru) 都會在記憶體壓力下默默地逐出排隊中的任務。(BullMQ production)
此外:
- 積極地設定
removeOnComplete(例如{ age: 3600, count: 1000 })。 - 一旦你開始使用 DLQ 模式,對
removeOnFail可以設定得更為積極。 - 啟用 Redis AOF 持久化 —
appendfsync everysec是 BullMQ 文件中提到的甜蜜點。(BullMQ production) - Worker 設定
maxRetriesPerRequest: null以防止 ioredis 在短暫斷線時拋出錯誤。(BullMQ production) - 生產者端:設定
enableOfflineQueue: false,讓生產者在無法連接時快速失敗,而不是緩衝請求。
優雅關機
process.on('SIGTERM', async () => {
await worker.close(); // 停止接受新任務;等待正在執行的任務完成。
await connection.quit();
process.exit(0);
});
process.on('SIGINT', async () => { /* 相同 */ });
BullMQ 的生產環境指南明確指出:在停止行程前先關閉 Worker;預設的停滯超時約為 30 秒。(BullMQ production)
佇列 vs 工作流程決策
來自 Temporal 文件的有用框架:「與訊息佇列在服務之間傳遞資料不同,Temporal 編排整個流程... 知道你處於工作流程的哪個位置、哪些已完成、哪些待處理、哪些需要重試。」(Temporal blog)
flowchart TD
A[背景工作] --> B{單一副作用還是管道?}
B -->|單一| C[BullMQ / Sidekiq / SQS]
B -->|多步驟| D{步驟耗時數秒還是數小時以上?}
D -->|數秒| E[BullMQ flows / Sidekiq batches]
D -->|數小時以上或需要人工介入| F[Temporal / DBOS / Restate]
C --> G{需要跨區域或多租戶隔離?}
G -->|是| H[SQS 或 Temporal — 單一 Redis 叢集無法滿足]
G -->|否| I[使用 Redis 叢集 + AOF + noeviction]
反模式
僅透過 Redis SET-NX 實現冪等性
症狀:在 Redis 短暫故障或重啟期間,同一個任務被執行兩次,並產生重複的副作用 (扣款、郵件、工單)。
診斷:Redis 是一個快取;它可能被逐出、重啟或發生分區。僅存在於 Redis 中的 SET-NX 去重機制是「盡力而為」的。
修復:在副作用記錄 (例如 email_sends.idempotency_key) 上使用資料庫唯一約束來實現冪等性金鑰。創建副作用的資料庫事務本身就是去重的基本單元。
lockDuration 短於處理器 p99
症狀:出現停滯任務日誌條目;同一個任務被兩個 Worker 處理。客戶投訴。
診斷:預設的 lockDuration 為 30 秒;處理器有時需要 45 秒;第二個 Worker 會撿起這個任務。
修復:測量 p99,將 lockDuration 設定為約 p99 的 3 倍,並在長時間處理器中呼叫 job.extendLock()。或者拆分任務。
純指數退避 (無抖動)
症狀:下游系統的短暫故障變成了下游系統的全面中斷;10k 個任務以鎖步方式衝擊正在恢復的服務。 診斷:沒有抖動,重試時間是相關聯的。 修復:使用全抖動退避。限制最大延遲以限制 DLQ 的失敗時間。
Redis 使用了 maxmemory-policy allkeys-lru
症狀:佇列在負載下神秘地遺失任務。沒有任何錯誤。(BullMQ production)
診斷:Redis 為了騰出空間而逐出了任務金鑰。
修復:執行 CONFIG SET maxmemory-policy noeviction 並在啟動時檢查此設定。Worker 應拒絕在違反此設定的環境中啟動。
假裝跨系統存在「恰好一次」
症狀:同一個客戶被扣款兩次;發送了兩封相同的郵件。工程師堅信「代理是『恰好一次』的。」 診斷:即使是一個 FIFO/恰好一次的代理,也無法將其保證延伸到你的資料庫、郵件服務商、Stripe 和帳本。 修復:將佇列視為「至少一次」。在每個副作用邊界實施冪等性。
DLQ 沒有重播工具
症狀:「我們 DLQ 裡有 3,200 個任務。我們不知道裡面有什麼。」DLQ 變成了一個無人監控的墳場。 診斷:建立了 DLQ,但沒有建立運維工具。 修復:提供按 ID 重播、按時間範圍重播、確認後清空等功能。CLI 或管理員 UI。需要經過測試。
在事件循環中執行長時間阻塞的工作
症狀:BullMQ Worker 行程無回應;心跳停止;鎖定過期;任務重新執行。
診断:在與佇列心跳相同的行程中執行 CPU 密集型工作;事件循環阻塞時間超過 lockDuration。
修復:為 CPU 密集型工作建立子行程或 Worker 執行緒。或者運行一個沙箱處理器 (BullMQ 支援每個任務的行程沙箱)。
品質檢查清單
- 測試: 混沌測試 — 在任務處理中途殺死 Worker;斷言任務會重新執行且副作用不會重複。
- 測試: 重試風暴測試 — 讓下游系統故障 60 秒;斷言退避 + 抖動分散了重試 (監控指標中沒有驚群效應)。
- 每個處理器都是冪等的,並使用源自業務事件的穩定冪等性金鑰。
- 存在資料庫級別的唯一約束來備份任何存在於 Redis 中的去重機制 (jobId, SET-NX)。
-
lockDuration/ 可見性超時 ≥ 3 × 測量到的處理器 p99 延遲,並且長時間處理器會延長鎖定。 - 永久錯誤 (
PermanentError類或等效物) 繞過重試,直接進入 DLQ。 - DLQ 警報:當增長速率 > N/分鐘時觸發頁面通知,而不僅僅是計數 > 0。
- DLQ 重播工具存在且已測試 (單個 + 範圍)。
- Redis:
maxmemory-policy=noeviction,已啟用 AOF (appendfsync everysec),並在 Worker 啟動時檢查。(BullMQ production) - 已設定
removeOnComplete和removeOnFail。任務計數指標已在監控中顯示趨勢。