這篇要回答的問題:broker 為什麼會把同一個 event 送給 consumer 兩次?這時候 handler 跑兩次會發生什麼?怎麼確保「無論 broker 送幾次,業務後果只發生一次」?

3 分鐘結論

  • 絕大多數 broker 是 at-least-once delivery:保證至少送一次,可能送 N 次
  • 解法:consumer 端用 processed_events 表當「處理過了嗎」的鎖,PK = (event_id, handler)
  • 跟篇 2 outbox 是雙保險:outbox 防漏(producer 沒送)、dedup 防重(broker 送 N 次)
  • 業界 EDA 核心 thesis:at-least-once delivery + idempotent consumer = effectively-once processing

這篇假設你知道

  • 篇 2 Outbox pattern ⸺ producer 端冪等的對稱
  • PK constraint / ON CONFLICT 基本概念(SQL 撞 PK 怎麼處理)
  • 不熟下面這些詞時 → #16 EDA 名詞速查at-least-once delivery / idempotency / ack / redelivery / effectively-once

完整 runnable demo:tools3455147/mq-event-driven-demo

完整 runnable demo + 程式碼:tools3455147/mq-event-driven-demo


1. 一個雙重夢魘的場景

凌晨團隊收到客訴:「為什麼我的訂單收到兩封確認信?以為被駭客盜刷了還報警。」

從 log 翻起來,發現兩種狀況同時發生

狀況 A:使用者連按兩下「下單」按鈕

1. 客戶按下單 → 第 1 個 HTTP request 送出
2. 網路慢,3 秒沒回應
3. 客戶以為失敗,又按一次 → 第 2 個 HTTP request 送出
4. 兩個 request 都成功
   → DB 有兩筆訂單 (不同 order_id)
   → broker 收到兩個 event
   → 每個 event 各觸發一封 email
   → 客戶收到 2 封信,扣兩次款,分兩筆訂單

狀況 B:broker 重投同一個 event

1. 客戶下單 → 1 筆訂單寫進 DB
2. broker 收到 event
3. email handler 開始跑、send email API 成功了
4. handler 還沒回 ack 給 broker,那一瞬間 process 被 K8s rolling update kill
5. broker 沒收到 ack,認定「失敗」,把 event 重投給另一個 worker
6. 另一個 worker 又跑了一次 send email
   → 客戶收到 2 封一模一樣的 confirmation(同 order_id)

兩種狀況長得很像根因不同

狀況 A狀況 B
觸發者client(使用者)broker
event_id不同(兩個 HTTP request 各自生 UUID)一樣(同個 event 被重送)
DB orders 表2 筆1 筆
解法篇 2 介紹過的 producer-side 冪等(client 帶 Idempotency-Key)本篇的 consumer-side dedup

兩個都要解,而且解法不一樣。下面 §2 講為什麼狀況 B 是 broker 的本性、§3 講兩層冪等怎麼配合。


2. 為什麼 broker 會重投:at-least-once delivery

新人最常見的誤解:「我 publish 一次、broker 就只送一次給 consumer」。

絕大多數 broker(RabbitMQ、Kafka、NATS、Redis Streams、SQS…)預設的 delivery 語意是 at-least-once:「保證至少送一次,但可能送 N 次」。

為什麼是 at-least-once

想像 broker 跟 consumer 之間的通訊:

[broker] ──── 1. send event ──────→ [consumer]
                                          │
                                          │ 2. handler 處理
                                          ↓
[broker] ←──── 3. ack ────────────── [consumer]

問題:broker 怎麼知道 consumer 真的處理完了?只能靠 step 3 的 ack。

  • 如果 step 1 失敗 → broker 重送(OK,沒副作用)
  • 如果 step 2 失敗(handler 拋 exception,沒 ack)→ broker 重送
  • 如果 step 3 失敗(handler 成功但 ack 沒送到)→ broker 也重送,但 handler 已經跑過一次了 🔥

第 3 種情況就是 at-least-once 的本質困難 ⸺ broker 沒辦法區分「handler 沒跑」跟「handler 跑了但 ack 沒送到」。選擇 at-least-once 保證「不會漏」、放棄「不會重」;選擇 at-most-once 反過來(保證不重複、放棄不漏)。production 大多選前者。

Exactly-once 為什麼幾乎沒人做

Kafka transactions、broker XA 之類的「exactly-once」機制存在,但代價:

  • throughput 下降數倍
  • 設定複雜、failure mode 很微妙
  • 只在「broker → broker」生效,到外部系統(DB / API)那一刻又退回 at-least-once

業界共識:**「at-least-once delivery + idempotent consumer = effectively-once processing」**比追 exactly-once delivery 划算太多。所以 production EDA 系統幾乎都選這條路。

真實的重投時機

不只 ack 丟失。實務上至少這些情境會觸發 broker 重投:

情境機制
Consumer 跑到一半 crash,broker 沒收到 ackbroker 等 timeout → 重投到別的 consumer
Consumer 處理成功但回 ack 時網路斷broker 認為失敗 → 重投
K8s rolling update kill consumer pod同上
Laravel Horizon 你按了 queue:retry-all所有 failed job 重投
Consumer rebalance(Kafka partition 重分配)重新分配到的 consumer 從上次 commit offset 重讀
Broker 升級 / 重啟,狀態恢復不完全已 ack 的訊息有少數會被重投

「至少一次」不是理論,是實際每天發生的事。團隊一定會撞到,沒做 dedup 就一定爆炸。


3. 兩層冪等:producer 端 vs consumer 端

回頭看 §1 的兩種狀況。兩層需要分別解:

client ──── HTTP ─── producer ──── publish ──── broker ──── deliver ──── consumer
   │                     │                          │                        │
   │ 連按兩下             │                          │ 重投同個 event_id        │
   │                     │                          │                        │
   ↓                     ↓                          ↓                        ↓
┌─────────────────────────────┐         ┌─────────────────────────────────────┐
│ 第 1 層:producer-side       │         │ 第 2 層:consumer-side               │
│ Idempotency-Key + outbox     │         │ processed_events 表 + PK 衝突       │
│ PK on event_id               │         │ on (event_id, handler)              │
└─────────────────────────────┘         └─────────────────────────────────────┘

第 1 層:producer 端用 Idempotency-Key

client 給 HTTP request 帶一個 Idempotency-Key header(通常是 UUID)。Server side 拿這個 key 當 event_id:

POST /events-outbox/rabbitmq
Idempotency-Key: 0abc... (client 自己生)
body: { "user_id": 1, "items": ["A"] }

→ server INSERT INTO outbox (event_id=0abc..., ...) ON CONFLICT DO NOTHING
→ 第二次同 key 進來 → ON CONFLICT 跳過,不會多進一筆
→ broker 也只會收到一個 event

這層解的是「client 重複送同個業務動作」。client 連按兩下、network retry 都會帶同個 key,server 用 PK 擋。

詳細實作在 篇 2 Outbox pattern §4 — outbox 表的 event_id UNIQUE 是這個 key 落地的地方。

第 2 層:consumer 端用 processed_events 表

即使 producer 端把 dedup 做完了,broker 仍然會把同一個 event_id 重投給 consumer N 次(§2 講的原因)。所以 handler 入口要再擋一層:

-- 進 handler 之前,check:「我這個 (event_id, handler) 處理過了嗎?」
SELECT 1 FROM processed_events WHERE event_id=$1 AND handler=$2;
 
-- 處理成功後,markDone:「記下這個 (event_id, handler) 處理過了」
INSERT INTO processed_events (event_id, handler, bus)
  VALUES ($1, $2, $3)
ON CONFLICT (event_id, handler) DO NOTHING;

PK on (event_id, handler) 而不是 event_id 因為同個 event 會被多個 handler 處理(email / inventory / analytics / recommender),每個 handler 各自記一筆。

為什麼雙層都要

讀者常問:「producer 端 outbox 已經保證 event 不重複了,consumer 為什麼還要 dedup?」

兩層的解法解的問題不同

