這篇要回答的問題: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),不要預先上
這篇假設你知道
- 篇 2 Outbox / 篇 3 Dedup / 篇 5 Retry+DLQ ⸺ 前面講的 3 張 reliability 表是 audit 的拼圖
- SQL JOIN 基本概念
- 不熟下面這些詞時 → #16 EDA 名詞速查:distributed tracing / correlation_id / causation_id / OpenTelemetry / 3 支柱
完整 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 的唯一 id | eA / eB / eC / … |
correlation_id | 這條業務鏈的 root id(所有相關 event 共用) | eA(永遠是業務鏈的第一個 event 的 id) |
causation_id | 是「哪個 event 觸發我」 | eB 的 causation_id 是 eA,eC 的是 eB |
schema_version | event 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/:id 用 correlation_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」要:
- 每個 service 各自跑 Telescope:A 的 Telescope 看 A 的 trace、B 的看 B 的
correlation_id/causation_id自己加在 job payload 內- 手動串各服務的 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. 相關文章
本系列前置(整個系列的拼圖最後一塊):
- 篇 1 Production EDA 入門
- 篇 2 Outbox — outbox 表是 audit 的拼圖之一
- 篇 3 Consumer dedup — processed_events 表是 audit 的拼圖之二
- 篇 4a DB-write idempotency — 業務表是 audit 的拼圖之三
- 篇 4b 事件亂序 + 跨 row 鎖 —
versionhistory 是 audit 的補完 - 篇 5 Retry + DLQ — dlq 表是 audit 的拼圖之四
外部參考:
- OpenTelemetry — 業界標準的 distributed tracing 規格
- Microservices.io — Audit Logging
- Greg Young — “What is a Saga?” — 補一些 saga / event 護照欄位的概念
- Laravel Telescope — 單服務 debug 的標竿
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