1. 程式人生 > >當我們在討論CQRS時,我們在討論些神馬?

當我們在討論CQRS時,我們在討論些神馬?

當我寫下這個標題的時候,我就有些後悔了,題目有點大,不太好控制。但我還是打算嘗試一下,通過這篇內容來說清楚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.Id) {
            //生成併發送事件
            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的前輩,本文借鑑了他們的文章和思想,感謝他們的分享!

文章中有任何不準確或錯誤的地方,請不吝賜教!歡迎討論!

參考文件

  • https://www.cnblogs.com/yangecnu/p/Introduction-CQRS.html
  • https://www.cnblogs.com/netfocus/p/4150084.html
  • http://www.ruanyifeng.com/blog/2018/07/cap.html
  • https://docs.microsoft.com/en-us/previous-versions/msp-n-p/dn589800(v=pandp.10)
  • https://msdn.microsoft.com/magazine/mt238399