CQRS 代表命令和查詢責任隔離,這是分隔數據存放區讀取和更新作業的模式。 在應用程式中實作 CQRS 可以最大化其效能、延展性和安全性。 遷移至 CQRS 所建立的彈性可讓系統隨著時間改善,並防止更新命令在網域層級造成合併衝突。
內容和問題
在傳統架構中,使用相同的數據模型來查詢和更新資料庫。 這很簡單,適用於基本 CRUD 作業。 不過,在更複雜的應用程式中,此方法可能會變得不複雜。 例如,在讀取端,應用程式可能會執行許多不同的查詢,並傳回具有不同圖形的數據傳輸物件(DTO)。 對象對應可能會變得複雜。 在寫入端,模型可能會實作複雜的驗證和商業規則。 因此,您最終可能會有過於複雜的模型,而該模型會執行太多動作。
讀取和寫入工作負載通常是非對稱的,其效能和規模需求非常不同。
數據讀取和寫入表示法通常不符,例如必須正確更新的其他數據行或屬性,即使它們不是作業的一部分也一樣。
在相同數據集上平行執行作業時,可能會發生數據爭用。
由於數據存放區和數據存取層的負載,以及擷取資訊所需的查詢複雜度,傳統方法會對效能產生負面影響。
管理安全性和許可權可能會變得複雜,因為每個實體都受限於讀取和寫入作業,這可能會在錯誤的內容中公開數據。
解決方案
CQRS 會使用 命令 來更新數據,以及 查詢 來讀取數據,將讀取和寫入分隔至不同的模型。
- 命令應該是以工作為基礎的,而不是以數據為中心的。 (“預訂酒店房間”,而不是將 ReservationStatus 設定為保留)。 這可能需要對用戶互動樣式進行一些對應的變更。 另一個部分是查看修改商業規則來處理這些命令,以更頻繁地成功。 支援這項功能的技術之一是在用戶端上執行一些驗證規則,甚至在傳送命令之前,甚至可能停用按鈕,解釋 UI 上的原因(「沒有剩餘會議室」)。 如此一來,伺服器端命令失敗的原因可能會縮小到競爭條件(兩個使用者試圖預訂最後一個房間),甚至有時可以使用一些更多的數據和邏輯來解決(將來賓放在等候清單中)。
- 命令可能會放在佇列上以進行 異步處理,而不是以同步方式處理。
- 查詢永遠不會修改資料庫。 查詢會傳回未封裝任何領域知識的 DTO。
然後,模型可以隔離,如下圖所示,雖然這不是絕對需求。
擁有個別的查詢和更新模型可簡化設計和實作。 不過,其中一個缺點是,CQRS 程式代碼無法使用 O/RM 工具等 Scaffolding 機制自動從資料庫架構產生(不過,您將能夠在產生的程式代碼之上建置自定義)。
為了獲得更大的隔離,您可以實際分隔讀取數據與寫入數據。 在此情況下,讀取資料庫可以使用自己針對查詢優化的數據架構。 例如,它可以儲存 數據的具體化檢視 ,以避免複雜的聯結或複雜的 O/RM 對應。 它甚至可以使用不同類型的數據存放區。 例如,寫入資料庫可能是關係型資料庫,而讀取資料庫是檔資料庫。
如果使用個別的讀取和寫入資料庫,則必須保持同步。一般而言,只要寫入模型更新資料庫,就會發佈事件來完成。 如需使用事件的詳細資訊,請參閱 事件驅動架構樣式。 由於訊息代理程式和資料庫通常無法編列到單一分散式交易中,因此在更新資料庫和發佈事件時保證一致性可能會面臨挑戰。 如需詳細資訊,請參閱 等冪訊息處理的指引。
讀取存放區可以是寫入存放區的唯讀複本,或者讀取和寫入存放區可以有不同的結構。 使用多個只讀複本可以增加查詢效能,特別是在只讀複本靠近應用程式實例的分散式案例中。
分隔讀取和寫入存放區也允許適當地調整每個存放區以符合負載。 例如,讀取存放區通常遇到比寫入存放區更高的負載。
CQRS 的某些實作會使用 事件來源模式。 使用此模式時,應用程式狀態會儲存為事件序列。 每個事件都代表一組數據的變更。 目前的狀態是藉由重新執行事件來建構。 在 CQRS 內容中,事件來源的其中一個優點是,相同的事件可用來通知其他元件,特別是通知讀取模型。 讀取模型會使用事件來建立目前狀態的快照集,這會更有效率地進行查詢。 不過,事件來源會增加設計的複雜性。
CQRS 的優點包括:
- 獨立調整。 CQRS 可讓讀取和寫入工作負載獨立調整,而且可能會導致鎖定爭用較少。
- 優化的數據架構。 讀取端可以使用針對查詢優化的架構,而寫入端則使用針對更新優化的架構。
- 安全性。 更輕鬆地確保只有正確的網域實體正在對數據執行寫入。
- 區分不同的考量。 隔離讀取和寫入端可能會導致更容易維護且更具彈性的模型。 大部分複雜的商業規則都會進入寫入模型。 讀取模型可能比較簡單。
- 更簡單的查詢。 藉由將具體化檢視儲存在讀取資料庫中,應用程式可以在查詢時避免複雜的聯結。
實作問題和考慮
實作此模式的一些挑戰包括:
複雜度。 CQRS 的基本概念很簡單。 但它可能會導致更複雜的應用程式設計,特別是如果他們包含事件來源模式。
傳訊。 雖然 CQRS 不需要傳訊,但通常會使用傳訊來處理命令併發佈更新事件。 在此情況下,應用程式必須處理訊息失敗或重複的訊息。 請參閱優先順序佇列的指引,以處理具有不同優先順序的命令。
最終一致性。 如果您分隔讀取和寫入資料庫,讀取數據可能會過時。 讀取模型存放區必須更新以反映寫入模型存放區的變更,而且很難根據過時讀取數據發出要求時偵測到。
使用 CQRS 模式的時機
針對下列案例,請考慮使用 CQRS:
可讓許多使用者平行存取相同數據的共同作業網域。 CQRS 可讓您定義具有足夠粒度的命令,以將網域層級的合併衝突降到最低,而且可能發生的衝突可由命令合併。
以工作為基礎的使用者介面,使用者會以一系列步驟或複雜的領域模型引導使用者完成複雜的程式。 寫入模型具有具有商業規則、輸入驗證和商務驗證的完整命令處理堆疊。 寫入模型可能會將一組相關聯的對象視為數據變更的單一單位(DDD 術語中的匯總),並確保這些物件一律處於一致狀態。 讀取模型沒有商業規則或驗證堆疊,而且只會傳回 DTO 以用於檢視模型。 讀取模型最終與寫入模型一致。
數據讀取效能必須與數據寫入效能分開微調的案例,特別是當讀取數目遠大於寫入數目時。 在此案例中,您可以相應放大讀取模型,但只對少數實例執行寫入模型。 少數寫入模型實例也有助於將合併衝突的發生降到最低。
一個開發人員小組可以專注於屬於寫入模型的複雜領域模型,而另一個小組可以專注於讀取模型和使用者介面。
系統預期會隨著時間演進,且可能包含多個模型的版本,或商務規則會定期變更的案例。
與其他系統整合,特別是與事件來源相結合,其中一個子系統的時態性失敗不應影響其他子系統的可用性。
當下列情況時,不建議使用此模式:
網域或商務規則很簡單。
簡單的 CRUD 樣式使用者介面和數據存取作業就已足夠。
請考慮將 CQRS 套用至系統有限區段,其中最有價值。
工作負載設計
架構設計人員應評估 CQRS 模式在工作負載的設計中如何使用,以解決 Azure 架構良好架構支柱中涵蓋的目標和原則。 例如:
要素 | 此模式如何支援支柱目標 |
---|---|
效能效率可透過調整規模、資料、程式碼達到最佳化,有效率地協助您的工作負載符合需求。 | 高讀寫工作負載中的讀取和寫入作業區隔可針對每個作業的特定用途啟用目標效能和調整優化。 - 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)
{
...
}
}
下一步
下列模式和指引在實作此模式時很有用:
數據一致性入門。 說明在使用 CQRS 模式時,由於讀取和寫入數據存放區之間的最終一致性,以及這些問題的解決方式,通常遇到的問題。
水平、垂直和功能性數據分割。 說明將數據分割成可個別管理和存取的數據分割的最佳做法,以改善延展性、減少爭用並優化效能。
模式和做法指南 CQRS 旅程圖。 特別是, 「命令查詢責任隔離」模式 簡介會探索模式及其有用時機,以及 結尾:學習 的教訓可協助您瞭解使用此模式時所發生的一些問題。
馬丁·福勒的部落格文章: