這篇要回答的問題:EDA 系統的事件流動「理論上 traceable 但實際上沒人 trace 得到」⸺ event_id 在 broker 走了哪些路徑?某個 handler 跑過了沒?business state 在這個 event 之後變成什麼樣?這篇用 application-level audit 解最常見的 80%、剩下 20% 才談 OpenTelemetry。

3 分鐘結論

  • EDA 比 MVC 難 debug:stack trace 走不到 broker 那層,事件流動跨多個 process
  • application-level audit = outbox + processed_events + dlq + 業務表用 event_id join,~100 行 code 解 80% 場景
  • correlation_id / causation_id 串多 event 鏈,audit endpoint 就能看完整 saga
  • OpenTelemetry 是 20% 場景的進階版(跨 service / performance breakdown),不要預先上

這篇假設你知道

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


1. 一個 debug 噩夢的場景

客服轉來客訴:「我下單 30 分鐘了,付款也成功,但沒收到 confirmation email。」

對方提供了訂單 number。你開始追:

🤔 訂單在 DB 嗎?
  → 查 orders 表 → 有
🤔 event 推到 broker 了嗎?
  → broker 沒 retention,看不到歷史
  → 看 outbox 表 → status='sent',OK 是推出去了
🤔 email handler 跑過了嗎?
  → 看 processed_events 表 → emailHandler 有 row,標記成功了
🤔 emails_sent 表呢?
  → 有 row,handler 確實有 INSERT
🤔 那為什麼客人沒收到信?
  → 翻 handler log 半小時
  → 找到一行:email service API 回 200 但實際把信送到 spam folder
  → SMTP service 配置問題

最後 30 分鐘解掉,但這個過程很

  • 翻了 5 個地方(DB orders / DB outbox / DB processed_events / DB emails_sent / handler log)
  • 沒有「同個 event_id 一次看全部」的入口
  • 如果 dlq 也要看就是第 6 個地方
  • 如果跨 handler 有 chain(A handler 觸發 B event 觸發 C handler)⸺ 翻倍

問題:EDA 的「事件流動」本質上 traceable,但需要設計才能 trace。沒設計時,每次 debug 就翻幾個小時。

下面 §3 講最低成本的解法 — 用 SQL JOIN 把 4-5 張表一次看完。


2. 為什麼 EDA 比 MVC 難 debug

MVC 同步呼叫的世界:

HTTP request 進來
  ↓ controller
  ↓ service A.call()
  ↓ service B.call()
  ↓ service C.call()
  ↓ return response

任何一個 service 出錯,stack trace 從 controller 一路串到出錯點。看一個 trace 看完。Laravel Telescope 把 SQL query / event / mail / job 全部記到一筆 request 上⸺ 同 process 內什麼都看得到。

EDA 的世界:

HTTP request → controller → publish event → 回 200
                                  │
                          ============(process 邊界)
                                  │
                          broker (跨 broker 沒 stack)
                                  │
                          ============(process 邊界)
                                  │
worker process 1 收 event → handler A → 寫 DB
worker process 2 收 event → handler B → call API → throw → DLQ
worker process 3 收 event → handler C → 寫 DB

每個 process 各自有 stack trace,stack trace 走不到 broker 那層。你看 worker 1 的 log 看不到 worker 2 為什麼掛。你看 controller log 看不到下游 handler 跑了什麼。

EDA 換來的 decoupling 是有代價的:跨 process 的可見性要自己設計,沒有「免費的 stack trace」可以看。

Distributed tracing 的解法

業界對「跨 process trace」有一套標準 ⸺ OpenTelemetry (OTel)。每個 request / event 帶一個 trace_id,跨 process call 時 propagate 出去,最後在後端(Jaeger / Tempo / Datadog)拼接成完整 span tree。

但 OTel 是大砲:要架 collector、agent、storage backend、UI、改 application code 加 instrumentation。對中小型系統來說常常 overshoot

下面 §3 講「輕量版 audit」⸺ 不上 OTel,用 application 自己的 reliability 表 + 一張 audit endpoint 解掉 80% 場景。


3. 輕量 audit:3 reliability 表 + 業務表 join

整個 EDA pipeline 跑完,DB 內留下這些痕跡:

event_id 串起:
  outbox            ─ producer 寫了沒、什麼時候 publish 出去
  processed_events  ─ 哪些 handler 標記完成了
  dlq               ─ 哪些 handler 永久失敗了
  business tables   ─ 業務狀態變成什麼樣(orders / emails_sent / inventory / ...)
  order_status_changelog (or 其他 history 表) — 業務狀態變遷史

把這些表用 event_id 當 join key串起來看,就拿到「這個 event 完整生命週期」的視圖。

Audit endpoint 結構

GET /audit/event/:id

Returns: {
  outbox: {
    rows: [{ status, attempts, sent_at, last_error }],
    count: 1
  },
  processed: [
    { handler: 'emailHandler', bus: 'rabbitmq', processed_at: '...' },
    { handler: 'inventoryHandler', ... },
    ...
  ],
  dlq: [
    { handler: 'paymentHandler', error: '...', attempts: 3, failed_at: '...' }
  ],
  business: {
    orders: { id, status, version, created_at, updated_at },
    emails_sent: [{ user_id, sent_at }],
    inventory_movements: [...],
    ...
  },
  status_history: [
    { from: 'pending', to: 'paid', changed_at: '...' },
    ...
  ]
}

