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: 重新投影出新的查詢視圖事件重放讓兩件難事變得可能:
- 新增查詢視圖:需要一種全新的報表?把歷史事件重放進新的讀模型即可,不必修改寫入端
- 修復讀模型:投影邏輯有 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:#fff3e0CQRS 與事件溯源是「常一起出現但相互獨立」的兩個模式。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 與可靠事件投遞」。