這篇要回答的問題:我已經有 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. 一個常見的場景

假設你做了個訂單系統。使用者點「下單」之後,後端要做這幾件事:

  1. 寫入訂單到 DB
  2. 寄送確認信
  3. 扣庫存
  4. 通知推薦系統「這個 user 買了什麼」(給之後推薦演算法用)
  5. 推一筆事件到 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 的 queueQUEUE_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

模式producerconsumer何時撞牆
純 MVC 同步Controller 直接 call 4 個 Servicedownstream 慢 / 失敗時主流程被拖死
真 EDAController event(...) 推送多個 handler 非同步處理broker 掛了、handler 失敗、重複處理(這 4 個就是本系列要解的
半 EDAevent(...) + 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

  1. 讀這篇(你在這了)
  2. 跳到 篇 6 Audit — 從「事後怎麼 debug」反推系統要記什麼
  3. 然後篇 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 --build

5 個 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=redisbrokerbroker 連線 + 序列化broker selection、observability
event(new XCreated($x))publish eventevent 物件、listener registryproducer-side 可靠性(outbox)
ShouldQueue listenerasync handler推到 queue、worker 從 queue 拉consumer-side 冪等、retry policy
dispatch(new Job())publish event(命令式)同上同上
Horizon Failed Jobs UIDLQ browseUI、手動 retry buttonalert、批次 triage、永久失敗的補償流程
tries, backoff() 屬性retry policy每 job 設定per-handler 而非 per-job 的設計(本系列演示)
DispatchAfterCommitoutbox 半成品tx commit 後才 dispatchbroker 掛掉時的補救(本系列 outbox 完整版)
Laravel Telescopeaudit(同 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. 相關文章

前置(如果你還沒讀過)

本系列其他篇

  • 篇 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