命令查詢責任隔離 (CQRS) 是一種設計模式,會將數據存放區的讀取和寫入作業隔離成不同的數據模型。 這可讓每個模型獨立優化,並可改善應用程式的效能、延展性和安全性。
內容和問題
在傳統架構中,單一數據模型通常用於讀取和寫入作業。 這種方法很簡單,適用於基本 CRUD 作業(見圖 1)。
圖 1. 傳統的 CRUD 架構。
不過,隨著應用程式成長,將單一數據模型上的讀取和寫入作業優化變得越來越具有挑戰性。 讀取和寫入作業通常有不同的效能和調整需求。 傳統的 CRUD 架構不會考慮這種不對稱。 這會導致數個挑戰:
數據不符: 數據讀取和寫入表示法通常不同。 在更新期間所需的某些欄位在讀取期間可能不必要。
鎖定爭用: 相同數據集上的平行作業可能會導致鎖定爭用。
效能問題: 傳統方法可能會對效能產生負面影響,因為數據存放區和數據存取層的負載,以及擷取資訊所需的查詢複雜度。
安全性考慮: 當實體受限於讀取和寫入作業時,管理安全性會變得困難。 此重疊可能會公開非預期內容中的數據。
結合這些責任可能會導致過度複雜的模型嘗試執行太多動作。
解決方案
使用 CQRS 模式,將寫入作業(命令)與讀取作業區隔(查詢)。 命令負責更新數據。 查詢負責擷取數據。
瞭解命令。 命令應該代表特定的商務工作,而不是低階數據更新。 例如,在旅館預約應用程式中,使用 “Book hotel room” 而不是 “Set ReservationStatus to Reserved”。此方法更能反映使用者動作背後的意圖,並將命令與商務程式對齊。 若要確保命令成功,您可能需要精簡用戶互動流程、伺服器端邏輯,並考慮異步處理。
精簡區域 | 建議 |
---|---|
客戶端驗證 | 先驗證特定條件,再傳送命令以避免明顯的失敗。 例如,如果沒有可用的會議室,請在UI中停用 [書籍] 按鈕,並在UI中提供清楚且方便使用的訊息,說明為什麼無法預約。 此設定可減少不必要的伺服器要求,並提供立即意見反應給使用者,以提高其體驗。 |
伺服器端邏輯 | 增強商業規則,以正常處理邊緣案例和失敗。 例如,若要解決競爭狀況(多個使用者嘗試預訂最後一個可用房間),請考慮將使用者新增至等候清單或建議替代選項。 |
異步處理 | 您也可以 以異步方式 處理命令,方法是將它們放在佇列上,而不是以同步方式處理命令。 |
了解查詢。 查詢永遠不會改變數據。 相反地,它們會傳回以方便格式呈現所需數據的數據傳輸物件(DTO),而不需要任何定義域邏輯。 這種清楚的考慮區隔可簡化系統的設計和實作。
了解讀取和寫入模型分隔
將讀取模型與寫入模型分開,可藉由解決數據寫入和讀取的不同考慮,簡化系統設計和實作。 此區隔可改善清晰性、延展性和效能,但會產生一些取捨。 例如,O/RM 架構之類的 Scaffolding 工具無法從資料庫架構自動產生 CQRS 程式代碼,需要自定義邏輯來橋接差距。
下列各節探討兩種在 CQRS 中實作讀取和寫入模型區隔的主要方法。 每個方法都有獨特的優點和挑戰,例如同步處理和一致性管理。
在單一數據存放區中分隔模型
此方法代表 CQRS 的基礎層級,其中讀取和寫入模型會共用單一基礎資料庫,但維護其作業的不同邏輯。 藉由定義個別考慮,此策略可增強簡單性,同時為一般使用案例提供延展性和效能的優點。 基本的 CQRS 架構可讓您在依賴共用資料存放區時,從讀取模型劃定寫入模型(請參閱圖 2)。
圖 2. 具有單一數據存放區的基本 CQRS 架構。
此方法藉由定義處理寫入和讀取考慮的不同模型,來改善清晰、效能和延展性:
寫入模型: 設計用來處理更新或保存數據的命令。 它包含驗證、網域邏輯,以及藉由優化交易完整性和商務程序來確保數據一致性。
讀取模型: 設計來提供用於擷取數據的查詢。 其著重於產生 DTO(數據傳輸物件)或針對呈現層優化的投影。 它可藉由避免網域邏輯來增強查詢效能和回應性。
在個別數據存放區中實體區隔模型
更進階的 CQRS 實作會針對讀取和寫入模型使用不同的數據存放區。 分隔讀取和寫入資料存放區可讓您調整每個存放區以符合負載。 它也可讓您針對每個數據存放區使用不同的儲存技術。 您可以使用檔案資料庫進行讀取資料存放區和寫入資料存放區的關係資料庫(請參閱圖 3)。
圖 3. 具有個別讀取和寫入數據存放區的 CQRS 架構。
同步處理個別的數據存放區: 使用個別存放區時,您必須確保兩者保持同步。常見的模式是每當它更新資料庫時,寫入模型就會發佈事件,讀取模型會用來重新整理其數據。 如需使用事件的詳細資訊,請參閱 事件驅動架構樣式。 不過,您通常無法將訊息代理程式和資料庫編列到單一分散式交易中。 因此,在更新資料庫和發佈事件時,保證一致性可能會面臨挑戰。 如需詳細資訊,請參閱 等冪訊息處理。
讀取數據存放區: 讀取數據存放區可以使用針對查詢優化的專屬數據架構。 例如,它可以儲存 具體化檢視 數據,以避免複雜的聯結或 O/RM 對應。 讀取存放區可以是寫入存放區的唯讀複本,或具有不同的結構。 部署多個只讀複本可藉由減少延遲和增加可用性,特別是在分散式案例中改善效能。
CQRS 的優點
獨立調整。 CQRS 可讓讀取和寫入模型獨立調整,這有助於將鎖定爭用降至最低,並改善負載下的系統效能。
優化的數據架構。 讀取作業可以使用針對查詢優化的架構。 寫入作業會使用針對更新優化的架構。
安全性。 藉由分隔讀取和寫入,您可以確保只有適當的網域實體或作業有權對數據執行寫入動作。
區分不同的考量。 分割讀取和寫入責任會導致更簡潔、更容易維護的模型。 寫入端通常會處理複雜的商業規則,而讀取端可以保持簡單且著重於查詢效率。
更簡單的查詢。 當您將具體化檢視儲存在讀取資料庫中時,應用程式可以在查詢時避免複雜的聯結。
實作問題和考慮
實作此模式的一些挑戰包括:
增加的複雜度。 雖然 CQRS 的核心概念很簡單,但它可能會對應用程式設計帶來顯著的複雜度,尤其是在與事件來源模式結合時。
傳訊挑戰。 雖然傳訊不是 CQRS 的需求,但您通常會使用它來處理命令併發佈更新事件。 涉及傳訊時,系統必須考慮潛在的問題,例如訊息失敗、重複專案和重試。 如需處理具有不同優先順序之命令的策略,請參閱 優先順序佇列 指導方針。
最終一致性。 當讀取和寫入資料庫分開時,讀取數據可能不會立即反映最新的變更,導致過時的數據。 確保讀取模型存放區會保持 up-to日期,寫入模型存放區中的變更可能會很困難。 此外,偵測及處理使用者對過時數據採取動作的案例需要仔細考慮。
使用 CQRS 模式的時機
CQRS 模式適用於需要資料修改(命令)和數據查詢(讀取)之間清楚分隔的案例。 請考慮在下列情況下使用 CQRS:
Collaborative 網域: 在多個用戶同時存取和修改相同數據的環境中,CQRS 有助於減少合併衝突。 命令可以包含足夠的粒度來防止衝突,而且系統可以解決在命令邏輯中發生的任何動作。
工作型使用者介面: 應用程式,以一系列步驟或複雜的領域模型,引導使用者完成複雜的程式,受益於 CQRS。
寫入模型具有具有商業規則、輸入驗證和商務驗證的完整命令處理堆疊。 寫入模型可能會將一組相關聯的對象視為數據變更的單一單位,稱為領域驅動設計術語中的 匯總。 寫入模型也可能確保這些物件一律處於一致狀態。
讀取模型沒有商業規則或驗證堆疊。 它會傳回用於檢視模型的 DTO。 讀取模型最終與寫入模型一致。
效能微調: 必須分別微調數據讀取效能與數據寫入效能的系統,特別是當讀取數目大於寫入數目時,CQRS 會受益。 讀取模型會水平調整以處理大型查詢磁碟區,而寫入模型在較少的實例上執行,以將合併衝突降到最低,並維持一致性。
區分開發考慮: CQRS 可讓小組獨立工作。 一個小組著重於在寫入模型中實作複雜的商業規則,而另一個小組則開發讀取模型和使用者介面元件。
進化系統: CQRS 支援隨著時間演進的系統。 它可配合新的模型版本、商務規則的頻繁變更或其他修改,而不會影響現有的功能。
系統整合: 與其他子系統整合的系統,特別是使用事件來源的系統,即使子系統暫時失敗,仍可供使用。 CQRS 會隔離失敗,防止單一元件影響整個系統。
不使用 CQRS 的時機
在下列情況下避免 CQRS:
網域或商務規則很簡單。
簡單的 CRUD 樣式使用者介面和數據存取作業就已足夠。
工作負載設計
架構設計人員應該評估如何在工作負載的設計中使用 CQRS 模式,以解決 azure Well-Architected Framework 支柱中涵蓋的目標和原則。 例如:
要素 | 此模式如何支援支柱目標 |
---|---|
效能效率可透過調整規模、資料、程式碼達到最佳化,有效率地協助您的工作負載符合需求。 | 高讀寫工作負載中的讀取和寫入作業區隔可針對每個作業的特定用途啟用目標效能和調整優化。 - PE:05 調整和分割 - PE:08 數據效能 |
如同任何設計決策,請考慮對其他可能以此模式導入之目標的任何取捨。
結合事件來源和 CQRS
CQRS 的某些實作會納入 事件來源模式,此模式會將系統的狀態儲存為時間序列的事件。 每個事件都會擷取在指定時間對數據所做的變更。 若要判斷目前的狀態,系統會依序重新執行這些事件。 在這裡組合中:
事件存放區是 寫入模型 和單一事實來源。
讀取模型 從這些事件產生具體化檢視,通常是以高度反正規化的形式。 這些檢視會藉由量身打造結構來查詢和顯示需求來優化數據擷取。
結合事件來源和 CQRS 的優點
更新寫入模型的相同事件可作為讀取模型的輸入。 讀取模型接著可以建置目前狀態的即時快照集。 這些快照集會藉由提供有效率且預先計算的數據檢視來優化查詢。
系統不直接儲存目前狀態,而是使用事件數據流作為寫入存放區。 此方法可減少匯總的更新衝突,並增強效能和延展性。 系統可以異步處理這些事件,以建置或更新讀取存放區的具體化檢視。
因為事件存放區可作為單一事實來源,因此您可以重新產生具體化檢視,或藉由重新執行歷程記錄事件來適應讀取模型中的變更。 基本上,具體化檢視會作為持久且只讀的快取,針對快速且有效率的查詢優化。
合併事件來源和 CQRS 時的考慮
在結合 CQRS 模式與事件來源模式之前,請先評估下列考慮:
最終一致性: 因為寫入和讀取存放區不同,讀取存放區的更新可能會落後於事件產生,因而產生最終一致性。
複雜度增加: 將 CQRS 與事件來源結合需要不同的設計方法,這可以讓成功的實作更具挑戰性。 您必須撰寫程式代碼來產生、處理及處理事件,以及為讀取模型組合或更新檢視。 不過,事件來源可簡化網域模型化,並可讓您藉由保留所有數據變更的歷程記錄和意圖,輕鬆地重建或建立新的檢視。
檢視產生效能: 產生讀取模型的具體化檢視可能會耗用大量的時間和資源。 這同樣適用於透過重新執行和處理特定實體或集合的事件來投影數據。 當計算牽涉到長時間分析或加總值時,這個效果會增加,因為必須檢查所有相關事件。 定期實作數據的快照集。 例如,儲存匯總總計的定期快照集(發生特定動作的次數)或實體的目前狀態。 快照集可減少重複處理完整事件歷程記錄的需求,以改善效能。
CQRS 模式的範例
下列程式代碼顯示 CQRS 實作範例中的一些擷取,這些範例會針對讀取和寫入模型使用不同的定義。 模型介面不會指定基礎數據存放區的任何功能,而且它們可以獨立演進和微調,因為這些介面會分開。
下列程式代碼顯示讀取模型定義。
// Query interface
namespace ReadModel
{
public interface ProductsDao
{
ProductDisplay FindById(int productId);
ICollection<ProductDisplay> FindByName(string name);
ICollection<ProductInventory> FindOutOfStockProducts();
ICollection<ProductDisplay> FindRelatedProducts(int productId);
}
public class ProductDisplay
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal UnitPrice { get; set; }
public bool IsOutOfStock { get; set; }
public double UserRating { get; set; }
}
public class ProductInventory
{
public int Id { get; set; }
public string Name { get; set; }
public int CurrentStock { get; set; }
}
}
系統可讓使用者對產品進行評分。 應用程式程式代碼會使用 RateProduct
下列程式代碼所示的命令來執行這項作業。
public interface ICommand
{
Guid Id { get; }
}
public class RateProduct : ICommand
{
public RateProduct()
{
this.Id = Guid.NewGuid();
}
public Guid Id { get; set; }
public int ProductId { get; set; }
public int Rating { get; set; }
public int UserId {get; set; }
}
系統會使用 類別 ProductsCommandHandler
來處理應用程式所傳送的命令。 用戶端通常會透過佇列等傳訊系統,將命令傳送至網域。 命令處理程式會接受這些命令,並叫用網域介面的方法。 每個命令的粒度是設計來減少衝突要求的機會。 下列程式代碼顯示 類別的 ProductsCommandHandler
大綱。
public class ProductsCommandHandler :
ICommandHandler<AddNewProduct>,
ICommandHandler<RateProduct>,
ICommandHandler<AddToInventory>,
ICommandHandler<ConfirmItemShipped>,
ICommandHandler<UpdateStockFromInventoryRecount>
{
private readonly IRepository<Product> repository;
public ProductsCommandHandler (IRepository<Product> repository)
{
this.repository = repository;
}
void Handle (AddNewProduct command)
{
...
}
void Handle (RateProduct command)
{
var product = repository.Find(command.ProductId);
if (product != null)
{
product.RateProduct(command.UserId, command.Rating);
repository.Save(product);
}
}
void Handle (AddToInventory command)
{
...
}
void Handle (ConfirmItemsShipped command)
{
...
}
void Handle (UpdateStockFromInventoryRecount command)
{
...
}
}
下一步
下列模式和指引在實作此模式時很有用:
- 水平、垂直和功能性數據分割。 說明將數據分割成可個別管理和存取的數據分割的最佳做法,以改善延展性、減少爭用並優化效能。