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 中的讀取操作(如
getById、getByName)是為 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 | 犧牲 Availability | Distributed Monolith(反模式) |
| Availability + Partition Tolerance | 犧牲 Consistency | Read 和 Write 可能不同步 |
CQRS 的優勢在於允許你對 Read 和 Write 端做出不同的 CAP 取捨:
- Write 端:選擇 Consistency + Availability,犧牲 Partition Tolerance(所有 Command Handler 運行在單一機器上)
- Read 端:選擇 Availability + Partition Tolerance,犧牲 Consistency(將讀取擴展到多台機器以提升擴展性)
小結#
- Commands 和 Queries 資料庫之間的同步過程稱為 Projection
- State-driven Projection 透過
IsSyncRequiredflag 驅動,適用於不使用 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 以提升擴展性