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 / PollingDual-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]

相關主題