一個 endpoint、一個 event_id、所有相關 row 全進來

Demo 跑一次

# 先建一筆 event
EVENT_ID=$(curl -X POST http://localhost:8000/demo/place-order \
  -H 'Content-Type: application/json' \
  -d '{"user_id":1,"items":["A"]}' | jq -r '.event_id')
 
# 等 1 秒讓 outbox worker + handlers 跑完
sleep 1
 
# 一次拿到完整 audit
curl -s "http://localhost:8000/audit/event/$EVENT_ID" | jq

回應大致長這樣(節錄):

{
  "event_id": "0a4f...",
  "outbox": {
    "count": 1,
    "rows": [{
      "bus": "rabbitmq",
      "topic": "safe",
      "status": "sent",
      "attempts": 0,
      "sent_at": "2026-05-28T..."
    }]
  },
  "processed": [
    { "handler": "emailHandler",       "processed_at": "..." },
    { "handler": "inventoryHandler",   "processed_at": "..." },
    { "handler": "analyticsHandler",   "processed_at": "..." },
    { "handler": "recommenderHandler", "processed_at": "..." }
  ],
  "dlq": [],
  "business": {
    "orders": { "status": "pending", "version": 1, "created_at": "..." },
    "emails_sent": [{ "user_id": 1, "sent_at": "..." }],
    "inventory_movements": [{ "items": ["A"], "moved_at": "..." }]
  }
}

「客人為什麼沒收到信」要追時,這個一個 endpoint 看完:outbox 有寫、broker 收到了、handler 跑完了、emails_sent 也有 row。剩下「為什麼信沒到 inbox」就是 SMTP / spam folder 的問題,不是 EDA 的問題。

Implementation:4 個 SQL JOIN

audit endpoint 內部只是把幾張表分別查再組裝:

async function auditEvent(eventId: string) {
  const [outbox, processed, dlq, orders, emails, ...] = await Promise.all([
    query(`SELECT * FROM outbox WHERE event_id = $1`, [eventId]),
    query(`SELECT * FROM processed_events WHERE event_id = $1`, [eventId]),
    query(`SELECT * FROM dlq WHERE event_id = $1`, [eventId]),
    query(`SELECT * FROM orders WHERE event_id = $1`, [eventId]),
    query(`SELECT * FROM emails_sent WHERE event_id = $1`, [eventId]),
    // ... 其他業務表
  ]);
  return { outbox, processed, dlq, business: { orders, emails, ... } };
}

不到 100 行 code。Index 都在(event_id 是 PK / UNIQUE / 有 idx),查詢成本低。


4. Event 護照欄位:correlation_id + causation_id

到目前為止 audit 都以 event_id 為單位。但 EDA 有個更高層的 unit:「一條業務鏈」可能跨多個 event

例:訂單流程 ⸺

event A: order.created     (id=eA, correlation_id=eA)
event B: payment.captured  (id=eB, correlation_id=eA, causation_id=eA)
event C: inventory.deducted (id=eC, correlation_id=eA, causation_id=eB)
event D: email.queued      (id=eD, correlation_id=eA, causation_id=eA)
event E: shipping.created  (id=eE, correlation_id=eA, causation_id=eB)

4 個欄位用法:

欄位意義範例
id本 event 的唯一 ideA / eB / eC / …
correlation_id這條業務鏈的 root id(所有相關 event 共用)eA(永遠是業務鏈的第一個 event 的 id)
causation_id是「哪個 event 觸發我」eB 的 causation_id 是 eA,eC 的是 eB
schema_versionevent payload 的 schema 版本,給降級 / forward compat 用”1.0” / “2.1” / …

Correlation_id 怎麼用

這個 user 下單後到收到 email 共經過幾個 event?」⸺ WHERE correlation_id = 'eA',所有相關 event 都跑出來。

SELECT * FROM outbox WHERE payload->>'correlation_id' = 'eA';

跨 service 也適用:service A 發 event 給 service B,service B 接到後產出新 event ⸺ 把 correlation_id 帶下去,audit 可以跨 service 看完整鏈。

Causation_id 怎麼用

correlation_id 是「同條業務鏈」但沒有順序資訊causation_id 補上順序:

eA (correlation=eA, causation=null)        ← 鏈起點
 └─ eB (correlation=eA, causation=eA)     ← 由 eA 觸發
     ├─ eC (correlation=eA, causation=eB)  ← 由 eB 觸發
     └─ eE (correlation=eA, causation=eB)  ← 由 eB 觸發
 └─ eD (correlation=eA, causation=eA)     ← 由 eA 觸發

組起來是一棵樹。Debug 「為什麼這個 event 會出現」時看 causation_id 沿著鏈追上去找根因。

Demo 對應

/demo/saga-chain-light endpoint 串 N 個 event,每個 event 的 handler 產生下一個 event(共用 correlation_id、causation_id 串接):

