狀態(state)是一個謊言。

更精確地說:資料庫裡的 order.status = 'SHIPPED' 是一個問題的答案,但你已經丟掉了問題本身。這個訂單是什麼時候從 PENDING 變成 PROCESSING?誰在什麼時間點核准了這筆退款?庫存從 50 件掉到 0,是因為一筆大訂單,還是五十筆小訂單?

對大多數系統來說,這些問題不重要,所以丟掉它們是合理的。但有一類問題,歷史跟當前狀態同樣重要:金融系統的每一筆餘額變動都必須有憑有據,協作編輯需要知道每個操作的時序,複雜業務流程在出錯時必須能說清楚「怎麼走到這一步」。

Event Sourcing 拒絕丟掉那個問題。


傳統方式丟掉了什麼

-- 傳統做法:每次更新都覆蓋舊狀態
UPDATE orders SET status = 'SHIPPED', updated_at = NOW() WHERE id = 123;
 
-- 你能查到的只有現在:
SELECT status FROM orders WHERE id = 123;
-- → 'SHIPPED'
 
-- 你查不到的:
-- 什麼時候從 PENDING 變成 PROCESSING?
-- 誰在 PROCESSING 狀態下改過預計出貨日?
-- 退款申請發生在什麼狀態之間?

這個問題的傳統解法是手動加 audit log——但 audit log 通常是事後加的補丁,容易遺漏,而且跟真實狀態邏輯不同步。Event Sourcing 把「記錄發生了什麼」從附加需求變成架構的核心。


Event Sourcing 的核心概念

Event Sourcing 的設計只有一條規則:不存狀態,存事件。當前狀態是事件序列的推導結果。

傳統方式:
  orders 表 → { id: 123, status: 'SHIPPED', amount: 1000 }

Event Sourcing:
  events 表 → [
    { orderId: 123, type: 'OrderCreated',   data: { amount: 1000 }, at: T1 },
    { orderId: 123, type: 'PaymentReceived', data: { txId: 'abc' }, at: T2 },
    { orderId: 123, type: 'OrderShipped',   data: { trackingNo: 'X99' }, at: T3 },
  ]

  當前狀態 = fold(events) = apply(OrderCreated, PaymentReceived, OrderShipped)

你不直接查「訂單現在的狀態是什麼」,你重播事件序列,從每個事件推導出狀態。OrderCreatedstatus 變成 PENDINGPaymentReceived 讓它變成 PROCESSINGOrderShipped 讓它變成 SHIPPED

這個「推導」讓一件事變得可能:你可以重播到任意時間點,看到那個時間點的系統狀態。也可以在不改動資料的情況下,重新定義「事件應該產生什麼狀態」——只要重播一遍就好。


Audit Trail 是副產品,不是主功能

Event Sourcing 最常被提到的好處是 audit log「免費附送」。這是真的,但把它當主要動機是倒果為因。

真正的主要動機是:事件是比狀態更豐富的業務語言

OrderShipped 事件記錄的不只是「狀態從 X 變成 Y」,而是「出貨這件事發生了」,附帶出貨時間、物流編號、操作者——這個語意是 UPDATE orders SET status = 'SHIPPED' 永遠無法表達的。Audit trail 是因為你把業務事件記錄下來而自然得到的,不是你為了 audit trail 才記錄事件。

如果你只是想要 audit trail,加一張 audit_logs 表或使用 PostgreSQL 的 temporal table 擴充,比引入完整的 Event Sourcing 便宜得多。


Projection:從事件流查詢狀態

Event Sourcing 立刻帶來一個實際問題:每次查詢都要重播所有事件嗎?

答案是否。你需要 projection——把事件流物化成查詢友好的讀取視圖。

events:
  OrderCreated { orderId: 123, userId: 456, amount: 1000 }
  PaymentReceived { orderId: 123 }
  OrderShipped { orderId: 123, trackingNo: 'X99' }

projection: order_read_model
  { orderId: 123, userId: 456, amount: 1000, status: 'SHIPPED', trackingNo: 'X99' }

Projection 是一個獨立的程序:訂閱 event stream,把每個事件的效果更新到讀取資料庫(可以是 PostgreSQL、Elasticsearch、Redis,視讀取需求而定)。這個讀取資料庫的 schema 完全不受 Write Model 限制,可以為查詢做任何形式的 denormalize。

