Event Sourcing 與 CQRS#

在微服務架構下,每個服務各自持有資料,傳統「就地更新狀態 (State-Oriented)」的儲存方式會丟失「狀態如何演變」的歷史,也難以在不阻塞寫入的前提下提供多樣化的查詢視圖。事件溯源 (Event Sourcing) 與命令查詢責任分離 (CQRS, Command Query Responsibility Segregation) 正是針對這兩個痛點而生的落地模式。

本章聚焦它們在微服務情境下的工程取捨;與分散式事務的純理論(2PC、TCC、最終一致性模型)相關內容請參閱 分散式系統基礎

為什麼需要事件溯源#

狀態導向儲存的盲點#

傳統做法只保存「當前狀態」。例如訂單表只有一列 status = SHIPPED,至於它經過了「建立 ➡️ 付款 ➡️ 揀貨 ➡️ 出貨」哪些步驟、何時、由誰觸發,全部被覆寫掉了。

痛點狀態導向儲存的後果
稽核缺失無法回答「為什麼會變成這個狀態」
時間旅行困難無法重現某一歷史時刻的資料快照
整合困難其他服務只能輪詢狀態差異,無法訂閱「發生了什麼」
除錯黑洞線上異常狀態無法重放,難以定位根因

事件溯源的核心思想#

事件溯源不保存「當前狀態」,而是把每一次狀態變更記錄為一筆不可變的事件 (Immutable Event),依序追加 (Append-only) 到事件儲存 (Event Store)。當前狀態是「把所有歷史事件依序重放 (Replay) 後得到的結果」。

$$ \text{當前狀態} = f(e_1) \circ f(e_2) \circ \cdots \circ f(e_n) $$

其中每個 $e_i$ 是一筆領域事件,$f$ 是把事件套用 (apply) 到狀態上的純函數。

flowchart LR
    subgraph 狀態導向["狀態導向:覆寫"]
        direction TB
        SA["status = CREATED"] -- "UPDATE" --> SB["status = PAID"]
        SB -- "UPDATE" --> SC["status = SHIPPED"]
        SC -. "歷史已遺失" .-> X["✗"]
    end

    subgraph 事件溯源["事件溯源:追加"]
        direction TB
        E1["OrderCreated"] --> E2["OrderPaid"] --> E3["OrderShipped"]
        E3 -- "重放推導" --> ST["當前狀態\nSHIPPED"]
    end

    style 狀態導向 fill:#fff3e0
    style 事件溯源 fill:#e8f5e9
    style X fill:#ffcdd2

事件是「事實」,描述「已經發生」的事情(過去式命名,如 OrderPaid);命令 (Command) 是「意圖」,描述「想要發生」的事情(祈使句命名,如 PayOrder)。命令可能被拒絕,事件不會被撤回——只能再追加一筆補償事件。

事件溯源 vs 快照#

純事件溯源下,載入一個生命週期很長的聚合(例如累積了上萬筆事件的帳戶)需要重放全部歷史,效能會隨事件數線性退化。快照 (Snapshot) 是標準解法:定期把某個版本的完整狀態序列化保存,載入時從最近的快照開始、只重放其後的增量事件。

flowchart LR
    E1["e1"] --> E2["e2"] --> E3["e3"]
    E3 --> SNAP["快照 @v3\n(完整狀態)"]
    SNAP --> E4["e4"] --> E5["e5"]
    E5 -. "載入:快照v3 + e4,e5" .-> CUR["當前狀態 @v5"]

    style SNAP fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
維度純事件溯源(無快照)加入快照
載入延遲隨事件數線性增長近似常數(快照 + 少量增量)
儲存成本較低(只存事件)額外快照儲存
實作複雜度簡單需處理快照版本與失效
一致性風險快照與事件須對齊同一版本號

快照是「純粹的效能最佳化」,不能取代事件。事件才是唯一真實來源 (Source of Truth);快照永遠可以被丟棄、由事件重新推導。觸發策略常見的是「每 N 筆事件」或「每隔固定時間」做一次。

事件儲存與重放#

事件儲存的職責#

事件儲存兼具「資料庫」與「訊息中介」雙重角色:

  • 持久化:以 (aggregateId, version) 為主鍵追加事件,並保證同一聚合內事件的全序 (Total Order)
  • 樂觀並行控制:寫入時校驗預期版本號,版本衝突即拒絕,避免併發覆寫
  • 訂閱:下游可訂閱事件流,驅動讀模型更新或跨服務整合

