Introducing a Separate Database for Queries#

本模組討論當 Commands 和 Queries 使用獨立資料庫時,如何處理兩者之間的同步問題,包含 Projection 策略、Eventual Consistency 的取捨,以及與 CAP Theorem 的關聯。

Projection:Commands 與 Queries 資料庫的同步#

當讀寫分別使用不同資料庫時,需要一個機制將 Commands 資料庫的變更同步到 Queries 資料庫。這個同步過程稱為 Projection

Projection 有兩大類:

  • State-driven Projection:由資料庫的狀態(state)驅動
  • Event-driven Projection:由 Domain Events 驅動

State-driven Projection#

State-driven Projection 透過監控 Commands 資料庫中的狀態變更來觸發同步。具體做法是在 Aggregate 的資料表中加入 IsSyncRequired flag,每當 Aggregate 被修改時,就將此 flag 設為 true

public class Student implements Entity {
    private String name;
    private String email;
    private boolean isSyncRequired;

    //getters & setters

    public void removeEnrollment(Enrollment enrollment, String comment) {
        enrollments.remove(enrollment);
        Disenrollment disenrollment = new Disenrollment(
            enrollment.getStudent(), enrollment.getCourse(), comment);
        disenrollments.add(disenrollment);
        isSyncRequired = true;
    }
}

另外還需要一個獨立的 Synchronization table 來追蹤哪些 Aggregate 需要同步。背景程序會監控這些 flag,並在偵測到變更時重建 Query 資料庫。

設定 Flag 的兩種方式#

有兩種方式可以設定 IsSyncRequired flag:

  • Database Triggers:在資料庫層級自動觸發
  • Domain Model 中明確設定:在程式碼中手動設定 flag

預設應選擇**明確設定(explicit)**的方式。在程式碼中明確表達行為,能大幅提升可維護性。只有在無法控制 Domain Model 原始碼時,才考慮使用 Database Triggers。

同步 vs 非同步 State-driven Projection#

State-driven Projection 又分為兩種模式:

非同步(Asynchronous)

  • 應用程式更新 Commands 資料庫後繼續工作,不等待同步完成
  • 由獨立的背景程序負責 Projection
  • 擴展性較佳

同步(Synchronous)

  • 應用程式自己執行 Projection,或等待背景程序完成後才回應客戶端
  • Read 和 Write Model 會立即一致
  • 增加處理時間,擴展性差

同步 Projection 不具擴展性。每新增一個 Read 資料庫副本,主應用程式都必須同步更新所有副本,延遲會持續增加。預設應選擇非同步方式。

常見的實作範例:

  • Indexed Views:同步 Projection 的例子,隨資料表一起立即更新
  • Database Replication:非同步 Projection 的例子,副本以非同步方式更新

Event-driven Projection#

Event-driven Projection 不依賴資料庫狀態,而是由 Domain Events 驅動。Projection 程序訂閱 Command 端產生的 Domain Events,並利用這些事件的資訊來更新 Read 資料庫。

優點

  • 擴展性極佳,比非同步 State-driven Projection 更好
  • 可使用 Message Bus 發布 Domain Events,多個 Projection 程序訂閱,不會對 Commands 資料庫造成額外負擔

缺點

  • 無法輕易重建 Read 資料庫。因為 Commands 資料庫只存最終狀態,不存 Domain Events,若遺漏任何事件,Read 資料庫將失去同步且無法從頭重建

如果要將 Domain Events 也存入 Commands 資料庫以解決重建問題,那其實應該直接轉向 Event Sourcing 架構。

如何選擇 Projection 策略#

選擇的經驗法則:

  • 沒有 Event Sourcing(只保存最終狀態):使用 State-driven Projection
  • 有 Event Sourcing(Domain Events 為一等公民):使用 Event-driven Projection,可透過重新播放所有事件來重建 Read 資料庫

將 Projection 策略與你的持久化機制對齊(align)。

Consistency:一致性問題#

使用兩個獨立資料庫會帶來 Write 和 Read Model 之間的延遲(latency)。這可能造成使用者困惑,例如:註冊了一個學生後,使用者在畫面上看不到新資料,以為操作失敗,再次註冊導致重複記錄。

緩解一致性問題的方式#

  • Uniqueness Constraints:在 Commands 資料庫上設定唯一性約束,即使使用者重複提交,系統也會回傳錯誤訊息防止重複
  • Commands 資料庫始終立即一致:一致性問題只發生在 Queries 資料庫,Commands 資料庫本身永遠是最新的

