這篇要回答的問題:我已經有 Laravel queue + event + listener 在跑了,這跟「真正的 EDA」差別在哪?production 出狀況時會踩到什麼坑?接下來 5 篇文章各解一個坑,這篇先把地圖攤開。
3 分鐘結論
- Framework 內建的 event + queue + listener 把「EDA 入門 80%」幫你做完,但 production 還有 4 個坑:dual-write、重複處理、handler 失敗、debug 困難
- 後 5 篇各解一個坑(outbox / dedup / retry+DLQ / audit)
- 這 4 個坑不是「Laravel 做得不夠好」,是設計問題,任何 framework 都會撞到
這篇假設你知道
- 寫過 web 後端(Controller / Service / DB 這層概念)
- 用過 framework 內建的 queue 或 event 機制(任何語言都行)
- 不熟下面這些詞時 → #16 EDA 名詞速查:broker / event / handler / async
讀這篇不需要有 EDA 經驗。完整 runnable demo + 程式碼:tools3455147/mq-event-driven-demo。
1. 一個常見的場景
假設你做了個訂單系統。使用者點「下單」之後,後端要做這幾件事:
- 寫入訂單到 DB
- 寄送確認信
- 扣庫存
- 通知推薦系統「這個 user 買了什麼」(給之後推薦演算法用)
- 推一筆事件到 analytics
最直覺的 MVC 寫法:
// Laravel
class OrderController {
public function create(Request $r) {
$order = Order::create($r->all());
EmailService::sendConfirmation($order);
InventoryService::deduct($order);
RecommenderService::signal($order);
AnalyticsService::track('order_created', $order);
return response($order, 201);
}
}跑得起來,需求也滿足。直到撞到下面這些情境:
- email service 偶爾 timeout,整個
create()拖 30 秒甚至 throw → 使用者看到「下單失敗」 - 推薦系統升級重啟那 5 分鐘 → 訂單全部 fail
- 黑五流量飆 10 倍 → analytics service 撐不住 → 拖累訂單主流程
於是有人說:「不要同步呼叫,改成 publish event,listener 在 background 跑」。
// 「event-driven」版本
class OrderController {
public function create(Request $r) {
$order = Order::create($r->all());
event(new OrderCreated($order)); // ← 推 event 就 return
return response($order, 201);
}
}
// listeners (each is ShouldQueue, runs async)
class SendEmailOnOrder implements ShouldQueue { ... }
class DeductInventoryOnOrder implements ShouldQueue { ... }
class NotifyRecommenderOnOrder implements ShouldQueue { ... }
class TrackOrderInAnalytics implements ShouldQueue { ... }看起來「event-driven 了」。但這是「半 EDA」,撐不到 production。下面三節我們分三步講清楚:為什麼這個改寫是對的方向(§2)、把術語先對齊(§3)、然後再說「半 EDA」哪裡還沒做完整(§4)。
2. 為什麼這時候會想要 async
把同步 4 個 service call 改成 publish event,這個方向是對的。底層三個動機,業界共識:
動機 1:降低使用者等待
下單成功的本質是「訂單寫進 DB」。寄信、扣庫存、推薦系統訊號這些是副作用 — 對使用者來說,這些不該決定 HTTP response 什麼時候回。
把副作用挪出主流程後,使用者拿到 201 created 的時間從「DB write + 4 個 service call」縮成「DB write + publish event」。延遲少一個量級。
動機 2:失敗隔離
純 MVC 同步:4 個 service 任何一個 throw → 整個 controller 500。email 服務 5 分鐘掛機 = 訂單 5 分鐘沒人下得了。這通常不合理,因為訂單跟寄信業務上沒那麼強耦合。
EDA:email handler 失敗只影響 email handler 自己。訂單照常寫進去,handler 之後重試 / 進 DLQ / 人工處理都是後話。
動機 3:削峰
黑五當天 HTTP 入口流量飆 10 倍。Downstream(email service / 推薦系統)通常沒有 10 倍 capacity。
同步串接時,downstream 被打掛 = 訂單入口跟著掛。
EDA:訂單 publish event 到 broker 就回,broker 把流量暫存住。Downstream consumer 按自己節奏拉,broker queue depth 上升但訂單入口不受影響。Broker 變成 buffer,把瞬時 burst 平緩成持續流。
這 3 個動機任何一個成立就值得改成 async。3 個都不成立的場景(例如純內部 tool、流量低、所有 service 都很穩、user 等多久沒差),維持 MVC 同步反而簡單。本系列不假設 EDA 一定比 MVC 好,只討論「決定要用 EDA 之後該怎麼做扎實」。
要不要用 EDA 這個決策本身,見 #18 MVC vs Event-driven Architecture。
3. Newcomer primer:先把術語對齊
繼續往下講之前先定義一下,這個系列每篇都會引到的詞:
Broker(訊息中介)
「broker」是訊息的中介者。Producer 把 event 送進去,consumer 從另一端把 event 拿出來。Producer 不知道 consumer 是誰、有幾個。
常見的 broker:
- Redis(內建的 list / pub-sub / stream)
- RabbitMQ(專門的 message queue)
- Kafka(大規模 event log)
- NATS JetStream(cloud-native 輕量)
Laravel 的 queue 配 QUEUE_CONNECTION=redis 就是用 Redis 當 broker。所以你已經在用 broker 了,只是 framework 幫你包好。
Event vs Message vs Job
技術上有微妙差別,但實務上常混用:
- Event:「某件事發生了」(過去式:order_created、payment_completed)
- Message:broker 裡傳遞的資料單位(不論內容是 event 還是命令)
- Job:通常指「要做某件事」(命令式:send_email、deduct_inventory)
Laravel event(new OrderCreated()) 是 event,dispatch(new SendEmailJob()) 是 job — 兩者最終都是 broker 裡的 message。**本系列大多用「event」**統一說。
Handler / Listener / Consumer
收到 event 後執行業務邏輯的程式碼。Laravel 叫 Listener、Sidekiq 叫 Worker、Kafka 圈叫 Consumer,本系列統一叫 handler。
Synchronous(同步)vs Asynchronous(非同步)
- 同步:A 呼叫 B,A 一直等到 B 回來。
EmailService::send()直到郵件實際寄完才 return。 - 非同步:A 把工作丟給 B,A 不等 B、立刻回去做別的。
event(new OrderCreated())推到 broker 就 return;handler 在另一個 process 跑。
EDA 的「event-driven」就是用非同步的方式串組件。
MVC / 真 EDA / 半 EDA
| 模式 | producer | consumer | 何時撞牆 |
|---|---|---|---|
| 純 MVC 同步 | Controller 直接 call 4 個 Service | — | downstream 慢 / 失敗時主流程被拖死 |
| 真 EDA | Controller event(...) 推送 | 多個 handler 非同步處理 | broker 掛了、handler 失敗、重複處理(這 4 個就是本系列要解的) |
| 半 EDA | event(...) + ShouldQueue listener 但沒做後續保護 | — | 看起來在跑,產品環境某天爆炸 |
本系列假設你已經到「半 EDA」這格,要往「真 EDA」走。
4. 「半 EDA」是什麼意思 — 上半做了下半沒做
回頭看 §1 結尾說的「半 EDA」。「半」不是貶意,是字面上的「只做了上半,下半還沒做」:
「上半」做到了 ── §2 講的 3 個動機(降低等待、失敗隔離、削峰)都成立。把同步 4 個 service call 拆成 producer + consumer 之後,主流程不再被 downstream 拖死。Framework(Laravel ShouldQueue 之類)幾行 code 就把這層包好了。
「下半」沒做 ── production 還需要至少 4 件事 framework 不會幫你:
- 確保 event 真的送出去(broker 掛了怎麼辦)
- 確保只處理一次(broker 必然會重投)
- 確保失敗能恢復(transient 該重試、permanent 該 park)
- 確保出狀況能追蹤(事件流動是 traceable 的,但要設計)
這 4 件 = 下面 4 小節介紹的 4 個 production 問題 = 本系列篇 2-6 各篇要解決的東西。
⚠️ 不是「Laravel 做得不夠好」。Framework 把入門 80% 包好了 — 這已經非常厲害,省下大量初期工作。但 production reliability 那 20% 是設計問題而非框架問題,需要你自己設計、跟業務語意對齊。
把場景拉回那個訂單系統。你已經改成 event(new OrderCreated()) + 5 個 queued listener。流量上來、團隊長大、業務複雜化之後,這 4 個問題會陸續找上門:
問題 1:DB 寫了但 event 沒推出去(producer dual-write)
$order = Order::create(...); ← DB transaction COMMIT 了
event(new OrderCreated($order)); ← 這行還沒跑完,process crash / Redis 連線斷
訂單存進了 DB(客人看得到自己的訂單),但 listener 沒被 trigger(沒有寄信、沒有扣庫存)。永遠不一致。
更陰險的版本:你把訂單寫入跟 event 推送都成功了,但兩者不在同一個 transaction,所以中間任何一刻 crash 都可能造成不一致。
→ 解法:Outbox pattern。篇 2 講。
問題 2:同一個 event 被處理兩次(duplicate processing)
很多新手以為「我 publish 一次、broker 就只送一次」。錯。
絕大多數 broker 預設的 delivery 語意是 at-least-once:「保證至少送一次,但可能送 N 次」。N > 1 的時機例如:
- consumer 處理到一半 crash,broker 沒收到 ack → 重投
- consumer 處理成功但回 ack 時網路斷了 → broker 認為失敗 → 重投
- Horizon 上線時你按了
queue:retry-all,所有 failed job 都被重新投遞 - client 重複送同樣的 HTTP request(網路慢、按兩下、retry policy)
如果 listener 不冪等,客人會收到兩封一樣的 email、庫存被扣兩次、訂單金額被算兩次。
→ 解法:consumer 端冪等 (dedup)。篇 3 講「為什麼一定會重投」+「怎麼用 dedup 表把效果變 effectively-once」。
問題 3:handler 失敗了該怎麼處理(retry vs DLQ)
兩種失敗模式:
- 暫時失敗(transient):寄信 API 偶爾 504、外部 DB 暫時 locked。重試幾次大概率會成功。
- 永久失敗(permanent):event payload 結構壞了、handler 拋 NullPointerException、業務規則改了所以舊 event 無效。重試 100 次也救不回。
Laravel 預設行為:失敗就丟 failed_jobs table。但這只解了一半 —
- 該重試幾次?太少救不回 transient,太多卡住一個壞訊息浪費資源
- 不同 listener 該有不同重試政策(email 重試 3 次 OK、payment 不該自動重試)
- failed jobs 進到那張表之後怎麼處理?人工點 Horizon UI 的 retry?大量失敗時怎辦?
→ 解法:retry with backoff + DLQ + per-handler policy。篇 5 講。
問題 4:「這筆事件跑到哪了?」debug 噩夢
客服轉來「我訂單付了但沒收到 email」,給你一個 event_id。你怎麼追?
MVC 時代:一個 stack trace 從 controller 串到所有 service,clear。EDA 時代:
- 訂單在 DB 嗎?yes
- event 推進 broker 了嗎?…broker 沒 retention 你看不到
- email listener 跑過了嗎?翻
failed_jobs找有沒有這個 id?翻 Horizon UI? - 翻完一個小時,發現 listener 跑了但邏輯誤判 condition 沒實際發信
EDA 的「事件流動」本質上是 traceable 的,但需要設計,不是免費。
→ 解法:3 表 join + event 護照欄位。篇 6 講輕量級 audit pattern + 何時才該上 OpenTelemetry。
順帶一提:cron / 排程跟這系列的關係
很多 codebase 同時有三條 async path:
- Queue / Event-driven(本系列焦點)— on-demand 觸發,事件發生才 trigger
- Cron / 排程(不在本系列)— time-based 觸發,固定時間 trigger
- MVC 同步(#18 講)— 跟 request 綁定
三者不互斥但常打架。例如「找出 30 分鐘前 pending 的訂單催繳」可以用 cron 跑(每 5 分鐘 SELECT pending orders),也可以用「delayed event」(下單時 schedule 30 分鐘後 fire)。哪個比較好取決於語意。
這系列只解 EDA 內部的 4 個 reliability 問題。三條 async path 的歷史動機 / 邊界劃法是另一個議題,會用一篇獨立基礎文(規劃中的 #15-async-primitives)回頭講。所以如果你看完這系列覺得「我系統還有 cron 怎麼整合」沒被回答,那是預期的 — 那篇出來時會接這層問題。
5. 系列地圖
整個 EDA pipeline 從 producer 到 consumer 到 handler,6 篇文章各負責一段:
producer 端 ───── broker ───── consumer 端 ────── handler 內部
──────────── ──────────── ─────────
HTTP client DB writes
│ │
│ client retry dual-write at-least-once update / insert
│ (same payload) (DB + broker) redelivery /delete / lock
↓ ↓ ↓ ↓
┌─────────────────┐ ┌─────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ 篇 3 Idempotency │ │ 篇 2 Outbox │ │ 篇 3 Dedup table │ │ 篇 4a SQL idiom │
│ key (producer │ │ (in-business │ │ (handler 入口前 check) │ │ + 篇 4b 並發控制 │
│ 端冪等) │ │ transaction) │ │ │ │ (handler 內寫入) │
└─────────────────┘ └─────────────────┘ └────────────────────────┘ └────────────────────────┘
│
│ handler 失敗
↓
┌────────────────────────┐
│ 篇 5 Retry + DLQ │
│ (transient / permanent)│
└────────────────────────┘
橫向 cross-cut:篇 6 Audit & observability ─ event_id 串起 3 reliability 表 + 業務表
每篇都會:
- 從一個常見場景開場
- 講 pattern 解什麼問題
- 用 demo 驗證行為(可以自己
docker compose up跑) - 對熟 Laravel 的讀者另附 sidebar 講「框架幫你做了哪部分、什麼還要自己做」
6. 怎麼跟這個系列互動
推薦讀法
如果你想快速建立 mental map:
- 讀這篇(你在這了)
- 跳到 篇 6 Audit — 從「事後怎麼 debug」反推系統要記什麼
- 然後篇 2、3、4、5 依序
如果你想照場景找解:直接跳對應的篇。每篇開頭都有「核心問題」一句話。
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 --build5 個 broker(RabbitMQ / Kafka / NATS / Redis / 內建 in-memory)+ Postgres + Node.js backend 一次起來。每篇都會用 curl 操作具體 endpoint 重現問題與解法。
我會盡量避免的
- 「pattern xxx 一定要用」 — 沒有銀彈,本系列每篇都會列「什麼時候不要用」
- 「正確答案」假象 — production 都是 trade-off,文章會把取捨講清楚
- 過度抽象 — 範例都用 e-commerce 訂單系統當底,因為大家都看得懂
7. 給 Laravel 讀者的術語對譯(可選)
📍 沒用過 Laravel 的讀者可整節跳過,不影響後面理解。下面這節是給有 Laravel 經驗的讀者一份「框架幫你做了什麼、你還要自己做什麼」的對照表。
💡 如果你寫過 Laravel:上面那些 pattern 其實你已經部分用過,只是不知道有名字。先對齊一下:
| Laravel 概念 | 本系列稱呼 | Laravel 幫你做了 | 你還要自己做 |
|---|---|---|---|
QUEUE_CONNECTION=redis | broker | broker 連線 + 序列化 | broker selection、observability |
event(new XCreated($x)) | publish event | event 物件、listener registry | producer-side 可靠性(outbox) |
ShouldQueue listener | async handler | 推到 queue、worker 從 queue 拉 | consumer-side 冪等、retry policy |
dispatch(new Job()) | publish event(命令式) | 同上 | 同上 |
| Horizon Failed Jobs UI | DLQ browse | UI、手動 retry button | alert、批次 triage、永久失敗的補償流程 |
tries, backoff() 屬性 | retry policy | 每 job 設定 | per-handler 而非 per-job 的設計(本系列演示) |
DispatchAfterCommit | outbox 半成品 | tx commit 後才 dispatch | broker 掛掉時的補救(本系列 outbox 完整版) |
| Laravel Telescope | audit(同 process) | 同 process trace | 跨 worker / 跨 service trace |
重點:Laravel 把「EDA 的入門 80%」內建好了 — 你 event(...) + ShouldQueue 就 work。剩下 20% 是 production-grade reliability,框架沒做,但生產環境一定撞到。這個系列就在補那 20%。
8. 反思
寫這系列的動機很簡單:Laravel / Rails / Django 把 event + queue + listener 包得很好,但「production 該用什麼姿勢」這層市面上沒整理過。新手一搜「Laravel event」,找到的都是「怎麼用 event」「怎麼設定 listener」,很少有人講「production 撞到問題該怎麼補」。
結果是:很多團隊用了 EDA 但用得很淺,撐到流量大或業務複雜就翻車,然後一次性導入 Kafka + saga + OpenTelemetry 過度補償,付出的學習成本跟運維成本都遠超實際需要的 leverage。
我寫這系列想達到的:
- 第一次看到 EDA 的人,讀完知道「為什麼要用、什麼時候用、什麼時候不用」
- 已經有 Laravel queue 在跑的人,讀完知道「哪些坑我已經踩過了沒處理、哪些其實框架幫我蓋了一半」
- 想升級到專門 broker(Kafka、RabbitMQ、NATS)的人,讀完知道「升級的具體信號是什麼,不是『大家都用所以我也要用』」
最後一個問題給看完這篇的你:
你目前的系統,有沒有可能正處在「半 EDA」狀態 — 用了 event + queue 但沒做後續保護?4 個問題你會踩到哪幾個?
9. 相關文章
前置(如果你還沒讀過):
- #14 RabbitMQ vs Kafka vs NATS vs Redis Streams — 怎麼選 broker
- #18 MVC vs Event-driven Architecture — 要不要用 EDA
本系列其他篇:
- 篇 2 → Outbox pattern: producer 端 dual-write 怎麼解
- 篇 3 → Consumer 端冪等性: 用 dedup 把 at-least-once 變 effectively-once
- 篇 4a → DB 寫入冪等性:6 種 SQL idiom
- 篇 4b → 事件亂序 + 跨 row 鎖:handler 內並發控制
- 篇 5 → Retry 跟 DLQ: handler 失敗的兩種解法
- 篇 6 → EDA 端到端追蹤: 3 表 join vs OpenTelemetry
Runnable demo: tools3455147/mq-event-driven-demo — MIT licensed
設計文件: docs/architecture.md