Outbox Pattern
部分內容由 LLM 生成,尚未經過人工驗證。
為什麼需要 Outbox Pattern?
業務資料要寫 DB,事件要發 MQ。這兩個系統沒辦法共用同一個 transaction,任何一步失敗都造成不一致:
| 情境 | 結果 |
|---|---|
| DB 成功 + MQ 失敗 | 資料存了,下游永遠不知道 |
| MQ 成功 + DB 失敗 | 事件發出去了,但資料根本不存在 |
這就是 Dual Write Problem。Outbox Pattern 的解法:把「要發的事件」也寫進 DB,讓兩者在同一個 transaction 裡 commit 或 rollback,把跨系統的原子性問題降維成單系統(DB)的原子性問題。
核心概念
Outbox Pattern 利用資料庫交易的原子性,確保業務數據與事件紀錄同步寫入,達成 At-Least-Once Delivery。
一句話定義: 在同一個 DB Transaction 中,同時寫入業務資料與待發送事件,保證兩者一致。
Outbox Table Schema
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(255), -- 如 "Order"
aggregate_id UUID, -- 如 訂單 ID
event_type VARCHAR(255), -- 如 "OrderCreated"
payload JSONB, -- 事件數據
status VARCHAR(50), -- PENDING/PUBLISHED/FAILED
created_at TIMESTAMP,
published_at TIMESTAMP NULL
);策略 A:CDC / Polling Publisher
核心機制: 業務服務只負責寫入 DB,訊息發送由外部組件(CDC 或 Polling Publisher)驅動。
sequenceDiagram
participant Client
participant Service
participant DB as Database
participant Publisher as CDC / Polling Publisher
participant MQ as Message Queue
Client->>+Service: Request
Service->>+DB: Begin Transaction
rect rgba(0, 255, 0, 0.1)
Note over Service,DB: Transaction Boundary
Service->>DB: Insert Business Data
Service->>DB: Insert to Outbox Table
DB-->>Service: Commit Success
end
Service-->>-Client: 202 Accepted
rect rgba(0, 0, 255, 0.1)
Note over DB,Publisher: CDC / Polling Process
DB->>Publisher: Detect New Outbox Record
Publisher->>MQ: Publish Event
Publisher->>DB: Update Status = PUBLISHED
end
loop Retry Failed Events
Publisher->>DB: Query Failed/Pending Events
Publisher->>MQ: Retry Publishing
end
適用場景
- 需要嚴格解耦業務邏輯與訊息發送
- 金融級交易(銀行轉帳、證券交易)
- ETL 資料同步
- 已有 CDC 基礎設施(如 Debezium)
策略 B:Dual-Write + Cron
核心機制: Transaction Commit 後立即嘗試發送訊息,失敗時由 Cron Job 負責補救。
sequenceDiagram
participant Client
participant Service
participant DB as Database
participant MQ as Message Queue
participant Cron as Cron Job
Client->>+Service: Request
Service->>+DB: Begin Transaction
rect rgba(0, 255, 0, 0.1)
Note over Service,DB: Transaction Boundary
Service->>DB: Insert Business Data
Service->>DB: Insert to Outbox (PENDING)
DB-->>Service: Commit Success
end
Service->>MQ: Try Publish Event
alt Publish Success
Service->>DB: Update Status = PUBLISHED
Service-->>Client: 200 OK
else Publish Failed
Service-->>Client: 200 OK (Event will retry)
end
loop Every N minutes
Cron->>DB: Query PENDING events (created_at < NOW - buffer)
Cron->>MQ: Publish Events
Cron->>DB: Update Status = PUBLISHED
end
適用場景
- 高併發微服務
- 新創團隊(較低的架構複雜度)
- 對延遲容忍度較高的系統
- 無 CDC 基礎設施
實際場景:兩個 Service 各自的 Outbox
場景: 用戶下單 → Order Service 寫訂單 → Inventory Service 扣庫存
每個 Service 有自己的 DB,自己的 outbox table,完全獨立。
sequenceDiagram
participant Client
participant OrderSvc as Order Service
participant OrderDB as Order DB<br/>(orders + order_outbox)
participant OrderPub as Order Publisher
participant MQ as Message Queue
participant InvSvc as Inventory Service
participant InvDB as Inventory DB<br/>(inventory + inv_outbox)
participant InvPub as Inventory Publisher
Client->>+OrderSvc: POST /orders
rect rgba(0, 200, 0, 0.1)
Note over OrderSvc,OrderDB: Transaction #1 — Order Service 自己的 DB
OrderSvc->>OrderDB: INSERT orders (status=PENDING)
OrderSvc->>OrderDB: INSERT order_outbox (OrderCreated)
OrderDB-->>OrderSvc: Commit ✅
end
OrderSvc-->>-Client: 202 Accepted
rect rgba(0, 100, 255, 0.1)
Note over OrderDB,OrderPub: Order Publisher
OrderPub->>OrderDB: Poll order_outbox WHERE status=PENDING
OrderPub->>MQ: Publish OrderCreated
OrderPub->>OrderDB: UPDATE order_outbox SET status=PUBLISHED
end
MQ->>+InvSvc: Consume OrderCreated
rect rgba(255, 140, 0, 0.1)
Note over InvSvc,InvDB: Transaction #2 — Inventory Service 自己的 DB
InvSvc->>InvDB: UPDATE inventory SET stock=stock-1
InvSvc->>InvDB: INSERT inv_outbox (InventoryReserved)
InvDB-->>InvSvc: Commit ✅
end
InvSvc-->>-MQ: ack
rect rgba(200, 0, 200, 0.1)
Note over InvDB,InvPub: Inventory Publisher
InvPub->>InvDB: Poll inv_outbox WHERE status=PENDING
InvPub->>MQ: Publish InventoryReserved
InvPub->>InvDB: UPDATE inv_outbox SET status=PUBLISHED
end
Outbox 解決了什麼:
| 情境 | 沒有 Outbox | 有 Outbox |
|---|---|---|
| MQ 暫時掛掉 | 事件永久遺失 | 事件在 outbox 等,MQ 恢復後補發 |
| Service crash(commit 後、publish 前) | 事件遺失 | commit 了就在 outbox,重啟後繼續發 |
| MQ publish 成功但狀態未更新 | 重複發送無法偵測 | outbox status 欄位追蹤,避免重複 |
核心保證: DB commit 成功 = 事件一定會被發出去(at-least-once)。不會出現「DB 寫了但 MQ 沒收到」的靜默失敗。
策略比較
| 特徵 | CDC / Polling | Dual-Write + Cron |
|---|---|---|
| 發送觸發點 | 外部組件 | 業務服務 |
| 發送失敗處理 | Publisher 重試 | Cron Job 接手 |
| 資料庫壓力 | 高(持續輪詢) | 低 |
| 架構複雜度 | 高 | 低 |
| 即時性 | 高 | 中(依賴 Cron 間隔) |
| 適用場景 | 銀行/ETL/嚴格解耦 | 高併發微服務/新創 |
實務建議
Cron Job 時間緩衝設計
避免 Cron 與業務服務同時發送同一筆訊息,造成重複:
-- 只撈取 N 分鐘前的 PENDING 事件
SELECT * FROM outbox
WHERE status = 'PENDING'
AND created_at < NOW() - INTERVAL '5 minutes';冪等性(Idempotency)必要性
無論使用哪種策略,消費端必須實作冪等處理:
- 使用
event_id做去重 - 業務邏輯設計為可重複執行
- 例:「建立訂單」改為「若不存在則建立」
選擇指南
flowchart TD
A[需要 Outbox Pattern?] --> B{已有 CDC 設施?}
B -->|是| C[策略 A: CDC/Polling]
B -->|否| D{對即時性要求?}
D -->|高| E[考慮導入 CDC]
D -->|中低| F[策略 B: Dual-Write + Cron]