這就是 Event Sourcing 和 CQRS 天然搭配的原因:Event Sourcing 讓 Write Model 只關心事件,CQRS 的 Read Model(projection)負責把事件轉成查詢友好的形式。你可以有多個不同目的的 projection——一個給用戶端、一個給報表、一個給即時分析——全部從同一個 event stream 建出來,不必修改 Write Model。


事件儲存:哪裡放、怎麼放

EventStore vs PostgreSQL

事件需要一個地方存。選項有兩個:

專用的 EventStore(如 EventStoreDB):天生支援 event stream 語意,內建 projection 引擎,有訂閱機制,append-only 的設計讓寫入非常快。代價是引入了另一個需要維運的系統。

PostgreSQL 作為 event store:在現有的 PostgreSQL 裡建一張 events 表,append-only insert,用 LISTEN/NOTIFY 或 CDC 觸發 projection 更新。簡單、不用新的基礎設施,但你要自己實作很多 EventStore 提供的功能。

大多數沒有極端寫入量需求的系統,PostgreSQL 就夠了。EventStoreDB 在你真的需要其功能時才引入。

Snapshot:解決「重播所有事件」的效能問題

一個訂單可能只有 10 個事件,但一個用戶帳戶可能有幾千個事件。每次查詢都從頭重播所有事件在實務上太慢。

解法是 snapshot:定期(例如每 100 個事件)儲存一次當前狀態的快照。重播時從最近的 snapshot 開始,只需要重播 snapshot 之後的事件。

[Event 1 ... Event 100] → Snapshot at Event 100
[Event 101 ... Event 200] → Snapshot at Event 200
[Event 201 ... Event 247] ← 只需要重播這 47 個

Snapshot 是效能優化,不是架構要求。事件本身仍然是真相來源,snapshot 只是加速讀取。


Schema 演進:Event Sourcing 最難的部分

事件是永久記錄——你不能回去「更新」一個已經發生的事件。這讓 schema 演進成為 Event Sourcing 最棘手的問題。

你在 2024 年定義了 OrderShipped 事件包含 { trackingNo }。2025 年業務需要在出貨事件裡加入 { carrier, estimatedDelivery }。問題是:2024 年存下來的那些 OrderShipped 事件沒有這兩個欄位,重播時會怎樣?

常見的三種策略:

Weak schema / Upcasting:重播時,遇到舊版本事件,用一個 upcaster 把它升級到新版本。這需要你永遠知道事件是哪個版本的。

Copy-and-transform:新建一個事件類型 OrderShippedV2,舊事件保持原樣,projection 同時處理兩種版本。

Event versioning:所有事件帶上版本號,讓 projection 按版本分別處理。

沒有哪個策略是免費的。Schema 演進在 Event Sourcing 裡比傳統資料庫 migration 複雜——傳統 DB migration 改 schema 就完事,Event Sourcing 要讓所有的歷史事件在新的解讀方式下仍然有意義。


什麼時候不需要 Event Sourcing

Event Sourcing 的維運成本很高——projection 要維護、snapshot 要管理、schema 演進要有策略、event replay 的測試比傳統 CRUD 複雜得多。在以下情況,這個成本沒有對應的收益:

業務不關心歷史。如果你的系統只需要當前狀態,歷史對業務沒有意義,Event Sourcing 帶來的複雜度沒有回報。加一個 updated_at 欄位通常就夠了。

沒有複雜的讀取需求。Event Sourcing 最大的好處是可以從同一份事件流建出不同的 projection。如果你的查詢需求簡單而固定,這個彈性用不到。

團隊剛接觸分散式系統。Event Sourcing 要求開發者用「事件」而不是「狀態」思考,這是一個思維轉換,在對分散式系統還不熟悉的團隊裡引入,學習曲線和 bug 的成本都很高。

Event Sourcing 真正適合的場景:業務流程複雜且歷史重要(金融、訂單履約、複雜 workflow)、讀取需求多樣且會演進、或者系統天生是 event-driven(IoT 感測器、日誌聚合)。


上一篇 → CQRS:讀寫模型分離

下一篇 → DDD 戰略設計