事件重放 (Event Replay) 的用途#

sequenceDiagram
    participant Store as 事件儲存
    participant Agg as 聚合根
    participant Proj as 投影器 (Projector)
    participant RM as 讀模型

    Note over Store,Agg: 用途一:重建聚合當前狀態
    Agg->>Store: 讀取 aggregateId 的所有事件
    Store-->>Agg: [e1, e2, ..., en]
    loop 依序套用
        Agg->>Agg: apply(ei)
    end

    Note over Store,RM: 用途二:重建讀模型 (Rebuild)
    Proj->>Store: 從 offset=0 重放全部事件
    Store-->>Proj: 事件流
    Proj->>RM: 重新投影出新的查詢視圖

事件重放讓兩件難事變得可能:

  1. 新增查詢視圖:需要一種全新的報表?把歷史事件重放進新的讀模型即可,不必修改寫入端
  2. 修復讀模型:投影邏輯有 bug,修好後清空讀模型、重放重建,無需資料遷移腳本

CQRS:命令端與查詢端分離#

CQRS 把系統拆成兩條獨立的路徑:

  • 命令端 (Command Side / Write Model):處理寫入意圖,產生事件,維護一致性與業務規則。模型通常是規範化的聚合
  • 查詢端 (Query Side / Read Model):處理讀取,維護一個或多個為查詢最佳化的讀模型 (Read Model / Materialized View),可去規範化、可用不同儲存引擎
flowchart TB
    U["用戶端"]
    U -- "命令 (Command)" --> CMD["命令端\n聚合 + 業務規則"]
    CMD -- "產生事件" --> ES[("事件儲存")]
    ES -- "事件流" --> PROJ["投影器"]
    PROJ -- "更新" --> RM[("讀模型\n去規範化")]
    U -- "查詢 (Query)" --> RM

    style CMD fill:#e3f2fd
    style RM fill:#e8f5e9
    style ES fill:#fff3e0

CQRS 與事件溯源是「常一起出現但相互獨立」的兩個模式。CQRS 不一定要事件溯源(讀寫端也可共用一個資料庫、用觸發器同步);事件溯源也不一定要 CQRS。但兩者結合時威力最大:事件流天然就是更新讀模型的驅動源。

讀模型的最終一致 (Eventual Consistency)#

命令端寫入事件後,投影器是異步消費事件來更新讀模型的。這意味著從「命令成功」到「查詢端看得到變更」之間存在一個複製延遲 (Replication Lag) 視窗——這正是 CQRS 最大的取捨。

議題後果與對策
讀寫不一致用戶剛下單卻查不到。對策:前端樂觀更新、或回傳新版本號供輪詢
重複投影事件可能被重送。對策:投影器以事件 ID 做冪等 (Idempotent)
亂序到達對策:依聚合版本號排序,落後的事件先緩衝

不要對「需要立即讀己之所寫 (Read-your-writes)」的場景強上 CQRS 異步讀模型。若業務無法容忍哪怕幾百毫秒的延遲(如轉帳後立即顯示餘額),應對該查詢直接走命令端、或採同步投影。

事件版本演進 (Event Versioning)#

事件是不可變且永久保存的,但程式碼會演進。當事件結構需要改變(新增欄位、改名、拆分),舊事件已經躺在儲存裡無法修改,必須讓新版程式碼仍能正確重放舊事件。

常見策略:

  • 弱結構 + 預設值:新增欄位時給予預設值,反序列化舊事件時自動補上
  • 上轉換器 (Upcaster):讀取時把舊版事件結構即時轉換成新版,業務邏輯只面對最新結構
  • 多版本並存:事件帶 schemaVersion,apply 時依版本分派
  • 永不破壞性變更:不刪欄位、不改語義;要改就發布全新事件類型
flowchart LR
    OLD["舊事件 v1\n{amount}"] --> UP["Upcaster\nv1 → v2"]
    UP --> NEW["新事件 v2\n{amount, currency='TWD'}"]
    NEW --> APPLY["apply()\n只認 v2"]

    style UP fill:#fff3e0,stroke:#ff9800