重複來源例子producer outbox 擋得到嗎consumer dedup 擋得到嗎
Client 連按兩下同 user 不同 UUID❌(client 帶不同 key)❌(不同 event_id)
Client retry with 同 Idempotency-Keynetwork blip 重送✅ outbox PK 擋✅ 即使第二次進去也沒新 event
Broker 重投(ack 丟失)rolling update❌(broker 行為,producer 看不到)✅ dedup table 擋
Consumer crash 重啟OOM kill
Horizon queue:retry-all人工觸發

producer outbox 不夠,broker 跟 consumer 之間的重投只能在 consumer 端擋。 consumer dedup 不夠,因為 client 連按兩下會送出不同 event_id,dedup 表認不出來,要靠 producer 端 Idempotency-Key。

兩層各擋一邊。少任何一層都會漏


4. processed_events 表設計

最薄的可運作版本:

CREATE TABLE processed_events (
  event_id      UUID         NOT NULL,
  handler       TEXT         NOT NULL,
  bus           TEXT         NOT NULL,            -- demo 多 broker,記哪條 bus 來的
  processed_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  PRIMARY KEY (event_id, handler)
);

幾個設計動機:

為什麼 PK 是 (event_id, handler) 不是 event_id

EDA 通常一個 event fan-out 給 N 個 handler。如果 PK 是單 event_id,第二個 handler 進 INSERT 時會撞 PK 失敗。所以 PK 必須包含 handler name。

event_id=abc...
  ├── handler=emailHandler        → 一筆 row
  ├── handler=inventoryHandler    → 一筆 row
  ├── handler=analyticsHandler    → 一筆 row
  └── handler=recommenderHandler  → 一筆 row

某 handler 還沒處理完前,那 handler 的 row 不存在 → 重投時該 handler 會再跑。其他 handler 不受影響

為什麼不需要 status / attempts 欄位

最薄版本:只記「成功完成」這件事。失敗不記,下次重投時 isDone 仍 false,handler 會被再跑 → 重試。

Production 系統會擴充欄位記更多狀態(attempts、last_error、locked_at…)做更精細的狀態機,但 demo 用最小骨架展示核心 idiom:PK 衝突當原子鎖

為什麼用 SQL 表不用 Redis SETNX

Redis SETNX 也常被拿來當 dedup 鎖:

# 偽 code
if redis.set(f"dedup:{event_id}:{handler}", "1", nx=True, ex=86400):
    process()       # 第一次拿到鎖 → 處理
else:
    skip()          # 已經有人處理過 → 跳過

SETNX 比較快,但有個關鍵 trade-off:它不能跟業務 DB 同 transaction

business_db.transaction:
    INSERT orders ...
    INSERT processed_events ...   ← 用 SQL:跟業務同 commit,atomic
                                  ← 用 Redis:兩個系統,又是 dual-write,原問題復活

如果你的 handler 寫業務 DB,用 SQL 表跟業務 INSERT 同一個 transaction 是天然的 atomic 保證 — handler 寫進業務表跟「我已處理」這件事一起 commit 或一起 rollback

Redis SETNX 適合「handler 沒碰 SQL DB」的場景(純 call 外部 API、純算 metric)。處理業務寫入的 handler 用 SQL processed_events 表幾乎是業界標準。

💡 這個取捨在實作上的意思:dedup check + markDone 應該包在「跟業務寫入同 transaction」內。本系列 demo 為了清楚展示,dedup 跟業務寫入是分開的(safe-fanout 的 markDone 在 handler 完成後才寫),這在「業務寫入失敗但 handler 不 throw」這種邊角情境會微妙,production 真正嚴格的版本要把兩個 INSERT 合在一個 transaction。


5. Demo:重現「broker 重投時 dedup 擋下來」

把 demo 起來:

git clone https://gitlab.com/tools3455147/mq-event-driven-demo
cd mq-event-driven-demo
docker compose -f infra/docker-compose.yml up -d --build

5.1 正常路徑:第一次處理

下一筆 event 走 safe path(含 dedup):

