當我寫下這個標題的時候,我就有些后悔了,題目有點大,不太好控制。但我還是打算嘗試一下,通過這篇內容來說清楚CQRS模式,以及和這個模式關聯的其它東西。希望我能說得清楚,你能看得明白,如果覺得不錯,右下角點個推薦!
先從CQRS說起,CQRS的全稱是Command Query Responsibility Segregation,翻譯成中文叫作命令查詢職責分離。從字面上就能看出,這個模式要求開發者按照方法的職責是命令還是查詢進行分離,什么是命令?什么是查詢?我們來繼續往下看。
Query & Command
什么是命令?什么是查詢?
- 命令(Command):不返回任何結果(void),但會改變對象的狀態。
- 查詢(Query):返回結果,但是不會改變對象的狀態,對系統沒有副作用。
對象的狀態是什么意思呢?
對象的狀態,我們可以理解成它的屬性,例如我們定義一個Person類,定義如下:
public class Person {
public string Id { get; set; }
public string Name { get; set; }
public int Age { get; set; }
public void Say(string word) {
Console.WriteLine($"{Name} Say: {word}");
}
}
在Person類中:
- Name、Age:屬性(狀態)
- Say(string): 方法(行為)
再回到本小節討論的內容,是不是就很好理解了呢?當我定義一個方法,要改變Person實例的Name或Age的時候,這個方法就屬於Command;如果定一個方法,只查詢Person實例信息的時候,這個方法就屬於Query。當我們按照職責將Command和Query進行分離的時候,你就在使用CQRS模式了。
其實這就是CQRS的全部。
有朋友可能要說了,如果這就是CQRS的全部,也太過於簡單了吧?是的,大道至簡!
讀寫分離
當我們按照CQRS進行分離以后,你是不是已經看出來,這玩意兒太適合做讀寫分離了?當我們的數據庫是主從模式的時候,主庫負責寫入、從庫負責讀取,完全匹配Command和Query,簡直完美。那么我們接下來就說一下讀寫分離。
現在主流的數據庫都支持主從模式,主從模式的好處是方便我做故障遷移,當主庫宕機的時候,可以快速的啟用從庫,從而減小系統不可用時間。
當我們在使用數據庫主從模式的時候,如果應用程序不做讀寫分離,你會發現從庫基本上沒用,主庫每天忙的要死,既要負責寫入,又要負責查詢,遇見訪問量大的時候CPU飆升是常有的事。然而從庫就太閑了,除了接收主庫的變更記錄做數據同步,再沒有別的事情可做,不管主庫壓力多大,從庫的CPU一直跟心電圖似的0-1-0-1...當我們讀寫分離以后,主庫負責寫入,從庫負責讀取,代碼要怎么改呢?我們只需要定義兩個Repository就可以了:
public interface IWritablePersonRepository {
//寫入數據的方法
}
public interface IReadonlyPersonRepository {
//讀取數據的方法
}
在IWritablePersonRepository中使用主庫的連接,IReadonlyPersonRepository中使用從庫的連接。然后,在Command里面使用IWritablePersonRepository, 在Query里面使用IReadonlyPersonRepository,這樣就在應用層實現了讀寫分離。
CRUD和EventSourcing
說到CQRS,不可避免的要說到這兩個數據操作模型。為什么要說數據操作模型呢?因為數據操作嚴重影響性能,而我們分離的一個重要目的就是要提高性能。
CRUD
CRUD(Create、Read、Update、Delete)是面向數據的,它將對數據的操作分為創建、更新、刪除和讀取四類,這四個操作可以對應我們SQL語句中的insert、select、update、delete,非常直觀明了,它的存在就是操作數據的。
因為存在即合理,我們不能片面的說CRUD是好或者壞,這里只簡單說一下它存在的問題:
- 並發沖突:這是個大問題,當A和B同時更新一行記錄的時候,你的事務必然報錯。
- 丟失數據操作的上下文:這個問題也不小,對於開發者來說,我們通常要知道數據是誰在什么時候做了什么更新,但是CURD只存儲了最終的狀態,對數據操作的上下文一無所知。
好了,更多的問題不再列舉,單是“並發沖突”這一個問題,在高並發的環境下就不適用。既然CRUD不適用,我們在構建高性能應用的時候,就只能寄希望於ES了。
Event Souring
Event Souring,翻譯過來叫事件溯源。什么意思呢?它把對象的創建、修改、刪除等一系列的操作都當作事件(注意:事件和命令還有區別,后面會講到),持久化的時候只存儲事件,存儲事件的介質叫做EventStore,當要獲取一個對象的最新狀態時,通過EventStore檢索該對象的所有Event並重新加載來獲取對象的最新狀態。EventStore可以是數據庫、磁盤文件、MongoDB等,由於Event的存儲都是新增的,所以不存在並發沖突的問題。
Command和Event
在CQRS+ES的方案中,我們要面對這兩個概念,命令和事件。
- Command:描述了用戶的意圖。
- Event:描述了對象狀態的改變。
我們舉一個例子,比如說你要更新自己的個人資料,例如將Age由35修改為18,那么對應的命令為:
public class PersonUpdateCommand {
public string Id { get; set; }
public int Age{ get; set; }
public PersonUpdateCommand(string id, int age){
this.Id = id;
this.Age = age;
}
}
PersonUpdateCommand是一個命令,它描述了用戶更新個人資料的意圖。當程序接收到這個命令以后,就需要對數據更改,從而引發數據狀態變化,產生Event:
public class PersonAgeChangeEvent {
public string Id { get; private set; }
public int Age{ get; private set; }
public PersonAgeChangeEvent(string id, int age){
this.Id = id;
this.Age = age;
}
}
public class PersonUpdateCommandHandler {
private PersonUpdateCommand Command;
public PersonUpdateCommandHandler(PersonUpdateCommand command) {
this.Command = command;
}
public void Handle() {
var person = GetPersonById(Command.Id);
if(person.Age != Command.Age) {
//生成並發送事件
var @event = new PersonAgeChangeEvent(Command.Id, Command.Age);
EventBus.Send(@event);
}
}
}
數據一致性
常見的數據一致性模型有兩種:強一致性和最終一致性。
- 強一致性:在任何時刻所有的用戶或者進程查詢到的都是最近一次成功更新的數據。
- 最終一致性:和強一致性相對,在某一時刻用戶或者進程查詢到的數據可能有不同,但是最終成功更新的數據都會被所有用戶或者進程查詢到。
說到一致性的問題,我們就不得不說一下CAP定理。
CAP定理
1998年,加州大學的計算機科學家 Eric Brewer 提出,分布式系統有三個指標。
- Consistency:一致性
- Availability:可用性
- Partition tolerance:分區容錯
它們的第一個字母分別是 C、A、P,這三個指標不可能同時做到。這個結論就叫做 CAP 定理。
對於分布式系統來說,受CAP定理的約束,最終一致性就成了唯一的選擇。實現最終一致性要考慮以下問題:
- 重試策略:在分布式系統中,我們無法保證每一次操作都能被成功的執行,例如網絡中斷、服務器宕機等臨時性的錯誤,都會導致操作執行失敗,那么我們就要等待故障恢復后進行重試。重試的操作對於系統來說可能會造成一些副作用,例如你正在支付的時候網絡中斷了,這個時候你不知道是否支付成功,聯網以后再次重試,可能就會造成重復扣款。如果要避免重試造成的系統危害,就要將操作設計為冪等操作。
-
- 冪等性:簡單的說,就是一個操作執行一次和執行多次產生的結果是一樣的,不會產生副作用。
- 撤銷策略:與重試策略相對應的,如果一個操作最終確定執行失敗,那么我們需要撤銷這個操作,將系統還原到執行該操作之前的狀態。撤銷操作有兩種,一種是直接將對象修改為執行前的狀態,這種情況將造成數據審計不一致的問題;另一種是類似於財務上的紅沖操作,新增一個命令,沖掉上一個操作,從而保證數據的完整性,並能夠滿足數據審計的要求。
Messaging
通過上面的介紹,我們已經知道在一個系統中所有的改變都是基於操作和由操作產生的事件所引發的。消息可以是一個Command,也可以是一個Event。當我們基於消息來實現CQRS中的命令和事件發布的時候,我們的系統將會更加的靈活可擴展。
如果你的系統基於消息,那么我猜你離不開消息總線,我在《手擼一套純粹的CQRS實現》中寫了一個基於內存的CommandBus的實現,感興趣的朋友可以去看一下,CommandBus的代碼定義如下:
public class CommandBus : ICommandBus
{
private readonly ICommandHandlerFactory handlerFactory;
public CommandBus(ICommandHandlerFactory handlerFactory)
{
this.handlerFactory = handlerFactory;
}
public void Send<T>(T command) where T : ICommand
{
var handler = handlerFactory.GetHandler<T>();
if (handler == null)
{
throw new Exception("未找到對應的處理程序");
}
handler.Execute(command);
}
}
基於內存的消息總線只能用於開發環境,在生產環境下不能夠滿足我們分布式部署的需要,這個時候就需要采用基於消息隊列的方式來實現了。消息隊列有很多,例如Redis的訂閱發布、RabbitMQ等,消息總線的實現也有很多優秀的開源框架,例如Rebus、Masstransit等,選一個你熟悉的框架即可。
數據審計
數據審計是CQRS帶給我們的另一個便利。由於我們存儲了所有事件,當我們要獲取對象變更記錄的時候,只需要將EventStore中的記錄查詢出來,便可以看到整個的生命周期。這種操作,簡直比打開了你青春期的日記本還要清晰明了。
當然,如果你要想知道對象的操作審計日志怎么辦?同樣的道理,我們記錄下所有的Command就可以了。那所有查詢日志呢?哈哈,不要調皮了。記錄的東西越多,你的存儲就越大,如果你的存儲空間允許的話,當然是越詳細越好的,主要還是看業務需求。
如果我們記錄了所有Command,我們還可以有針對性的進行分析,哪些命令使用量大、哪些命令執行時間長。。這些數據將對我們的擴容提供數據支撐。
分組部署
在分布式系統中,Command和Query的使用比例是不一樣的,Command和Command之間、Query和Query之間的權重也存在差異,如果單純的將這些服務平均的部署在每一個節點上,那純粹就是瞎搞。一個比較靠譜的實踐是將不同權重的Command和Query進行分組,然后進行有針對性的部署。
總結
CQRS很簡單,如何用好CQRS才是關鍵。CQRS更像是一種思想,它為我們提供了系統分離的基本思路,結合ES、Messaging等模式,為構建分布式高可用可擴展的系統提供了良好的理論依據。
園子里有很多鑽研CQRS+ES的前輩,本文借鑒了他們的文章和思想,感謝他們的分享!
文章中有任何不准確或錯誤的地方,請不吝賜教!歡迎討論!