# 跑 5 步 chain
curl -X POST 'http://localhost:8000/demo/saga-chain-light?steps=5' | jq
 
# 用 correlation_id 看完整鏈
CORR=$(curl ... | jq -r '.correlation_id')
curl -s "http://localhost:8000/audit/correlation/$CORR" | jq
 
# 回應顯示 5 個 event、causation chain 串起來

/audit/correlation/:idcorrelation_id 當 join key 撈所有相關 event,按 created_at 排序回傳。


5. 何時要升級到 OpenTelemetry

application-level audit 解 80% 場景。剩下 20% 才需要 OTel:

Audit 能解的場景

  • 「這個 event_id 跑到哪了?」
  • 「哪些 handler 跑了?哪些失敗了?」
  • 「這條業務鏈共幾個 event?順序是什麼?」
  • 「這個 user 的訂單目前 status / version 是什麼?」
  • 「為什麼某個 handler 進 DLQ?」

Audit 不能解的場景

  • 跨 application boundary 的 trace:你 publish 給 Kafka,下游是 Spark job / 另一個 team 的 service,你看不到下游 instrumented 了什麼
  • performance breakdown:每個 handler 花多久?哪一段是瓶頸?handler 內哪個 SQL query 最慢?
  • 跨 thread / async context 的 stack trace:handler 內 async 呼叫鏈,audit 看不到細節
  • service mesh 的網路層:兩個 service 之間 retry / circuit-break 細節

當你真的需要這些時,引入 OTel:

  • collector (Jaeger / Tempo / Honeycomb / Datadog)
  • SDK 在每個 service / handler / DB call instrument
  • 跨 broker 把 trace context propagate(Kafka headers / message metadata)

不要為了「將來可能有用」提前上 OTel。基礎建設 + 學習成本都不便宜。先用 audit 解 80%,撞到剩下 20% 再升級。

兩個方案不互斥

OTel + audit 可以並存:OTel 看 performance / 跨 service flow、application audit 看 business state 跟 reliability 表狀態。兩個解的問題層次不一樣。


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

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

Laravel Telescope 是 audit 的某種形式:

Laravel Telescope本篇 audit範圍
HTTP request → response同 process✅ 完整 trace
Eloquent query同 process✅ 看得到 SQL + duration
Mail / Notification同 process✅ 觸發點 + payload
Queue job (dispatched + processed)跨 process⚠️ 同 Telescope 才看得到,跨 service 看不到
Event同 process(events firing)

Telescope 的核心限制:只看同 application instance。跨 application 的 event 流動 ⸺ 例如 service A 的 controller dispatch 給 service B 的 worker ⸺ Telescope 看不到。

要做到本篇講的「跨 process trace」要:

  1. 每個 service 各自跑 Telescope:A 的 Telescope 看 A 的 trace、B 的看 B 的
  2. correlation_id / causation_id 自己加在 job payload 內
  3. 手動串各服務的 Telescope(每個 job 帶 correlation_id 進去)

或者:

  • 引入第三方 trace tool(OTel / Datadog APM)
  • 自己寫 audit endpoint(本篇講的)

重點:Telescope 是「單服務 debug tool」,audit endpoint 是「多服務 event lifecycle tracker」⸺ 兩個都有用,解的問題不同。


7. 反思

寫整個系列的過程,我最有感的是這篇。前面 5 篇講的 outbox / dedup / SQL idempotency / retry / DLQ 都是「不出事就感覺不到價值」的設計 ⸺ 平常 work fine 沒人想。

但 audit 不一樣:出事的那一刻你會立刻發現「我系統有沒有設計這層」。沒設計的話:

  • 客服轉來「我訂單沒收到信」 → 你翻 4 個小時
  • bug 在某 handler,但問題 surface 在另一個 handler → 你瞎猜
  • 每個 incident 後 retrospective 上「下次怎麼避免」⸺ 寫 5 行檢核表,跑 3 個月後沒人記得

audit endpoint 是「我把每個 incident 學到的東西結構化記下來」的具體機制。你看到「哪個 event 的哪個 handler 在某個時間點失敗了」這層資料一旦在手,未來所有 incident 都有起點。

更重要的:audit 是 EDA 系統的可維護性核心。不要等出事才補。

建議:你不需要等寫完 outbox / dedup / DLQ 才寫 audit。事實上**「第一個 handler 開始寫 DB 那一刻」就該有最薄版本的 audit**,不然你連最初幾個 incident 都 debug 不動。

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

你現在 maintain 的系統,有沒有「給我一個 event_id 我就能看到所有相關 row」的入口?沒有的話,下個 incident 你打算用什麼姿勢追?


9. 相關文章

本系列前置(整個系列的拼圖最後一塊)

外部參考

Runnable demo: tools3455147/mq-event-driven-demo — 本篇 demo 在 src/audit/ 模組 + /audit/event/:id/audit/correlation/:id 兩個 endpoint。Hands-on:跑任何 demo endpoint 後用回傳的 event_id / correlation_id 查 audit 看完整鏈 設計文件: docs/architecture.md