在 Command 執行期間查詢資料庫#

一個常見問題:在 Command Handler 中需要查詢資料(例如檢查 email 唯一性),應該查詢哪個資料庫?

  • 不應使用 Query(Queries 資料庫可能不是最新的)
  • 應查詢 Commands 資料庫,使用 Repository 或 UnitOfWork,因為它永遠是最新的
  • 這些讀取操作是 Command 處理流程的一部分,結果不會跨越應用程式邊界,不算是 Query
public class StudentRepository {
    public Student getById(long id) {
        return unitOfWork.get<Student>(id);
    }
}

public class CourseRepository {
    public Course getByName(String name) {
        return unitOfWork.query<Course>()
            .singleOrDefault(x -> x.Name == name);
    }
}

這些 Repository 中的讀取操作(如 getByIdgetByName)是為 Commands 服務的,不是為 Queries 服務的。如果使用 Event Sourcing,由於 Commands 資料庫儲存的是事件而非狀態,無法有效查詢當前狀態,此時必須查詢 Read 資料庫並處理可能的不一致。

Eventual Consistency#

引入獨立的 Read 資料庫後,Query 端不會立即一致,而是 Eventually Consistent

即時一致性是反直覺的#

現實世界中,變更本來就不是即時一致的。例如搬家後,駕照上的地址不會立即更新,要去 DMV 才能換發。現實世界本質上是非同步且最終一致的。使用者其實能很快學會接受 Eventual Consistency。

緩解使用者困惑的技巧#

  • 顯示提示訊息:例如「學生註冊已提交,將在稍後完成處理」
  • 在客戶端本地更新:提交後先在使用者端顯示新資料,等下次重新整理時 Queries 資料庫已同步完成
  • 雙向通訊:Read Model 收到更新後,主動推送訊息到 UI(需要維持持久連線,增加複雜度)

Versioning(版本控制)#

Eventual Consistency 在某些情境下可能有問題:當基於過期資料做決策的代價很高時(例如高頻交易)。

針對大多數情境,可以透過 Optimistic Concurrency Control 來緩解:

  • 在 Read 和 Write 資料庫中都維護 Aggregate 的 Version Number
  • UI 從 Read Model 取得資料時,同時取得版本號
  • 發送 Command 時將版本號一併送出
  • Write Model 比對版本號,若不一致則拒絕變更並要求使用者重新整理

這樣可以確保使用者不會基於過期資料做出錯誤操作。

CQRS 與 CAP Theorem#

CAP Theorem 指出,分散式資料儲存系統不可能同時滿足以下三項保證,最多只能擇其二:

  • Consistency:每次讀取都能取得最新寫入的值或錯誤
  • Availability:每個請求都能收到回應
  • Partition Tolerance:即使節點間的訊息遺失或延遲,系統仍能繼續運作

三種組合的對應情境:

組合說明範例
Consistency + Availability犧牲 Partition Tolerance單一節點的關聯式資料庫
Consistency + Partition Tolerance犧牲 AvailabilityDistributed Monolith(反模式)
Availability + Partition Tolerance犧牲 ConsistencyRead 和 Write 可能不同步

CQRS 的優勢在於允許你對 Read 和 Write 端做出不同的 CAP 取捨

  • Write 端:選擇 Consistency + Availability,犧牲 Partition Tolerance(所有 Command Handler 運行在單一機器上)
  • Read 端:選擇 Availability + Partition Tolerance,犧牲 Consistency(將讀取擴展到多台機器以提升擴展性)

小結#

  • Commands 和 Queries 資料庫之間的同步過程稱為 Projection
  • State-driven Projection 透過 IsSyncRequired flag 驅動,適用於不使用 Event Sourcing 的場景;設定 flag 時優先選擇明確的程式碼方式而非 Database Triggers
  • State-driven Projection 分為同步非同步兩種,預設選擇非同步以獲得更好的擴展性
  • Event-driven Projection 由 Domain Events 驅動,擴展性最佳,但無法輕易重建 Read 資料庫;適用於 Event Sourcing 架構
  • 引入獨立 Read 資料庫意味著接受 Eventual Consistency,可透過 Uniqueness Constraints、提示訊息、本地更新、Optimistic Concurrency Control 等方式緩解
  • CQRS 與 CAP Theorem 密切相關:CQRS 允許在 Read 和 Write 端做出不同的 CAP 取捨,Write 端重視 Consistency,Read 端重視 Partition Tolerance 以提升擴展性