Synchronizing the Commands and Queries Databases#

本模組討論 Commands 與 Queries 資料庫之間的同步機制。當我們在前一個模組中引入了獨立的 Read 資料庫後,兩個資料庫之間就需要一個同步策略,這種同步也稱為 Projection

同步策略依觸發方式可分為兩大類:

  • State-driven Projection – 由資料庫狀態驅動
    • Synchronous(同步)
    • Asynchronous(非同步)
  • Event-driven Projection – 由 Domain Events 驅動

State-driven Projections#

State-driven Projection 的核心概念是在資料表中加入一個 flag(如 IsSyncRequiredIsDirty),每個 Aggregate 對應一個 flag。當 Aggregate 的任何資料被修改時,就將 flag 設為 true,背景同步程序會偵測到 flag 的變化,將資料同步到 Read 資料庫,然後重置 flag。

運作流程#

  1. 當 Student 或 Course 等 Aggregate 的資料被更新時,設定 IsSyncRequired = true
  2. 背景同步程序偵測到 flag 變化
  3. 將變更同步到 Read 資料庫(新增或更新記錄)
  4. 重置 flag

每個 Aggregate(Student、Course)各自擁有 IsSyncRequired flag,用於追蹤同步狀態

State-driven Projection 流程:Commands 端設定 IsSyncRequired → 同步到 Queries 端 → 重置 flag

Synchronization 表的優化#

可以額外建立一張小型的 Synchronization 表,只包含一個 IsSyncRequired flag。當任何 Aggregate 的 flag 被設定時,這張表的 flag 也會被設定。這樣背景程序只需要輪詢這張小表,而不用頻繁查詢所有 Aggregate 的表,減輕資料庫壓力。

兩種實作 Flag 的方式#

方式一:Database Triggers

  • 建立 Database Trigger 監控所有資料表的變更
  • 自動更新對應的 IsSyncRequired flag
  • 需要實作 Soft Delete(使用 IsDeleted flag 取代實際刪除)

Database Triggers 方式:Trigger 監控資料表變更,自動更新 IsSyncRequired flag,需實作 Soft Delete

方式二:在 Domain Model 中明確加入 Flag

  • 在 Student 和 Course 等 Entity 中加入 isSyncRequired 屬性
  • 當任何屬性被修改時,顯式地設定 flag
public class Student implements Entity {
    private String name;
    private String email;
    private boolean isSyncRequired;

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

可以使用 Hibernate 的 Event Listeners 或 Entity Framework 的 Change Tracker 來自動偵測帶有 IsSyncRequired flag 的 Entity,並同步更新 Synchronization 表。

優先選擇在 Domain Model 中明確實作 flag 的方式。顯式的實作大幅提升程式碼的可維護性。只有在無法控制 Domain Model 原始碼時,才選擇 Database Trigger 的方式。

State-driven Projection 的優點#

  • 直觀且容易使用
  • 若需要重建 Read 資料庫,只需將所有記錄的 flag 設為 true
  • 也可以選擇性地只重建部分記錄

Synchronous State-driven Projections#

前面描述的 State-driven Projection 是非同步的:應用程式更新 Commands 資料庫後,不等待同步完成就繼續運作,由背景程序負責同步。

Synchronous(同步)版本則是由應用程式本身執行同步,或等待背景程序完成同步後,才回應客戶端。

同步版本的特性#

  • 應用程式自行完成 Projection
  • 增加處理時間
  • Read 與 Write Model 立即一致,消除使用者的困惑

同步版本的缺點#

  • 很少在有兩個獨立資料庫時使用,因為它會顯著增加延遲
  • 不具備擴展性:每新增一個 Read 資料庫副本,延遲就會增加,因為主應用程式必須更新所有 Read 資料庫

一般不建議使用同步版本。若需要立即一致性,考慮將 Read 和 Write Model 放在同一個資料庫中以減少延遲,或者使用非同步 Projection。

實際例子#

  • Synchronous:Indexed Views – 關聯式資料庫的 Indexed View 會與來源表同步更新
  • Asynchronous:Database Replication – 資料庫副本是非同步更新的

Event-driven Projections#

Event-driven Projection 的主要差異在於它不是由資料庫狀態驅動,而是由 Domain Events 驅動。同步程序訂閱 Command 端產生的 Domain Events,並利用這些事件中的資訊來更新 Read 資料庫。

優點#

  • 擴展性極佳:應用程式只需將 Domain Events 發布到 Message Bus 上,可以有任意數量的 Projection 程序訂閱這些事件,不會對 Commands 資料庫產生額外壓力

缺點#

  • 無法輕易重建 Read 資料庫:如果沒有儲存 Domain Events,就無法從 State 反推出 Events;一旦遺漏某個事件,Read 資料庫就會失去同步
  • 若要將 Domain Events 與 State 一起儲存在 Commands 資料庫中,複雜度會大幅增加,此時可能應該直接轉向 Event Sourcing 架構

如何選擇 Projection 策略#

將 Projection 策略與持久化機制對齊:

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

Consistency#

擁有兩個資料庫而非一個,會在 Write 與 Read Model 之間引入延遲,可能導致使用者困惑。例如,註冊學生後,使用者可能無法立即在列表中看到該學生,誤以為操作失敗而重複註冊。

緩解一致性問題的方法#

  • Uniqueness Constraints:在 Commands 資料庫上設定唯一性約束,即使使用者嘗試重複操作也會收到錯誤訊息
  • Commands 資料庫始終立即一致:一致性問題只影響 Queries 資料庫

在 Command 執行期間如何查詢資料#

當需要在 Command 執行期間驗證資料(例如 Email 唯一性檢查)時:

  • 不應使用 Query:因為 Queries 使用的資料庫可能尚未與 Commands 資料庫同步
  • 應直接查詢 Commands 資料庫:使用 Repository 或 UnitOfWork 來查詢,因為 Commands 資料庫始終是最新的

Command 端的讀取操作(如 getStudentByIdgetCourseByName)是 Command 處理流程的一部分,其結果不會跨越應用程式邊界。這與 Query 的用途不同,不要將兩者混淆。

如果使用 Event Sourcing,則無法有效率地從 Commands 資料庫查詢當前狀態(因為儲存的是 Events 而非 State),此時不得不查詢 Read 資料庫,並處理由此帶來的一致性問題。

Eventual Consistency#

Eventual Consistency 是一種一致性模型,保證在沒有新的更新發生時,最終所有對該資料的存取都會回傳最後更新的值。

即時一致性是反直覺的#

現實世界本質上就是非同步且最終一致的。例如搬家後,駕照上的地址不會立即更新,需要去 DMV 換發。在此期間,駕照上的資訊是過時的。使用者在現實生活中已經習慣了 Eventual Consistency,因此在軟體中接受它也不會太困難。

提升使用者接受度的方法#

  • 顯示友善訊息:例如「學生註冊已提交,將在短時間內處理完成」,設定正確的期望
  • 本地更新 UI:提交後立即在使用者端更新畫面,等到下次重新整理時,Read 資料庫已經同步完成
  • 雙向通訊:Read Model 收到更新後主動通知 UI 更新搜尋結果(需要維持連線,複雜度較高)

在大多數情況下,一個簡單的訊息提示或本地資料更新就足夠了。

緩解 Eventual Consistency 的 UI 策略:顯示提示訊息、本地更新記錄、雙向通訊

Versioning#

當基於過時資料做決策的成本很高時,Eventual Consistency 可能會有問題。但這種情況極少發生,典型例外是高頻股票交易。

對於其他大多數場景,可以透過 Versioning 搭配 Optimistic Concurrency Control 來緩解:

運作方式#

  1. 在 Read 和 Write 資料庫中都維護 Aggregate 的版本號
  2. UI 從 Read 資料庫查詢資料時,取得版本號(例如 Alice v1)
  3. UI 發送 Command 時,附帶該版本號
  4. Write Model 比對 Command 中的版本號與 Aggregate 當前版本號
  5. 若版本不同,拋出 Concurrency Error 並拒絕變更
  6. 通知使用者重新整理畫面以取得最新資料

Versioning 流程:UI 從 Queries 取得 Alice v1,發送 Command 時附帶版本號

Optimistic Concurrency Control:Commands 端檢查版本號,若不一致則拒絕變更

將版本號納入 UI、Command 和 Read Model 之間所有通訊的一部分,即可實現 Optimistic Concurrency Control。

CQRS 與 CAP Theorem#

CAP Theorem 指出分散式系統無法同時滿足以下三項保證中的兩項以上:

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

CAP 的三種組合#

組合特性範例
Consistency + Availability無 Partition Tolerance,單一伺服器傳統關聯式資料庫
Consistency + Partition Tolerance犧牲 Availability,需要鎖定整個系統等待同步Distributed Monolith(反模式)
Availability + Partition Tolerance犧牲 Consistency,節點間可能暫時不同步大型分散式系統

CAP Theorem:Consistency、Availability、Partition Tolerance 三者最多只能同時滿足兩項

CQRS 如何在 CAP 中取得平衡#

CQRS 的優勢在於能對 Read 和 Write 做出不同的取捨

  • Write 端:選擇 Consistency + Availability,犧牲 Partition Tolerance
    • 所有 Command Handler 在單一機器上運作,確保一致性
  • Read 端:選擇 Availability + Partition Tolerance,犧牲完全一致性
    • 可將 Read 擴展到多台機器,實現可擴展性
    • 接受 Eventual Consistency

小結#

  • Commands 與 Queries 資料庫之間的同步稱為 Projection,分為 State-driven 和 Event-driven 兩種策略
  • State-driven Projection 透過在 Aggregate 表中加入 IsSyncRequired flag 來觸發同步,實作方式包括 Database Trigger 和在 Domain Model 中顯式設定(優先選擇後者)
  • State-driven Projection 可以是同步非同步的;同步版本不具擴展性,預設應選擇非同步
  • Event-driven Projection 透過訂閱 Domain Events 來驅動同步,擴展性更好,但無法輕易重建 Read 資料庫
  • 選擇 Projection 策略應與持久化機制對齊:無 Event Sourcing 用 State-driven,有 Event Sourcing 用 Event-driven
  • Eventual Consistency 在實務中通常可被接受,可透過友善提示、本地 UI 更新和 Versioning(Optimistic Concurrency Control)來緩解
  • CQRS 允許在 CAP Theorem 中對 Read 和 Write 做出不同取捨:Write 端重視一致性,Read 端重視可擴展性