curl -X POST 'http://localhost:8000/events-outbox/rabbitmq' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 7f8a0000-0000-0000-0000-000000000001' \
  -d '{"user_id":1,"items":["A"]}'

幾百毫秒後查 audit:

curl -s http://localhost:8000/audit/event/7f8a0000-0000-0000-0000-000000000001 | jq '.processed'

回應:

[
  { "handler": "emailHandler",       "bus": "rabbitmq", "processed_at": "2026-05-27T..." },
  { "handler": "inventoryHandler",   "bus": "rabbitmq", "processed_at": "..." },
  { "handler": "analyticsHandler",   "bus": "rabbitmq", "processed_at": "..." },
  { "handler": "recommenderHandler", "bus": "rabbitmq", "processed_at": "..." }
]

4 個 handler 各一筆。emails_sent 表也只有一筆。一切正常。

5.2 模擬 broker 重投:replay 同個 event

/demo/replay-event/:id 把指定 event 重新 publish 到 safe topic ⸺ 模擬「broker 重投同個 event_id」這個動作:

curl -X POST 'http://localhost:8000/demo/replay-event/7f8a0000-0000-0000-0000-000000000001'

回應:

{
  "event_id": "7f8a...",
  "status": "replayed",
  "note": "event re-published to broker; safe fanout should dedup it (check /audit/event/:id)"
}

幾秒後再看 audit:

curl -s http://localhost:8000/audit/event/7f8a0000-0000-0000-0000-000000000001 | jq '.processed'

仍然只有 4 筆(每 handler 一筆,沒翻倍)。emails_sent 表也仍只有 1 筆。

幕後發生的事:

broker 重投同個 event_id
    ↓
safe-fanout 對每個 handler 跑 isDone(event_id, handler)
    ↓ 4 個 handler 都查到 row 存在
log 寫 'dedup.skip' step
    ↓
handler 不執行、不寫業務表

看 log:

docker compose -f infra/docker-compose.yml logs backend-node | grep dedup.skip | head -4
# INFO dedup.skip step=dedup.skip event_id=7f8a... bus=rabbitmq handler=emailHandler
# INFO dedup.skip step=dedup.skip event_id=7f8a... bus=rabbitmq handler=inventoryHandler
# ... (4 個)

5.3 同個 Idempotency-Key 連送兩次

驗證 producer 端那層(第 1 層):

KEY=7f8a0000-0000-0000-0000-000000000002
for i in 1 2; do
  curl -X POST 'http://localhost:8000/events-outbox/rabbitmq' \
    -H 'Content-Type: application/json' \
    -H "Idempotency-Key: $KEY" \
    -d '{"user_id":1,"items":["A"]}'
  echo
done

兩次 request 都回 200,但 outbox 表只有 1 筆,broker 只收到 1 個 event,下游全部只跑 1 次:

curl -s "http://localhost:8000/audit/event/$KEY" | jq '.outbox.count, .processed | length'
# 1
# 4    (4 個 handler,沒翻倍)

兩層各自把不同來源的重複擋掉。這就是「effectively-once processing」的具體形狀


6. Effectively-once 是什麼意思

「at-least-once delivery + idempotent consumer = effectively-once processing」這句話是 production EDA 的核心 thesis。拆開講:

  • at-least-once delivery:broker 保證至少送一次,可能 N 次。這是 broker 的客觀性質
  • idempotent consumer:handler 跑 N 次跟跑 1 次的業務結果一樣。這是你設計出來的性質
  • effectively-once processing:使用者觀感是「動作只發生一次」(信只寄一封、款只扣一次)

對使用者來說「動作只發生一次」就夠了。業界放棄追「broker 只送一次」這個技術上很貴的目標,改追「業務後果只發生一次」這個 application 層級可達的目標

實務上要做到 effectively-once 需要:

  1. 每個 event 有穩定的 event_id(producer 給,不要 server 自動生)
  2. broker 重投時 event_id 不變(broker 預設行為)
  3. handler 入口前 dedup check(本篇講的 processed_events 表)
  4. handler 內 SQL 也要冪等篇 4a 講,是「handler 入口冪等」的雙保險)

