這篇要回答的問題: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 = 災難放大

這篇假設你知道

完整 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 blipevent 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 → 150ms

Production 系統推薦 exponential + jitter。AWS / GCP SDK / Google’s Cloud SDK 內建的 retry 都長這樣。


4. Per-handler policy — 不同 handler 不同政策

最常見的 anti-pattern:一份 retry config 套全部 handler。實務上各 handler 性質差太多:

Handler性質適合的 retry policy
email重要 + 可慢、外部 SMTP 偶爾抖maxAttempts=5、exponential backoff
payment_charge關鍵 + 不能盲目重試、可能扣兩次款maxAttempts=0 (fail-fast → 人工 triage)
analyticsbest-effort、掉了沒差maxAttempts=1 (不浪費 retry 預算)
inventory中度、要對得平maxAttempts=3、linear backoff
recommender_signalbest-effortmaxAttempts=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,沒進 DLQ

6. 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 屬性backoffMslinear / array / closureexponential + jitter 要自己寫 closure
$timeout 屬性timeoutMs每 Job 設一樣,注意是 OS-level signal,不是 Promise.race
Horizon Failed Jobs UIDLQ browseUI + 手動 retryalert + 自動 triage 流程
failed() callbackDLQ insert handler進 failed 表時 trigger跨 service compensation
queue:retry-allbulk republish一行指令跟 dedup 表搭配(沒擋到會重複處理)
Bus::chain([...])sequential job失敗中斷後續補償邏輯(哪些已執行該 rollback)
WithoutOverlapping middlewarehandler-level lockatomic redis lockevent-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. 相關文章

本系列前置

本系列接下來

  • 篇 6 → EDA 端到端追蹤 — DLQ 是 audit 的重要拼圖之一

外部參考

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