
API 必須 200ms 內回應,但同時要寄信、寫分析、觸發 webhook。如果全部同步做,回應時間直接飆到 2 秒。如果其中一個服務掛了,整個 request 就失敗。Queue 的目的不是把問題藏起來,是把「不可控」變成「可控」。
先講結論
同步只做核心成功(扣款、下單),其餘交給 Queue。Consumer 必須冪等(重複處理不出錯)。失敗的事件進 DLQ(Dead Letter Queue),不能直接消失。沒有監控的 Queue 比沒有 Queue 更危險。
同步 vs 非同步:先劃清邊界
簡單的判斷:使用者要立刻知道結果的 → 同步。失敗可延後處理的 → 非同步。
- 同步:扣款、權限驗證、下單確認
- 非同步:寄信、推播通知、分析事件、對外 webhook
原則是 API 只做「核心成功」,剩下的事情發一個 event 就好。使用者不需要等你寄完信才看到「下單成功」。
冪等:讓重複也不出錯
大部分 Queue 系統只保證 at-least-once——訊息可能被送兩次。如果 consumer 不是冪等的,你就會寄兩封信、扣兩次款。
CREATE TABLE event_consumed (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ DEFAULT now()
);
-- 消費前先登記,已存在就跳過
INSERT INTO event_consumed (event_id)
VALUES (:event_id)
ON CONFLICT DO NOTHING;事件要有唯一 event_id,consumer 先查有沒有處理過,處理過就直接 ACK。簡單但救命。
Outbox Pattern:避免「DB 寫了但事件沒發」
你在同一個 request 裡要做兩件事:寫 DB + 發事件。如果 DB 寫成功但事件發失敗呢?資料已經變了,但下游不知道。
Outbox Pattern 的解法:把事件也寫進同一個 DB transaction 的 outbox table,然後由背景 worker 去讀 outbox 發送事件。因為在同一個 transaction 裡,要嘛一起成功、要嘛一起失敗。
Retry 和 DLQ:失敗不是結束
第一次失敗 5 秒後重試、第二次 30 秒、第三次 5 分鐘。超過次數就進 DLQ。
DLQ 不是垃圾桶。 裡面的事件都是需要人工介入的。你要有流程去 review DLQ:為什麼失敗?修完 bug 後能不能重新送回 Queue?如果 DLQ 堆了幾百筆沒人管,那等於你有幾百筆業務丟失了。
技術選型:從簡單開始
Redis Queue(BullMQ):最快上手,你多半已經有 Redis。適合小團隊、低流量。
import { Queue, Worker } from 'bullmq'
const queue = new Queue('email', {
connection: { host: 'redis', port: 6379 },
})
await queue.add('send', { to: 'user@acme.com' }, { attempts: 3 })
const worker = new Worker('email', async job => {
// send email
}, { connection: { host: 'redis', port: 6379 } })RabbitMQ:需要 routing、ack、延遲、priority 的場景。功能比 Redis 強大,但多一個服務要維護。
Kafka:高吞吐、可 replay。適合事件溯源和大規模系統。但部署和維護成本高,10 人團隊不需要。
建議順序:Redis → RabbitMQ → Kafka。 不要因為履歷好看就直接上 Kafka。
Backpressure:Queue 越來越長怎麼辦?
Queue 長度持續增加 = 生產速度 > 消費速度。不處理的話,記憶體會爆、延遲會飆。
應對方式:增加 worker 數量(最直接)、降低 producer 速率(限流)、監控 queue length 並告警。如果 queue 長度超過 1000 且持續 10 分鐘,你應該要知道。
事件命名:發布了就是契約
order.created、invoice.paid —— domain.action 格式。加上 schema_version: 2 做版本管理。
事件一旦發布就是契約,不能隨便改格式。consumer 依賴這個格式,你改了它就壞了。要改就加新版本,讓 consumer 有時間遷移。
Queue 就像餐廳的出菜口。廚師做完菜放上去,服務生自己來拿。廚師不用等服務生、服務生不用等廚師,兩邊各自忙各自的——但前提是出菜口不能塞住。