這篇要回答的問題:DB 已經 commit 了,但接下來推 event 到 broker 那行 crash 了 / broker 連線斷了 / process 被 kill 了 — 結果訂單寫入 DB 但 listener 永遠不會跑。這個「dual-write」問題該怎麼解?為什麼框架的
DispatchAfterCommit只解了一半?
3 分鐘結論
- DB + broker 同時寫會出事,因為兩個系統沒共用 transaction
- 解法:outbox pattern — 把「要送什麼 event」寫進 DB 的 outbox 表(跟業務寫入同 transaction),背景 worker 再從表 publish 到 broker
- Laravel
DispatchAfterCommit不夠:它解 partial(DB rollback 還 dispatch),沒解 broker 掛
這篇假設你知道
- 篇 1 Production EDA 入門 講的「半 EDA」是什麼
- DB transaction 基本概念(
BEGIN ... COMMIT、rollback) - 不熟下面這些詞時 → #16 EDA 名詞速查:dual-write / outbox pattern / broker / publish
完整 runnable demo:tools3455147/mq-event-driven-demo。
完整 runnable demo + 程式碼:tools3455147/mq-event-driven-demo。
1. 一個常見的場景
凌晨 2 點,行銷團隊跑 promotion,下單流量飆到平常 8 倍。Infra 同事剛好在那 30 秒滾動更新 broker container(RabbitMQ 重啟)。事後對帳發現:
orders表多了 47 筆新訂單(DB 寫入成功)- email service log 顯示只寄了 12 封確認信
- 客服早上開始接到電話:「我下單成功了但沒收到 email,是不是被詐騙?」
倒回去看 controller 的 code:
// Laravel
class OrderController {
public function create(Request $r) {
$order = Order::create($r->all()); // ← DB COMMIT,47 筆都成功了
event(new OrderCreated($order)); // ← broker 重啟那 30 秒,35 筆在這行失敗
return response($order, 201);
}
}問題很清楚:第 1 行成功了,第 2 行沒成功,但 client 已經收到 201。對使用者而言訂單存在,對 listener 而言這個 event 從來沒發生過。
更可怕的是這種狀況不會有 alert。沒有 exception 留下來(broker SDK 內部 swallow 了,或者 process 直接被 kill)、DB 看起來正常、controller log 一切順利。等客服反映時已經 6 小時後了。
這就是 dual-write 問題:你需要同時寫兩個系統(DB + broker),但這兩個系統沒有共同的 transaction 機制。
2. 為什麼跨系統 transaction 不可行
新人第一個直覺:「那把 DB 寫入跟 broker publish 包成同一個 transaction 不就好了?」
跨系統 transaction(distributed transaction、2PC two-phase commit)技術上可行,實務上幾乎沒人這樣做。原因:
原因 1:broker 沒有 transaction API
絕大多數 broker(RabbitMQ、Kafka、Redis、NATS)的 publish() 是 fire-and-forget 或 confirm-on-broker — 沒有「跟外部 DB 共用 transaction」的 API。Kafka 有 transactions 但只在 Kafka 內部 topic-to-topic 之間有用,跨到外部 Postgres 一樣斷裂。
原因 2:2PC 的代價
XA / 2PC 協定理論上能跨 DB + broker,但需要:
- DB 支援 XA(Postgres 支援但要特別啟用)
- broker 支援 XA(多數開源 broker 不支援)
- transaction coordinator(有狀態的服務、自己也會掛、自己也是 single point of failure)
- 整體 throughput 下降 5-10 倍(多次 round-trip)
業界共識:這個成本通常不值得,能用 application-level pattern 補就用 pattern 補。
原因 3:即使有 2PC,coordinator 自己也會掛
2PC 的 coordinator crash 時,DB 跟 broker 都不知道該 commit 還是 rollback,需要人工介入。問題沒消失,只是被推到另一層。
於是業界選了一條 detour 路線:只用「DB 自己的 transaction」。
3. Outbox pattern:把 broker write 變成 DB write
核心 idea 一句話:
不要直接 publish 到 broker。把「我要送什麼 event」這件事寫進你自己 DB 的一張 outbox 表,跟業務寫入同一個 transaction。背景 worker 再從 outbox 撈出來真的送到 broker。
舊路徑(dual-write,會壞):
INSERT INTO orders ... ─┐
│ 兩個系統,沒共用 transaction
broker.publish(event) ─┘ 任何一邊失敗造成不一致
新路徑(outbox,原子):
BEGIN TRANSACTION;
INSERT INTO orders ... ─┐
INSERT INTO outbox (..., 'pending')│ 一個 transaction,原子
COMMIT; ─┘
─── 跨進程邊界 ───
Background worker (separate process):
SELECT * FROM outbox WHERE status='pending' ← polling
broker.publish(event) ← 真的送
UPDATE outbox SET status='sent' WHERE id=... ← 標記完成
關鍵性質:
- 業務寫入跟 outbox 寫入「同一個 DB transaction」→ 要嘛兩個都進、要嘛兩個都不進。Postgres 自己的 ACID 保證這件事
- broker 掛了不影響「下單成功」的判定 — 訂單跟它要送出去的 event 已經安全地存在 DB 裡了
- broker 恢復後,worker 下一輪 poll 自然把累積的 pending event 補發出去
- 代價:訊息送達會有 polling 延遲(worker 每 N 毫秒撈一次,所以延遲 = poll interval / 2 平均)
這個 trade-off 通常划算:用幾百 ms 的延遲換事件絕對不會丟。
4. Demo:跑一次看 outbox 怎麼運作
把 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 --build4.1 正常路徑:下單成功 → outbox commit → worker 發送
下一筆訂單,看 outbox 流程:
curl -X POST http://localhost:8000/demo/place-order \
-H 'Content-Type: application/json' \
-d '{"user_id":"u1","items":["A","B"]}'回應:
{
"event_id": "0a4f...",
"status": "committed",
"tx_ms": 8.34,
"note": "order + outbox both committed atomically; worker will publish soon"
}幕後發生了什麼?看 source:
// apps/backend-node/src/http/routes/demo.ts (節錄)
await inTransaction(async (client) => {
await client.query(
`INSERT INTO orders (event_id, user_id, items) VALUES ($1, $2, $3)`,
[event.id, event.user_id, JSON.stringify(event.items)],
);
await outbox.writeInTx(client, event.id, 'rabbitmq', event, 'safe');
});outbox.writeInTx() 用呼叫方傳進來的 transaction client(不是新開一個 connection),所以 INSERT orders 跟 INSERT outbox 真的在同一個 Postgres transaction 內。
幾百毫秒後,worker 撈到並送出。看 audit:
curl -s http://localhost:8000/audit/event/0a4f... | jq你會看到 outbox 跟 processed 表都有 record,business 表 orders 也有,全部對得起來。
4.2 失敗路徑:transaction rollback,沒有半成品
加上 ?fail=1:
curl -X POST 'http://localhost:8000/demo/place-order?fail=1' \
-H 'Content-Type: application/json' \
-d '{"user_id":"u1","items":["A","B"]}'回應:
{
"event_id": "9b22...",
"status": "rolled_back",
"error": "simulated business failure before commit",
"note": "order + outbox both rolled back; check `orders` and `outbox` tables — no rows for this event_id"
}這個 endpoint 故意在 transaction 即將 commit 前 throw。結果:
orders表沒有9b22...這筆outbox表也沒有9b22...這筆- broker 也沒有收到任何訊息
「全有」或「全無」。沒有「訂單進 DB 但 event 沒送出去」的半成品狀態。這就是 outbox pattern 的核心保證。
4.3 模擬 broker 掛掉:worker 累 outbox
最關鍵的 demo — 模擬「broker 掛 30 秒、流量還在進來」:
# Step 1: 暫停 outbox worker(模擬 broker 不可用)
curl -X POST http://localhost:8000/demo/chaos/pause-outbox
# Step 2: 連續下 10 筆訂單
for i in $(seq 1 10); do
curl -X POST http://localhost:8000/demo/place-order \
-H 'Content-Type: application/json' \
-d "{\"user_id\":\"u$i\",\"items\":[\"A\"]}"
echo
done
# Step 3: 看 outbox 的 pending 數量
curl -s http://localhost:8000/metrics | jq '.reliability.outbox'
# {
# "pending": 10,
# "oldest_pending_lag_ms": 8234
# }
# Step 4: 恢復 worker
curl -X POST http://localhost:8000/demo/chaos/resume-outbox
# Step 5: 等 1 秒後再看,pending 歸零
sleep 1
curl -s http://localhost:8000/metrics | jq '.reliability.outbox'
# { "pending": 0, "oldest_pending_lag_ms": null }跟 §1 故事對比一下:在那個故事裡,broker 掛的那 30 秒,35 筆訂單「DB 進去了但 event 沒推」。用 outbox 之後,那 30 秒內的訂單一筆都不會丟 — 它們安安靜靜在 outbox 表裡等 worker 醒過來,等 broker 恢復連線。
/demo/race-naive-vs-outbox?count=N endpoint 是同一個情境的量化對比:同條件下 naive path 掉幾筆、outbox path 掉幾筆,數字會說明一切。
5. Outbox 表 schema + worker 怎麼設計
Schema
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE,
bus TEXT NOT NULL, -- rabbitmq / kafka / nats / redis / inmemory
topic TEXT NOT NULL DEFAULT 'safe', -- plain / safe(決定送到哪條 broker 流)
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending / sent / failed
attempts INT NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
sent_at TIMESTAMPTZ
);
-- 只 index pending row,因為 worker 永遠只查 pending
CREATE INDEX idx_outbox_pending
ON outbox (status, created_at) WHERE status = 'pending';幾個欄位的設計動機:
event_idUNIQUE:你的 producer 可能因為某些原因 retry(client 連按兩下、application 內部重試),重複 INSERT 會被 PK 擋掉 → producer-side 冪等。這也是篇 3 會講的 client-side idempotency key 的基礎bus:demo 支援 5 個 broker,要記住這筆 event 該送到哪個。生產系統只有一個 broker 就不需要這欄topic:demo 內有 plain / safe 兩條獨立 broker 流,記住要送哪條。生產系統如果是 topic-based broker 也適用status+ partial index:worker 永遠只WHERE status='pending',partial index 把表的 99% 已 sent 的 row 排除在 index 之外,scan 成本恆為 O(pending size)attempts+last_error:worker 重試的軌跡,給/admin/outbox/failedUI 用
Worker:polling + FOR UPDATE SKIP LOCKED
worker 的核心 SQL(outbox/repo.ts:67-78):
SELECT * FROM outbox
WHERE status = 'pending' AND attempts < $maxAttempts
ORDER BY id
LIMIT $batchSize
FOR UPDATE SKIP LOCKED;兩個關鍵 SQL 招式:
FOR UPDATE ── 鎖住這幾筆 row,其他 transaction 看不到(直到本 transaction commit)。為什麼需要?因為等下 worker 要 publish 並 markSent,這中間如果另一個 worker process 也撈到同一筆 → 重複送。
SKIP LOCKED ── 如果 row 已經被另一個 worker 鎖住,跳過、不要等。換成下一筆。
為什麼需要?因為你之後可能想跑多個 worker process 水平擴展(demo 沒做,但 production 很常見)。沒 SKIP LOCKED 的話多 worker 會輪流卡住、變單線程,等於沒 scale。
沒講的細節:worker 把 batch 撈出來後就 commit 那個 transaction(釋放鎖),用 application 層的記憶體 track 哪幾筆「正在處理」,publish 完才回 DB 更新 status。這樣 row 不會被鎖太久。代價:如果 worker 在 publish 完之前 crash,row 被丟回 pending、下次重新撈到 → 可能重送一次 → 這就是為什麼 consumer 必須冪等(篇 3)。
Polling 的代價
Outbox 一定有 polling 延遲,因為 worker 不可能「秒知道」新 pending row。常見 tuning:
| Poll interval | 對使用者觀感 | 代價 |
|---|---|---|
| 100ms | 幾乎即時 | DB load 高(高頻 SELECT FOR UPDATE) |
| 500ms(demo default) | 平均 ~250ms 延遲 | DB load 中等 |
| 5s | 半秒級延遲(可能太慢) | DB load 低 |
進階解法:用 LISTEN/NOTIFY(Postgres)或 CDC tail(Debezium)替代 polling。寫入時觸發 NOTIFY,worker 收到 notify 就立刻 poll,不用等下一個 interval。代價是多一個機制要懂、debug 變難。
Demo 用 polling,因為 polling 是最直覺、最少 magic 的版本。production 系統視流量決定要不要升級。
6. Outbox row 卡住 failed 怎麼辦:人工 retry loop
worker 不是萬能的。某些情境 publish 會持續失敗:
- broker 掛超過 worker 的最大重試次數(demo 是 5 次)
- 某個 event payload 結構錯誤導致 broker 拒收
- 你的 broker credentials 在某個更新 cycle 被 rotate 但沒換到 worker config
這些情境下,attempts 達 5 後 row 翻成 status='failed',worker 不再重試。這時候需要人工介入:
# 看現在 failed 的 outbox row
curl -s http://localhost:8000/admin/outbox/failed | jq
# 修好 broker 之後,把某筆 row 翻回 pending
curl -X POST http://localhost:8000/admin/outbox/123/retry
# Worker 下一輪 poll 會撈到,再送一次為什麼不直接讓 worker 無限重試?因為 root cause 沒解決前無限重試只是浪費 broker connection 跟 DB lock。把它沉到 failed 狀態,由人決定什麼時候 retry 比較合理。
這個「permanent failure → 人工 triage → 修了再 retry」的 loop 在篇 5(Retry 跟 DLQ)會更完整地討論。outbox failed 是producer-side 版本,DLQ 是 consumer-side 版本,兩者形狀很像但解的層次不同。
7. 給 Laravel 讀者的術語對譯(可選)
📍 沒用過 Laravel 的讀者可整節跳過,不影響後面理解。
Laravel 有一個近年加上的 helper 叫 DispatchAfterCommit:
// Laravel 8+
DB::transaction(function () {
$order = Order::create([...]);
DispatchAfterCommit::dispatch(new SendOrderConfirmation($order));
});語意是「transaction commit 成功之後才把 job 丟進 queue」。比起 naive 寫法的 Order::create() + event(...) 已經好很多 — 至少不會「DB rollback 了但 email 還是寄出去」。
但 DispatchAfterCommit 只解了 partial:
| 情境 | 純 event() | DispatchAfterCommit | 完整 outbox |
|---|---|---|---|
| DB 寫入失敗 → 不要送 event | ❌ event 已經送出去 | ✅ 不送 | ✅ 不送 |
| DB commit 成功,broker 連線斷 | ❌ event 丟失 | ❌ event 丟失 | ✅ 留在 outbox 等 retry |
| process 在 commit 跟 dispatch 之間 crash | ❌ event 丟失 | ❌ event 丟失 | ✅ outbox 已是 commit 的一部分 |
Laravel DispatchAfterCommit 對應到 outbox pattern 的「避免半成品 publish」這一半,但沒有解決「broker 掛了還能補發」這一半。要做到後者,你需要:
| 元件 | Laravel 對應 | 自己加 |
|---|---|---|
| outbox 表 schema | 沒有 | migration 加 |
| writeInTx(順著 transaction 寫入) | DB::transaction() 內呼叫 Outbox::create() | 寫 helper |
| background worker | Laravel scheduler + console command 或 supervisor | 寫 php artisan outbox:dispatch 配 cron 跑 |
| status=‘failed’ UI | Horizon UI 不適用 | 自己刻 admin endpoint |
社群有 package(搜 laravel-transactional-outbox),但自己刻不難,需要的 SQL 都很標準 Postgres。
8. 反思
Outbox 是我看到最多新人「以為自己已經有」但其實沒有的 pattern。
寫過 Laravel 的人很容易誤把 DispatchAfterCommit 當成 outbox。語意接近但保證範圍差很多 — 一個只防「transaction 內部 rollback 後不要送」,另一個是「broker 任何時候掛掉都不丟訊息」。前者解 partial,後者解 outage。
引入 outbox 的成本通常不高:一張表、一個 worker、幾個 SQL helper。代價是事件送達多了平均 250ms 延遲跟 DB load 微升。但這個代價買回來的是**「broker 連線那 30 秒掛掉、我不會收到客訴」這個安心感** — 對任何「使用者下單付了錢但沒收到 confirmation」這類業務後果嚴重的場景,這幾乎是必須加的。
最後留一題給你:
你目前在跑的系統,「DB 寫了但 event 沒送出去」這件事,有沒有被 monitor?如果今天 broker 掛 30 秒,你會在 6 小時後從客服那邊知道,還是 30 秒內從 alert 看到?
9. 相關文章
本系列前置:
- 篇 1 Production EDA 入門 — 為什麼 naive EDA 撐不住 production
本系列接下來:
- 篇 3 → Consumer 端冪等性 — outbox 不保證 broker 只送一次給 consumer,所以 consumer 一定要冪等
- 篇 4a → DB 寫入冪等性:6 種 SQL idiom
- 篇 4b → 事件亂序 + 跨 row 鎖
- 篇 5 → Retry 跟 DLQ — handler 失敗該怎麼處理;outbox 失敗的對稱
- 篇 6 → EDA 端到端追蹤 — outbox / processed / dlq 三表 join 看完整鏈
外部參考:
- Microservices.io — Transactional Outbox
- Confluent — Transactional Outbox pattern
- Debezium Outbox event router — 進階版:用 CDC 取代 polling
Runnable demo: tools3455147/mq-event-driven-demo — MIT licensed
設計文件: docs/architecture.md