命令查詢責任隔離 (CQRS) 是一種設計模式,會將數據存放區的讀取和寫入作業隔離成不同的數據模型。 此方法可讓每個模型獨立優化,並可改善應用程式的效能、延展性和安全性。
內容和問題
在傳統架構中,單一數據模型通常用於讀取和寫入作業。 這種方法很簡單,適合用於基本建立、讀取、更新和刪除 (CRUD) 作業。
隨著應用程式成長,在單一數據模型上優化讀取和寫入作業可能會越來越困難。 讀取和寫入作業通常有不同的效能和調整需求。 傳統的 CRUD 架構不會將此不對稱納入考慮,這可能會導致下列挑戰:
數據不符: 數據讀取和寫入表示法通常不同。 在更新時所需的某些欄位,可能在讀取作業時不必要。
鎖定爭用: 相同數據集上的平行作業可能會導致鎖定爭用。
效能問題: 由於數據存放區和數據存取層的負載,以及擷取資訊所需的查詢複雜度,傳統方法可能會對效能產生負面影響。
安全性挑戰: 當實體受限於讀取和寫入作業時,管理安全性可能會很困難。 此重疊可能會公開非預期內容中的數據。
結合這些責任可能會導致過於複雜的模型。
解決方法
使用 CQRS 模式,將寫入作業或 命令與讀取作業或 查詢分開。 命令會更新數據。 查詢會擷取數據。 CQRS 模式在需要命令與讀取之間清楚分隔的案例中很有用。
瞭解命令。 命令應該代表特定的商務工作,而不是低階數據更新。 例如,在旅館預約應用程式中,使用 “Book hotel room” 命令,而不是 “Set ReservationStatus to Reserved”。此方法可更妥善地擷取使用者的意圖,並將命令與商務程式對齊。 若要協助確保命令成功,您可能需要精簡用戶互動流程和伺服器端邏輯,並考慮異步處理。
改進區域 建議 客戶端驗證 在傳送命令之前先驗證特定條件,以避免明顯的失敗。 例如,如果沒有可用的房間,請停用 [預訂] 按鈕,並在介面中提供清楚且方便使用的訊息,說明為什麼無法預訂。 此設定可減少不必要的伺服器要求,並提供立即意見反應給使用者,以增強其體驗。 伺服器端邏輯 增強商業規則,以正常處理邊緣案例和失敗。 例如,若要解決競爭狀況,例如嘗試預訂最後一個可用空間的多個使用者,請考慮將使用者新增至等候清單或建議替代方案。 異步處理 以異步方式處理命令,方法是將它們放在佇列中,而不是以同步方式處理命令。 理解查詢。 查詢永遠不會改變數據。 相反地,它們會傳回以方便格式呈現所需數據的數據傳輸物件(DTO),而不需要任何定義域邏輯。 這種不同的責任區隔可簡化系統的設計和實作。
分離讀取模式和寫入模式
將讀取模型與寫入模型分開,可藉由解決數據寫入和數據讀取的特定考慮,簡化系統設計和實作。 此區隔可改善清晰度、延展性和效能,但會產生取捨。 例如,像 O/RM 對象關係映射框架這樣的 Scaffolding 工具無法從資料庫架構自動產生 CQRS 程式碼,因此您需要自定義邏輯來填補這個差距。
下列各節說明兩種在 CQRS 中實作讀取模型和寫入模型區隔的主要方法。 每個方法都有獨特的優點和挑戰,例如同步處理和一致性管理。
在單一數據存放區中分隔模型
此方法代表 CQRS 的基礎層級,其中讀取和寫入模型會共用單一基礎資料庫,但維護其作業的不同邏輯。 基本的 CQRS 架構可讓您在依賴共用資料存放區時,從讀取模型劃定寫入模型。
此方法藉由定義處理讀取和寫入考慮的不同模型,以改善清晰、效能和延展性。
寫入模型 的設計目的是要處理更新或保存數據的命令。 它包含驗證和網域邏輯,並藉由優化交易完整性和商務程序,協助確保數據一致性。
讀取模型 的設計目的是為擷取數據的查詢提供服務。 其著重於產生針對呈現層優化的 DTO 或投影。 它可藉由避免網域邏輯來增強查詢效能和回應性。
不同數據存放區中的個別模型
更進階的 CQRS 實作會針對讀取和寫入模型使用不同的數據存放區。 分隔讀取和寫入數據存放區可讓您調整每個模型以符合負載。 它也可讓您針對每個數據存放區使用不同的儲存技術。 您可以針對讀取數據存放區使用文件資料庫,針對寫入數據存放區使用關係資料庫。
當您使用不同的數據存放區時,必須確定兩者保持同步處理。 常見的模式是在更新資料庫時,讓寫入模型發佈事件,讀取模型用來重新整理其數據。 如需如何使用事件的詳細資訊,請參閱 事件驅動架構樣式。 因為您通常無法將訊息代理程式和資料庫編列到單一分散式交易中,因此當您更新資料庫和發佈事件時,可能會發生一致性挑戰。 如需詳細資訊,請參閱 等冪訊息處理。
讀取數據存放區可以使用自己針對查詢優化的數據架構。 例如,它可以儲存 具體化檢視 數據,以避免複雜的聯結或 O/RM 對應。 讀取數據存放區可以是寫入存放區的唯讀複本,或具有不同的結構。 部署多個只讀複本可藉由減少延遲和增加可用性,特別是在分散式案例中改善效能。
CQRS 的優點
獨立縮放。 CQRS 可讓讀取模型和寫入模型獨立調整。 這種方法有助於將鎖定爭用降至最低,並改善負載下的系統效能。
優化的數據架構。 讀取作業可以使用針對查詢優化的架構。 寫入作業會使用一套針對更新優化的架構。
安全。 藉由分隔讀取和寫入,您可以確保只有適當的網域實體或作業有權對數據執行寫入動作。
職責分離。 分隔讀取和寫入責任會導致更簡潔、更容易維護的模型。 寫入端通常會處理複雜的商業規則。 讀取端可以保持簡單且著重於查詢效率。
更簡單的查詢。 當您將具體化檢視儲存在讀取資料庫中時,應用程式可以在查詢時避免複雜的聯結。
問題和考慮
當您決定如何實作此模式時,請考慮下列幾點:
增加複雜度。 CQRS 的核心概念很簡單,但是它可以在應用程式設計中帶來顯著的複雜性,特別是與 事件來源模式結合時。
傳訊挑戰。 傳訊並非 CQRS 的需求,但您通常會使用它來處理命令併發佈更新事件。 包含訊息時,系統必須考慮潛在的問題,例如訊息失敗、重複訊息和重新嘗試。 如需處理具有不同優先順序之命令之策略的詳細資訊,請參閱 優先順序佇列。
最終一致性。 當讀取資料庫和寫入資料庫分開時,讀取數據可能不會立即顯示最新的變更。 此延遲會導致過時的數據。 確保讀取模型存放區會保持 up-to日期,且寫入模型存放區中的變更可能具有挑戰性。 此外,偵測和處理使用者對過時資料進行操作所需的各種情境需要仔細考慮。
使用此模式的時機
當下列情況時,請使用此模式:
您在共同作業環境中工作。 在多個用戶同時存取和修改相同數據的環境中,CQRS 有助於減少合併衝突。 命令可以包含足夠的粒度來防止衝突,而且系統可以解決命令邏輯內發生的任何衝突。
您有任務導向的使用者介面。 以一系列步驟或具有複雜領域模型來引導使用者完成複雜程式的應用程式受益於 CQRS。
寫入模型具有具有商業規則、輸入驗證和商務驗證的完整命令處理堆疊。 寫入模型可能會將一組相關聯的對象視為數據變更的單一單位,這稱為領域驅動設計術語中的 匯總 。 寫入模型也可能有助於確保這些物件一律處於一致狀態。
讀取模型沒有商業規則或驗證堆疊。 它會傳回用於檢視模型的 DTO。 讀取模型最終與寫入模型一致。
您需要對效能進行微調。 必須將數據讀取效能和數據寫入效能分開微調的系統可以從 CQRS 中受益。 當讀取數目大於寫入數目時,這種模式特別有用。 讀取模型會水平擴展以處理大量查詢。 寫入模型會在較少的實例上執行,以將合併衝突降到最低,並維持一致性。
您擁有開發關注點的分離。 CQRS 可讓小組獨立工作。 一個小組會在寫入模型中實作複雜的商業規則,另一個小組會開發讀取模型和使用者介面元件。
您有不斷演進的系統。 CQRS 支援隨著時間發展的系統。 它可配合新的模型版本、商務規則的頻繁變更或其他修改,而不會影響現有的功能。
您需要系統整合: 與其他子系統整合的系統,特別是使用事件來源模式的系統,即使子系統暫時失敗,仍可供使用。 CQRS 會隔離失敗,以防止單一元件影響整個系統。
當下列情況時,此模式可能不適合:
網域或商務規則很簡單。
簡單的 CRUD 樣式使用者介面和數據存取作業就已足夠。
工作負載設計
評估如何在工作負載的設計中使用 CQRS 模式,以解決 Azure Well-Architected Framework 支柱中涵蓋的目標和原則。 下表提供此模式如何支援效能效率要素目標的指引。
支柱 | 此模式如何支援支柱目標 |
---|---|
效能效率 可透過調整、數據和程式碼的優化,有效率地協助您的工作負載符合需求。 | 在高讀寫比工作負載中,將讀取操作和寫入操作分離,能夠針對每種操作的特定用途進行專門的效能和擴展優化。 - PE:05 擴展和分割 - PE:08 數據效能 |
請考慮針對此模式可能引入之其他要素目標的任何取捨。
結合事件來源和 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)
{
...
}
}
後續步驟
當您實作此模式時,下列資訊可能相關:
- 數據分割指引 說明如何將數據分割成不同的區塊,並提供最佳實務,以便您能夠分別管理和存取,進而改善擴充性、降低爭用,以及優化效能。