這篇要回答的問題:handler 失敗了該重試嗎?重試幾次?間隔多久?哪些失敗根本不該重試該直接 park?failed job 進 DLQ 後該怎麼處理?
3 分鐘結論
- handler 失敗分兩種:transient(救得回,retry)跟 permanent(救不回,進 DLQ)
- Retry 要有 backoff(避免暴衝)、要有上限(避免 poison message 卡死)、要 per-handler 政策(不同 handler 容忍度不同)
- DLQ 不是「失敗回收桶」,是 operational triage 入口 ⸺ 沒人看的 DLQ 等於不存在
- Retry 安全的前提是 handler 冪等(篇 3),不冪等 + retry = 災難放大
這篇假設你知道
- 篇 3 Consumer 端冪等性 ⸺ retry 的前提
- try/catch + throw 基本概念
- 不熟下面這些詞時 → #16 EDA 名詞速查:retry with backoff / DLQ / transient failure / timeout / poison message
完整 runnable demo:tools3455147/mq-event-driven-demo。
1. 一個 retry 失控的場景
某新加入的工程師寫了個 handler 送通知到 Slack:
async function notifyOpsHandler(event) {
await slackClient.postMessage({
channel: '#ops',
text: `New high-value order: ${event.amount}`,
});
}某天 Slack API rate-limit,所有 call 開始回 429。Handler throw,broker 重投,handler 再 throw…
幾分鐘後 oncall 收到三百個 alert:
- 訊息監控:「Slack 收到 5000+ 條同樣的訊息」(broker 把同 event 重投 N 次)
- DB 監控:「processed_events 表寫入飆 100 倍」(每次重投 dedup check + 失敗 retry)
- broker 監控:「queue depth 飆到 50 萬」(一直 retry 一直累積)
問題:handler 沒設重試上限。失敗就無限 retry,把整個系統拖垮。
更糟的版本:handler 處理某個壞 payload throw 後,broker 把它一直 retry。同個 message 永遠卡在隊列前面,後面的 message 排不到。整條 pipeline 卡死。
這就是「Poison message(毒訊息)」現象。下面講怎麼設計 retry 策略 + DLQ 避免這種失控。
2. Newcomer primer:先把幾個概念對齊
Retry / Backoff / DLQ
- Retry:handler 失敗後,broker(或 application)把同個 event 再送一次給 handler
- Backoff:retry 之間等多久才重試(避免「失敗 → 立刻 retry → 又失敗」的火力浪費)
- DLQ (Dead Letter Queue):retry 用盡仍失敗的 event 被丟到的「永久失敗 sink」,不再 retry,等人工 / 自動補償流程處理
Transient vs Permanent failure
兩種根本不同的失敗模式:
| Transient(暫時) | Permanent(永久) | |
|---|---|---|
| 例子 | 第三方 API 偶爾 504 / DB temp lock / network blip | event payload 結構錯 / 外鍵不存在 / handler 拋 NullPointerException |
| 重試行為 | 重試幾次很可能成功 | 重試 N 次都失敗 |
| 該怎麼處理 | 用 retry with backoff 救回 | 退 DLQ,不要無限 retry |
| 區分方法 | 看 error 種類 / network 性質 | 看 error 種類 / payload 性質 |
production 系統的 retry 策略要同時應對兩種:對 transient 救得回、對 permanent 不死纏。
Timeout 是不同的維度
「handler 跑了 30 秒還沒回」跟「handler throw」不一樣:
- handler throw → retry loop 處理(retry 或 DLQ)
- handler 卡住(無限迴圈 / 外部 API 沒設 timeout)→ 不會 throw,永遠不會走到 retry 邏輯
需要在每次 attempt 外面套一層 timeout 當 last resort,逼停止 → 變 throw → 進 retry 流程。
3. Retry with backoff 設計
最簡單的 retry:
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await handler(event);
return; // 成功
} catch (err) {
if (attempt >= maxAttempts) throw err;
await sleep(backoffMs); // 等一下再 retry
}
}關鍵問題:backoffMs 怎麼算?
Linear backoff — 簡單但容易撞牆
attempt 1 → 等 100ms → attempt 2 → 等 200ms → attempt 3 → 等 300ms → ...
- ✅ 簡單、可預測
- ❌ 對短暫失敗夠用,但對「下游掛了 5 分鐘」這種長故障不夠 — 你 retry 3 次(100+200+300=600ms)後就放棄了
Exponential backoff — 適應大範圍故障
attempt 1 → 等 1s → attempt 2 → 等 2s → attempt 3 → 等 4s → attempt 4 → 等 8s → ...
- ✅ 故障短時快速救回、故障長時自動延後
- ✅ 對下游施加的壓力遞減(避免雪上加霜)
- ❌ 5 次後就是 16 秒一次,等到第 10 次要等 17 分鐘 — 可能比業務 deadline 久
Exponential + jitter — production 推薦
attempt N → 等 base * 2^N + random(0, jitter_max)
- ✅ 同上的指數退避
- ✅ jitter 隨機延遲打散「眾多 handler 同時 retry 撞 broker」的雪崩
- ✅ 對 distributed system 友善
- ❌ 不可預測(hard for debugging)
Demo 用的策略
mq-event-driven-demo 為了 deterministic 測試用最簡單的 linear:
// src/domain/handler-registry.ts
backoffMs: (attempt) => 50 * attempt
// attempt 1 → 50ms, 2 → 100ms, 3 → 150msProduction 系統推薦 exponential + jitter。AWS / GCP SDK / Google’s Cloud SDK 內建的 retry 都長這樣。
4. Per-handler policy — 不同 handler 不同政策
最常見的 anti-pattern:一份 retry config 套全部 handler。實務上各 handler 性質差太多:
| Handler | 性質 | 適合的 retry policy |
|---|---|---|
| 重要 + 可慢、外部 SMTP 偶爾抖 | maxAttempts=5、exponential backoff | |
| payment_charge | 關鍵 + 不能盲目重試、可能扣兩次款 | maxAttempts=0 (fail-fast → 人工 triage) |
| analytics | best-effort、掉了沒差 | maxAttempts=1 (不浪費 retry 預算) |
| inventory | 中度、要對得平 | maxAttempts=3、linear backoff |
| recommender_signal | best-effort | maxAttempts=1 |
設計重點:retry policy 是 business decision,不是 framework default。每個 handler 都該有意識地選一個。
Demo 的 HandlerPolicy interface
// src/domain/handler-registry.ts
export interface HandlerPolicy {
maxAttempts: number;
timeoutMs: number;
backoffMs: (attempt: number) => number;
dedupEnabled: boolean;
}
export const DEFAULT_POLICY: HandlerPolicy = {
maxAttempts: 3,
timeoutMs: 30_000,
backoffMs: (attempt) => 50 * attempt,
dedupEnabled: true,
};
// 註冊 handler 時附 policy(demo 全部用 DEFAULT_POLICY):
register({
name: 'analyticsHandler',
fn: analyticsHandler,
policy: {
...DEFAULT_POLICY,
maxAttempts: 1, // analytics 是 best-effort,掉就掉
},
});把 policy 跟 handler 綁在一個 registry,handler 註冊時就決定好自己的 retry 策略 ⸺ 而不是靠 env var / config file 散落各處。
為什麼不用 env var
「MAX_HANDLER_RETRIES=3 一個 env var 套全部」是新人最常見的設計。問題:
- 一個值套不同性質的 handler,總有 handler 不適合
- 想改某個 handler 的政策要動 env var → restart → 影響全部
- env var 跟業務語意脫鉤(讀 env var 名字看不出「為什麼這個 handler 該重試 3 次」)
per-handler policy 在程式碼裡,跟 handler 註冊在同一處,business decision 跟 implementation 並排。
5. Timeout — handler 卡死的 last resort
handler 沒 throw 但永遠不回應的情境:
- 外部 API call 沒設 client timeout(Node fetch 預設無 timeout)
- 無限迴圈 bug
- DB query 因為缺 index 跑 5 分鐘
- 死鎖(兩個 handler 互等對方的鎖)
這些情境不會走到 retry 邏輯,因為 retry loop 在等 handler 回應。永遠卡住。
解法:每次 attempt 套 timeout
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
if (timeoutMs <= 0) return promise;
return new Promise<T>((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`handler timeout ${timeoutMs}ms`)), timeoutMs);
promise.then(
(v) => { clearTimeout(t); resolve(v); },
(e) => { clearTimeout(t); reject(e); },
);
});
}
// retry loop 內:
await withTimeout(handler(event), policy.timeoutMs);Promise.race 風格的 timeout — handler 卡 timeoutMs 後 reject(變 throw),retry loop 看到「失敗」會重試或進 DLQ。
注意 Promise.race 的痛點
Promise.race 不會真的取消原本的 handler call — 它只是「不等了,往下走」。
如果原本 handler 真的有狀態變化(拿了個 DB lock、開了個 connection),race 後仍然在跑。所以:
- handler 內呼叫的所有外部 call 都該各自帶 timeout(DB query timeout、HTTP client timeout)
- 業務 transaction 要小,handler timeout 後 DB 自己會 rollback
- 不要依賴「timeout 後一切清理乾淨」— 那只是個指示,不是 enforcement
Demo 對應
/demo/transient-fail endpoint 故意讓 email handler 失敗 N 次後成功,驗證 retry 確實救得回:
# 設定:email handler 接下來頭 2 次 throw、第 3 次成功
curl -X POST 'http://localhost:8000/demo/transient-fail?fail_count=2' \
-d '{"user_id":1,"items":["A"]}'
# 查 audit
curl -s "http://localhost:8000/audit/event/<event_id>" | jq '.processed[] | select(.handler=="emailHandler")'
# emailHandler 最後 markDone,沒進 DLQ6. DLQ 作為 operational loop 起點
retry 用盡仍失敗的 event → DLQ。但 DLQ 不是終點,是開始 operational triage 的入口。
DLQ schema
CREATE TABLE dlq (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL,
handler TEXT NOT NULL,
bus TEXT NOT NULL,
payload JSONB NOT NULL, -- 完整 event payload,給 triage 用
error TEXT NOT NULL, -- 最後一次失敗的 error message
attempts INT NOT NULL, -- 嘗試了幾次才放棄
failed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);注意:PK 不是 event_id,是 BIGSERIAL ⸺ 因為「一個 event 給多個 handler,可能 N 個 handler 各自進 DLQ」。
DLQ 的 operational loop
1. handler retry 用盡 → INSERT dlq row
2. alert 觸發 (e.g., dlq.count > threshold) → oncall 看到
3. oncall 開 /admin/dlq 看清單,挑一筆 / 一批 triage
4. 看 payload + error 判斷:
a) Transient 在 N 個小時前掛現在恢復了 → POST /admin/dlq/:id/retry → republish
b) Bug 已修、code 已 deploy → 同上
c) Permanent 數據錯 (foreign key、null) → 標記 + 不重投,留待 audit
d) 已過 business deadline → 標記 abandon
5. row 處理完 → DELETE / archive
DLQ alert 不是「自動修復」,是「人類進來看」。production 系統會把 DLQ growth rate 接到 alerting(Datadog / PagerDuty),超過 SLA 就 page oncall。
為什麼進 DLQ 後 broker 不再 retry
關鍵設計:手動 retry by reading from DLQ → republish to broker,不要讓 broker 自己 retry 已 DLQ 的 event。
理由:DLQ 是「已決定永久失敗」的訊號。如果 broker 還在 retry,DLQ row 就會跟 broker queue 雙重計數,oncall 看不到完整全貌。乾淨的設計是「進 DLQ 後 broker 端 ack 掉、不再投遞」,retry 的觸發只有「人工點 retry button」。
Demo 對應
# 1. 故意推一個一定失敗的 event
curl -X POST 'http://localhost:8000/demo/force-dlq' -d '{"user_id":1,"items":["X"]}'
# 2. 等 retry 用完
sleep 5
# 3. 看 DLQ
curl -s 'http://localhost:8000/admin/dlq' | jq
# 4. 看單一筆細節
curl -s 'http://localhost:8000/admin/dlq/1' | jq
# 5. 人工 retry:republish + 刪 DLQ row
curl -X POST 'http://localhost:8000/admin/dlq/1/retry'
# 已 markDone 的 handler 會被 dedup 跳過、failed 的 handler 會重跑/admin/dlq/:id/retry 不光是 republish — 它配合 篇 3 dedup 表 工作:已成功的 handler 在 retry 後仍然被 dedup 擋下,只有真的失敗的 handler 會重跑。
7. 給 Laravel 讀者的術語對譯(可選)
📍 沒用過 Laravel 的讀者可整節跳過,不影響後面理解。
Laravel + Horizon 在 retry / DLQ 這層做得最完整:
| Laravel 概念 | 本篇對應 | Laravel 幫你做了 | 你還要自己做 |
|---|---|---|---|
$tries 屬性 | maxAttempts | 每 Job class 設 | per-handler policy(per-Job 比 per-handler 細,OK) |
$backoff 屬性 | backoffMs | linear / array / closure | exponential + jitter 要自己寫 closure |
$timeout 屬性 | timeoutMs | 每 Job 設 | 一樣,注意是 OS-level signal,不是 Promise.race |
| Horizon Failed Jobs UI | DLQ browse | UI + 手動 retry | alert + 自動 triage 流程 |
failed() callback | DLQ insert handler | 進 failed 表時 trigger | 跨 service compensation |
queue:retry-all | bulk republish | 一行指令 | 跟 dedup 表搭配(沒擋到會重複處理) |
Bus::chain([...]) | sequential job | 失敗中斷後續 | 補償邏輯(哪些已執行該 rollback) |
WithoutOverlapping middleware | handler-level lock | atomic redis lock | event-level dedup(本篇沒講,篇 3 講過) |
Laravel 在這層生態相當好:$tries=3、$backoff=[1, 5, 10]、failed_jobs 表是個內建版本的 DLQ、Horizon UI 提供瀏覽 + retry。
剩下要自己加的:
- Per-handler policy 跨 framework 一致地表達(Laravel 用 Job class 屬性、本系列 demo 用 HandlerPolicy registry,兩個都 OK)
- 進 DLQ 後的 alert + 自動 triage
- 跨 service / 跨 framework 的補償邏輯
- 跟 dedup 表的整合(篇 3)
8. 反思
寫這篇遇到最微妙的取捨是「retry 跟 dedup 的關係」。
很多新人以為「retry 跟 dedup 是兩件不相關的事」。實際上 retry 的存在前提就是 handler 冪等:
no dedup + retry → 每次 retry 都產生新副作用 → 客人收到 N 封信
yes dedup + retry → 重試直到成功,副作用只發生一次 → effectively-once
所以引入 retry 前,先確認 篇 3 dedup 跟 篇 4a SQL 冪等 都做了。這兩篇不做就引入 retry,會把問題放大 N 倍。
更深一層:DLQ 不是「失敗的訊息回收桶」,是「operational decision 的入口」。每個進 DLQ 的 event 都該被人類(或自動補償流程)看過、做出決定(retry / abandon / 標記)。
很多團隊有 DLQ 但沒人看 DLQ,於是 DLQ 積了幾萬筆訊息 ⸺ 跟沒有 DLQ 沒兩樣。DLQ 沒人 triage = DLQ 不存在。
最後一個問題給看完這篇的你:
你現在系統的 failed jobs 表 / DLQ:多久有人看一次?是不是現在打開 admin UI 會看到幾百筆失敗訊息躺在那?如果是,DLQ 對你的系統實際上沒在 work。
9. 相關文章
本系列前置:
- 篇 1 Production EDA 入門
- 篇 3 Consumer 端冪等性 — retry 安全的前提
- 篇 4a DB 寫入冪等性 — handler 內 SQL 冪等
- 篇 4b 事件亂序 + 跨 row 鎖 — 並發控制
本系列接下來:
- 篇 6 → EDA 端到端追蹤 — DLQ 是 audit 的重要拼圖之一
外部參考:
- AWS Architecture Blog — Implementing exponential backoff and jitter
- Microservices.io — Dead Letter Channel(提到 DLQ 角色)
- Laravel Docs — Queue Failed Jobs
Runnable demo: tools3455147/mq-event-driven-demo — 本篇 demo 在 src/domain/safe-fanout.ts(retry loop) + src/domain/handler-registry.ts(HandlerPolicy) + src/dlq/(DLQ table)。Hands-on 用 /demo/transient-fail、/demo/force-dlq、/admin/dlq/* 三組 endpoint
設計文件: docs/architecture.md