3 跟 4 都要 ⸺ 因為 3 擋不到「handler 跑到一半 crash」這種情境(已寫業務 row 但 markDone 還沒寫)。詳細在篇 4a;handler 內並發控制(event 亂序、跨 row 鎖)在 篇 4b


7. 給 Laravel 讀者的術語對譯(可選)

📍 沒用過 Laravel 的讀者可整節跳過,不影響後面理解。

Laravel queue / Horizon 在 dedup 這層幾乎沒幫

Laravel 概念本篇對應Laravel 幫你做了你還要自己加
dispatch(Job::class)publish eventbroker 連線 + 序列化 + worker 拉dedup 完全沒做
WithoutOverlapping middleware同 job 不能同時跑atomic lock by job class不解 event-level dedup(同 job class 不同 event 還是會跑 N 次)
Horizon retry / queue:retry-all重投失敗 jobUI + 觸發retry 之後就是 at-least-once,沒 dedup 就是真的多跑一次
ShouldBeUnique interface同 unique key job 不能進 queue進 queue 之前用 Redis lock 擋處理「跨時間視窗」沒解(lock 過期了還是會重複)

Laravel ShouldBeUnique 看起來像 dedup 但解的是不同問題:它擋的是「同個 unique key 的 job 不能同時或在某個時間視窗內進 queue」。但 broker 重投同一個 job instance 它擋不到 ⸺ 因為 broker 重投時 job 不是「進 queue 再被擋」,是直接 deliver 給 worker。

結論:Laravel 在 application-level dedup 這層要自己加。最直觀的做法:

// 在每個 ShouldQueue job 的 handle() 開頭:
public function handle() {
    if (ProcessedEvent::where([
        'event_id' => $this->eventId,
        'handler'  => static::class,
    ])->exists()) {
        return; // dedup skip
    }
 
    DB::transaction(function () {
        // 業務寫入
        Order::create([...]);
        // markDone 跟業務同 transaction
        ProcessedEvent::create([
            'event_id' => $this->eventId,
            'handler'  => static::class,
        ]);
    });
}

Eloquent 的 firstOrCreate 或直接 SQL INSERT ... ON CONFLICT DO NOTHING 都行。重點是 dedup INSERT 跟業務寫入要在同一個 transaction


8. 反思

寫這篇遇到最難解釋的概念是「為什麼 outbox + dedup 不是重複」。新人直覺:「outbox 不是已經保證 event 只進 broker 一次了嗎?consumer 為什麼還要 dedup?」

答案不在「outbox 保證了什麼」,而在「outbox 沒保證什麼」:outbox 保證的是「producer 不會 dual-write 失敗造成漏掉 event」,但沒保證「broker 不會把已送出的 event 重投」。後者是 broker 的本性,只能在 consumer 端解。

兩層的關係:

  • outbox 防漏(producer 寫了業務 DB 但 broker publish 失敗)
  • dedup 防重(broker 重投 / 跨服務 retry / 人工 queue:retry-all)

漏跟重是兩件事,要分別解。

最後一個問題給看完這篇的你:

你現在系統的 listener,handler 入口有沒有 dedup?如果今天有人按 queue:retry-all 把過去 24 小時的 failed job 全部重投,你的 email handler 會不會把同一封信寄 100 次?


9. 相關文章

本系列前置

本系列接下來

  • 篇 4a → DB 寫入冪等性:6 種 SQL idiom(dedup 的雙保險)
  • 篇 4b → 事件亂序 + 跨 row 鎖:並發控制
  • 篇 5 → Retry 跟 DLQ — handler 失敗該怎麼處理
  • 篇 6 → EDA 端到端追蹤 — processed_events 表是 audit 的重要拼圖之一

外部參考

Runnable demo: tools3455147/mq-event-driven-demo — 本篇 demo 在 src/dedup/ 模組跟 domain/safe-fanout.ts,hands-on 用 /events-outbox/:bus (with Idempotency-Key) + /demo/replay-event/:id 重現 設計文件: docs/architecture.md