把上轉換放在「讀取/反序列化」這一層,而不是改寫儲存裡的歷史事件。歷史事件一旦被「就地升級」就失去了不可變性,也失去了稽核價值。

效能、儲存與隱私刪除的取捨#

事件溯源並非免費的午餐,落地前必須評估以下成本:

效能與儲存#

  • 寫入吞吐:追加寫對寫入友善,但每筆變更都產生事件,事件總量遠大於狀態量
  • 儲存增長:事件只增不刪,需配合快照、冷熱分層、歸檔策略控制成本
  • 查詢效能:靠讀模型補償;查詢端可選用最適合的引擎(如全文檢索、列式分析庫)

隱私刪除的兩難#

這是事件溯源最棘手的合規問題。法規(如個資刪除權、被遺忘權)要求「能徹底刪除某人的資料」,但事件儲存的本質是不可變、永久追加——兩者直接衝突。

方案做法取捨
加密分片刪除個資加密後存事件,金鑰另存;刪除時銷毀金鑰使密文不可解主流方案,事件結構不變
事件改寫重寫歷史事件、移除個資欄位破壞不可變性,需重算下游快照與讀模型
外部化敏感資料事件只存引用 ID,個資存可刪除的外部表削弱「事件自包含」特性

「加密分片刪除 (Crypto-shredding)」是兼顧不可變性與刪除權的常見折衷:替每位用戶的個資用獨立金鑰加密,要刪除時只需丟棄該金鑰,密文即成為無法還原的亂數。但要注意金鑰管理本身的可靠性與備份策略。

聚合根重放事件(偽碼)#

以下示範一個訂單聚合根如何用事件重建狀態、處理命令並產生新事件。沿用本章既有的 Java 風格:

public class Order {
    private String id;
    private OrderStatus status;
    private long version;          // 樂觀並行控制用

    // === 重放:把歷史事件依序套用,重建當前狀態 ===
    public static Order replay(String id, List<DomainEvent> history) {
        Order order = new Order();
        for (DomainEvent e : history) {
            order.apply(e);        // 純函數:只改記憶體狀態,不產生副作用
            order.version++;
        }
        return order;
    }

    // === apply:事件 → 狀態變更(不含業務校驗)===
    private void apply(DomainEvent e) {
        if (e instanceof OrderCreated c) {
            this.id = c.orderId();
            this.status = OrderStatus.CREATED;
        } else if (e instanceof OrderPaid) {
            this.status = OrderStatus.PAID;
        } else if (e instanceof OrderShipped) {
            this.status = OrderStatus.SHIPPED;
        }
    }

    // === 命令處理:校驗業務規則 → 產生事件(尚未落庫)===
    public List<DomainEvent> pay() {
        if (status != OrderStatus.CREATED) {
            throw new IllegalStateException("僅 CREATED 狀態可付款");
        }
        OrderPaid event = new OrderPaid(this.id, Instant.now());
        apply(event);              // 立即套用到自身,保持記憶體狀態最新
        return List.of(event);     // 交由應用層追加到事件儲存
    }
}
從快照載入的最佳化版本
public static Order load(String id, EventStore store) {
    // 1. 取最近快照(可能為空)
    Snapshot snap = store.latestSnapshot(id);
    Order order = (snap == null)
        ? new Order()
        : Order.fromSnapshot(snap);     // 直接還原狀態,跳過早期事件

    long fromVersion = (snap == null) ? 0 : snap.version();

    // 2. 只重放快照之後的增量事件
    List<DomainEvent> delta = store.eventsAfter(id, fromVersion);
    for (DomainEvent e : delta) {
        order.apply(e);
        order.version++;
    }
    return order;
}

關鍵:快照只是加速載入,刪掉它系統依然能由全量事件正確重建。

小結#

模式解決的問題主要取捨
事件溯源稽核、時間旅行、整合、可重放儲存增長、隱私刪除、版本演進複雜
快照長生命週期聚合的載入效能額外儲存與版本對齊
CQRS讀寫負載與模型分離、多樣化查詢讀模型最終一致、運維兩套模型

三者常與 Saga、可靠事件投遞一起出現:命令端產生的事件既驅動讀模型,也驅動跨服務協作。相關落地請參閱 服務通信 中的「Saga 與可靠事件投遞」。