大家好,我叫湯雪華。我平時工作使用Java,業余時間喜歡用C#做點開源項目,如ENode, EQueue。我個人對DDD領域驅動設計、CQRS架構、事件溯源(Event Sourcing,簡稱ES)、事件驅動架構(EDA)這些領域比較感興趣。我希望把自己所學的知識能否分享給大家,所以,把這個領域里的一些知識串聯了起來,整理了一個PPT,並為每張PPT配備注釋,分享給大家。希望能對這個領域有興趣的朋友有所幫助。
上面的提綱是今天主要分享的內容概要。開始之前想先說一下微服務架構和CQRS架構的區別和聯系。
微服務架構現在很熱,到處可以看到各大互聯網公司的微服務道路的分享總結。但是,我今天的分享和微服務沒有關系,希望可以帶給大家一些新的東西。如果一定要說微服務和CQRS架構的關系,那我覺得微服務是一種邊界思維,微服務的目的是為了從業務角度拆分(職責分離)當前業務領域的不同業務模塊到不同的服務,每個微服務之間的數據完全獨立,它們之間的交互可以通過SOA RPC調用(耦合比較高),也可以通過EDA 消息驅動(耦合比較低);
微服務架構和CQRS架構的關系:每個微服務內部,我們可以用CQRS/ES架構來實現,也可以用傳統三次架構來實現;
首先,我們需要先理解DDD中的聚合、聚合根這兩個概念。
聚合,它通過定義對象之間清晰的所屬關系和邊界來實現領域模型的內聚,並避免了錯綜復雜的難以維護的對象關系網的形成。聚合定義了一組具有內聚關系的相關對象的集合,我們把聚合看作是一個修改數據的最小原子單元。聚合根,每個聚合都有一個根對象,根對象管理聚合內的其他子對象(實體、值對象);聚合之間的交互都是通過聚合根來交互,不能繞過聚合根去直接和聚合下的子實體進行交互。上面的例子中,Car、Wheel、Position、Tire四個對象構成一個聚合,其中Car是聚合根;Customer也是聚合根,Customer不能直接訪問Car下的Tire(子實體),而是只能通過聚合根Car來訪問。
上面表達了一個關於聚合的一致性設計原則:聚合內的數據修改,是ACID強一致性的;跨聚合的數據修改,是最終一致性的。遵守這個原則,可以讓我們最大化的降低並發沖突,從而最大化的提高整個系統的吞吐。
In-Memory的意思是指整個系統中的所有的聚合根對象都活在內存。而不是像我們平時那樣,用到的時候才從DB獲取對象,然后再做修改,再保存回去。
在In-Memory的架構下,當要修改某個聚合根的狀態時,它已經在內存,我們可以直接拿到該對象的引用,且框架會盡量保證聚合根對象的狀態就是最新的。聚合根是在內存中的最小計算單元,每個聚合內部都封裝了業務規則,並保證數據的強一致性。
上圖我是挪用了之前比較或的LMAX架構中的一個圖,表達的思想就是in-memory架構。其中Business Logic Processor就是中央業務邏輯處理器,內部承載了大量在機器內存中活着的聚合根對象。
接下來,我們再來看一下什么是事件溯源。
一個對象從創建開始到消亡會經歷很多事件,以前我們是在每次對象參與完一個業務動作后把對象的最新狀態持久化保存到數據庫中,也就是說我們的數據庫中的數據是反映了對象的當前最新的狀態。而事件溯源則相反,不是保存對象的最新狀態,而是保存這個對象所經歷的每個事件,所有的由對象產生的事件會按照時間先后順序有序的存放在數據庫中。可以看出,事件溯源的這種做法是更符合事實觀的,因為它完整的描述了對象的整個生命周期過程中所經歷的所有事件。
那么,事件到底如何影響一個領域對象的狀態的呢?很簡單,當我們在觸發某個領域對象的某個行為時,該領域對象會先產生一個事件,然后該對象自己響應該事件並更新其自己的狀態,同時我們還會持久化在該對象上所發生的每一個事件;這樣當我們要重新得到該對象的最新狀態時,只要先創建一個空的對象,然后將和該對象相關的所有事件按照事件發生先后順序從先到后再全部應用一遍即可還原得到該對象的最新狀態,這個過程就是所謂的事件溯源。
另一方面,因為是用事件來表示對象的狀態,而事件是只會增加不會修改。這就能讓數據庫里的表示對象的數據非常穩定,不可能存在DELETE或UPDATE等操作。因為一個事件就是表示一個事實,事實是不能被磨滅或修改的。這種特性可以讓領域模型非常穩定,在數據庫級別不會產生並發更新同一條數據的問題。
通過上面這個圖,大家應該可以更直觀的理解事件溯源和傳統CRUD思想的區別。
Actor模型,這個概念大家應該都了解。Actor模型的核心思想是,對象直接不會直接調用來通信,而是通過發消息來通信。每個Actor都有一個Mailbox,它收到的所有的消息都會先放入Mailbox中,然后Actor內部單線程處理Mailbox中的消息。從而保證對同一個Actor的任何消息的處理,都是線性的,無並發沖突。從全局上來看,就是整個系統中,有很多的Actor,每個Actor都在處理自己Mailbox中的消息,Actor之間通過發消息來通信。
Akka框架就是實現Actor模型的並行開發框架,並且Akka框架融入了聚合、In-Memory、Event Sourcing這些概念。Actor非常適合作為DDD聚合根。Actor的狀態修改是由事件驅動的,事件被持久化起來,然后通過Event Sourcing的技術,還原特定Actor的最新狀態到內存。
上圖表達的是事件驅動的架構的思想。Node表示節點,每個節點負責處理邏輯;Event表示消息,節點之間通過消息進行通信。消息通過分布式消息隊列如RocketMQ,Equeue進行通信。
事件驅動架構的核心思想是:
- 不同於SOA架構,EDA架構是pub-sub模式;Node1處理完邏輯后產生消息,Node2訂閱消息並進行處理,Node1不知道Node2的存在;
- 最終一致性原則,Node1,Node2之間的數據一致性通過MQ最終保證一致;
- 如何保證最終一致性(消息鏈不會斷開):1)MQ保證消息不丟;2)任何一個Node要保證自己完全處理完后才發送ACK給MQ;3)每個Node做到對任何消息處理的冪等性;
- 整個架構具有所有分布式MQ所帶來的優點:如異步解耦、削峰、降低整個系統的整體部署成本;
上圖是一個面向Topic的分布式MQ的邏輯架構圖,采用這種架構的MQ有:Kafka,RocketMQ,EQueue
- Producer發送消息到某個Topic的某個Queue;
- 消息都存儲在Broker上;
- Consumer從Broker拉取消息進行消費,並支持消費者負載均衡;
好了,上面是基本概念的介紹。接下來我們來看一下CQRS/ES架構。
上圖是CQRS架構的典型架構圖。
什么是CQRS架構?
CQRS本身只是一個讀寫分離的架構思想,全稱是:Command Query Responsibility Segregation,即命令查詢職責分離,表示在架構層面,將一個系統分為寫入(命令)和查詢兩部分。一個命令表示一種意圖,表示命令系統做什么修改,命令的執行結果通常不需要返回;一個查詢表示向系統查詢數據並返回。
CQRS架構中,另外一個重要的概念就是事件,事件表示命令操作領域中的聚合根,然后聚合根的狀態發生變化后產生的事件。
采用CQRS架構的一個前提
由於CQRS架構的一致性模型為最終一致性,所以,你的系統要接受查詢到的數據可能不是最新的,而是有幾個毫秒的延遲。之所以會有這個前提,是因為CQRS架構考慮到,作為一個多用戶同時訪問的互聯網應用,當在高並發修改數據的情況下,比如秒殺、12306購票等場景,用戶UI上看到的數據總是舊的。比如你秒殺時提交訂單前看到庫存還大於0,但是當你提交訂單時,系統提示你寶貝賣完了。這個就說明,在這種高並發修改同一資源的情況下,任何人看到的數據總是Stale的,即舊的。
CQRS作為一種架構思想,可以有多種實現方式
- 最常見的CQRS架構是數據庫的讀寫分離;
- 系統底層存儲不分離,但是上層邏輯代碼分離;
- 系統底層存儲分離,C端采用Event Sourcing的技術,在EventStore中存儲事件;Q端存儲對象的最新狀態,用於提供查詢支持;
CQRS架構的適用場景
- 當我們的應用的寫模型和讀模型差別比較大時;
- 當我們希望實踐DDD時;因為CQRS架構可以讓我們實現領域模型不受任何ORM框架帶來的對象和數據庫的阻抗失衡的影響;
- 當我們希望對系統的查詢性能和寫入性能分開進行優化時,尤其是讀/寫比非常高的系統,CQ分離是必須的;
- 當我們希望我們的系統同時滿足高並發的寫、高並發的讀的時候;因為CQRS架構可以做到C端最大化的寫,Q端非常方便的提供可擴展的讀模型;
這里我主要分享的CQRS架構是上面第3種實現方式,也就是上圖所畫的架構。在我心目中,只有第三種才是真正意義上的CQRS架構。
下面簡單描述一下上面的CQRS架構的數據流
C端的命令的執行流程
客戶端如(MVC Controller)發送命令通知系統做修改:
- 發送命令到分布式MQ;
- 然后命令的訂閱者處理命令;
- 訂閱者內部根據不同的命令調用不同的Command Handler進行處理;
- Command Handler內部根據命令所指定的聚合根ID從In-Memory內存中直接獲取聚合根對象的引用,然后操作聚合根對象;
- 聚合根對象狀態發生變化並產生事件;
- 框架負責自動持久化事件到Event Storage(簡稱EventStore);
- 框架負責將事件發布到Event MQ;
- Event訂閱者訂閱事件,然后調用對應的Event Handler進行處理,如更新Data Storage(保存了聚合根的最新狀態,通常叫讀庫,ReadDB);
Q端的查詢的執行流程
客戶端如(MVC Controller)發出查詢請求系統返回數據:
- 調用輕薄的Query Service,傳如Query DTO;
- Query Service從讀庫進行查詢並返回結果;
讀庫可以有很多種,依據我們的業務場景來選擇:比如關系型DB、分布式緩存等NoSQL、搜索引擎,etc.
前面的CQRS架構圖我介紹了CQRS架構的基本概念、設計初衷、一致性模型、實現方式、適用場景、架構的基本數據流這些方面。但這不是CQRS架構的全部,我們還可以挖掘出更多有用的特性出來。比如假設我們為這個架構引入以下一些特性,就可以達到更多意想不到的好處:
- 遵守一個原則:一個命令只允許修改一個聚合根;
- 命令或事件在分布式MQ的路由根據聚合根ID來路由,也就是同一個聚合根的命令和事件都在一個隊列里;
- 引入Command Mailbox,Event Mailbox這兩個概念,將聚合根需要處理的命令和產生的事件都隊列化,去並發;做到架構上最大的並行,將並發降低到最低;
- 引入Group Commit技術,做到整個C端的架構層面支持批量提交聚合根產生的事件,從而極大的提高C端的整體吞吐量;比如可以實現對同一個聚合根的每秒修改TPS達到5W?這個在傳統的架構下是很難做到的。而在這個架構下,框架就可以提供支持。
- 通過引入Saga(不了解的同學可以網上搜一下什么是CQRS Saga)的概念,做到基於事件驅動的最終一致性,大家可以回想一下前面介紹的Node通過Event連接的架構;整個系統的所有節點的交互通過消息來驅動;
通過引入上面這些架構設計原則,我們可以讓CQRS架構的C端更強大,性能更高;當然,復雜性也大大增加。所以,要完成這樣一套架構,沒有成熟框架的支撐,是幾乎不可能的,ENode框架就是在為做這樣的一個框架而努力。
我們可以從上面幾個非功能性特性去考察這個架構。大部分大家應該都可以體會到,關於消息的冪等處理這塊,CQRS\ES這個架構可以做的非常徹底。
平時傳統我們的消息驅動的架構,或者是RPC調用的SOA風格的應用,消息處理者或者服務被調用方,必須自己做到數據修改的冪等性。而冪等性的實現思路也很多,比如用kv來判重,用DB的唯一索引,等等。
而CQRS\ES架構,由於使用了Event Sourcing的技術,所以可以直接在EventStore中自動做到聚合根並發修改的沖突的檢測、以及同一個命令的重復處理的檢測。並能通知框架自動做並發處理或做重新發布該命令所產生的事件;
大家可能會疑問,為何已經將命令通過聚合根ID進行路由了,且同一台機器內頁已經通過Actor Mailbox技術解決並發問題了,還是有並發沖突的可能呢?原因是當我們的服務器在出現擴容或縮容時,會出現由於集群中服務器變動導致的同一個聚合根的不同命令可能會在不同的機器上同時被處理,從而導致並發沖突。
最后,關於這個架構的瓶頸,相信大家已經可以發現,是在EventStore。所以,這就要求我們設計一個超高性能的EventStore數據庫。具體見后面的介紹吧。
上面這個圖演示了,當C端產生的事件,在Q端的處理順序如果不一致時,導致Q端的結果和C端不一致了。所以,事件的處理順序必須和產生的順序一致,這點必須保證,但可以由框架來保證,開發者無需關注。需要強調的是,這個順序處理事件不需要交給分布式消息中間件來保證,而是應該交給Consumer來自己保重。當Consumer收到一個版本為N+2的時間,而當前Q端的版本為N,則N+2的消息需要先hold一下,不要立即處理。然后等待N+1的事件過來,N+1的事件過來並處理后,再處理N+2的事件。如果N+1的事件一直不過來,則需要永遠等待。總之,這里的順序必須保證。如果這個順序交給分布式消息中間件去保證,那性能上會非常差,而要讓分布式消息中間件實現絕對意義上的順序消費,又要實現高可用,高性能,難度很大。我個人不太贊成,除非是Consumer自己無法處理消息順序的場景才迫不得已讓分布式消息中間件來保證,比如mysql binlog的同步。
上圖演示了假設一個命令修改兩個或多個聚合根時,會導致阻塞大大增加,從而整個系統的吞吐會降低。而好處是,我們可以得到聚合根之間的數據的強一致性。
上圖演示了,當一個命令只修改一個聚合根時,先通過一級路由,將聚合根路由到分布式MQ的同一個隊列里,然后同一個隊列總是被一台固定的機器消費,從而保證同一個聚合根的命令總是在一台機器上處理。
上圖掩演示了,當命令進入一台機器后,再通過Command Mailbox的二次路由,同樣是根據聚合根ID,從而保證單個機器內,同一個聚合根的命令的處理是順序線性的,從而避免了並發沖突。
EventStore處理並發和命令冪等的根本設計就是上圖的兩個唯一索引。
1. 聚合根ID + 事件版本號唯一;
2. 聚合根ID + 命令ID唯一;
當萬一出現了並發沖突,則框架需要取出重新加載該聚合根的最新狀態,然后重試當前命令;當出現了命令的重復處理,則框架需要把該命令之前產生的事件再重新取出來,發布到分布式消息中間件。因為有可能之前雖然這個事件被持久化了,但理論山有可能這個事件沒有成功發布到分布式消息中間件(因為那個時候斷電了,夠倒霉的,呵呵)。所以,事件的消費者可能會再次收到這個事件,並處理。但這么做都是為了保證整個業務流的最終一致性。想想之前的EDA的架構圖的說明吧。
下面我們來看看CQRS架構下,開發者需要寫的代碼有哪些?
首先是需要定義Command和Event。其中Command相當於DDD經典四層架構中的應用層的一個方法的參數。
Command表示命令系統做什么,表達一種意圖,在架構上設計為一個DTO即可。Event表示一個事件,表示領域內發生了什么狀態變化,用過去式命名事件。事件是只讀的。
Command Handler是無狀態的,用於處理一個或多個命令,不同的命令有不同的Handle方法。一個Command Handler做的典型的事情就兩個:
- 根據命令的信息創建一個聚合根;
- 根據命令的信息修改一個聚合根;
框架可以做到開發人員無需關注底層的技術問題,比如如何存儲聚合根產生的事件,如何發布事件到MQ;徹底做到技術架構和業務邏輯分離。這點在傳統架構下是很難做到的。
Note表示一個DDD聚合根,這里最核心的概念是:Note內部的狀態的修改都是通過事件來驅動的,也就是Note要做任何修改前,總是應該先產生事件,然后框架根據事件調用到對應的Handle方法,然后我們在Handle方法中修改Note的內部狀態。
為何要獨立拆分出Handle方法呢?因為是在Event Souring事件溯源還原聚合根狀態時,框架需要調用這些Handle方法。根據Event Sourcing的思想,會根據Note聚合根的ID獲取該聚合根的所有的事件,然后按照事件發生的順序,分別調用每個事件的Handle方法,就可以還原出聚合根的最新狀態了。
最后一個需要開發者寫的代碼就是Event Handler,根據CQRS架構的定義,Event Handler負責根據C端產生的事件來更新讀庫。上面的例子只是記錄日志,實際我們需要在Handle方法中更新讀庫,如數據庫,分布式緩存等。
這是ENode中今年打算實現的文件版本的EventStore的設計思路,目前是使用的DB來實現的。我現在在做EQueue的高可用,等這個做完,就開始做EventStore的文件版本。上面PPT中的設計思路,還希望能和大家多多交流,一起完善它。因為它是整個CQRS/ES架構的核心所在。
前面介紹了很多CQRS\ES架構方面的東西,最后我們再看兩個實際應用的場景:秒殺、12036購票。
要實現高並發的訂單處理(生成訂單、預扣庫存兩個核心步驟)。淘寶做的很牛逼,可以在這兩個步驟都完成后直接告訴用戶下單結果,當然,我認為CQRS架構也完全可以在保證這兩點處理后再返回買家的前提下,實現淘寶一樣的吞吐。
這里我列舉這些訂單狀態的目的,主要是想表達第一個狀態用意:訂單處理中。通過引入這個狀態,我們處理訂單的的代價就輕很多了,不需要在完成生成訂單、預扣庫存這兩個核心步驟就可以返回客戶端瀏覽器了。買家訂單提交成功后,服務端首先在分布式緩存中檢查商品的庫存是否足夠,如果不夠,則立即返回並通知買家寶貝賣完了;如果足夠,則發送下單的命令到MQ(異步處理訂單)。然后通知買家“您好,您的訂單已收到,正在處理中。請稍后到我的訂單中心查看訂單處理結果。祝您購物愉快!”之類的提示。
然后當買家進入“我的訂單中心”查看訂單時,可能的情況有:
- 訂單未生成,則買家看不到訂單,沒關系,TA過一會兒刷新頁面繼續查看;
- 訂單已生成,但是預扣庫存還未有結果,則提示訂單處理中,用戶同樣會等待;
- 訂單已生成,預扣庫存也已經有結果,不管庫存是否足夠,都顯示相應狀態給用戶;
通過這樣的訂單狀態的設計和交互體驗,相當於把輪訓查看訂單處理結果的職責交給了買家。而這個小小的設計,帶來的好處是極大的方便我們實現非常高的訂單處理吞吐了。當然,如果我們能做到像淘寶這樣的體驗,就是下單時直接告訴結果,那自然最好了。只是這樣代價更大而已。我提出這個例子的原因是CQRS架構是一種C端異步處理命令的架構,所以在這種架構上,我們需要一切盡量以異步為出發點去思考和設計業務流程,設計用戶交互體驗。實際上這個體驗在亞馬遜上買東西,你可能會遇到,甚至亞馬遜直接讓你去你的郵箱看訂單處理結果。所以,我覺得這里只是一個購物習慣的差別,但對技術的要求卻差別很大。
上圖描述了一個DDD CQRS架構的典型的Saga的設計,對應前面的秒殺場景的訂單處理流程。
上圖中,Order、Conference、Payment為三個聚合根,分別表示訂單、庫存、支付;Order Process Manager是無狀態的,表示一個流程管理器,CQRS架構中一般叫Saga。流程管理器的設計理念是:訂閱事件,根據不同的事件,發送不同的命令。也就是說,流程管理器的職責是對流程進行建模,負責封裝流程控制邏輯,而聚合根負責業務邏輯。整個訂單處理的流程大概為業務層面的2PC。即下單時,要先預扣庫存;然后,買家付款后要真正扣庫存。
上圖中,棕色的線條表示命令,藍色的線條表示事件。
Saga是CQRS架構中處理復雜業務流程的典型做法,通過事件驅動的方式去替代傳統的分布式事務。犧牲強一致性的方式來提高系統的吞吐。實際上,在高並發的情況下,有時我們不得不選擇最終一致性,因為分布式事務的成本太高。
這個案例是關於12306購票的例子,上面說了核心的業務場景和領域概念。我舉這個例子的用意是為了說明,12306購票的場景,C端的領域模型是比傳統的電商網站要復雜很多的,因為庫存是一個動態的概念。不像普通電商,一個庫存跟着SKU,很簡單。12306你買了一個車子的某個區間的票之后,這個區間內的其他的票的庫存數都會發生變化,而且這個庫存數還要考慮座位的分配,非常復雜。
這個場景,就是我上面說的CQRS的應用場景中的:要滿足高並發的寫、高並發的查詢,同時C端的業務模型非常復雜。要同時面對這3點,實現這個系統是很難的。
我認為,這個場景的難點不在於技術層面,而是在於DDD領域建模層面。大家如果對這個場景的領域模型,架構實現,以及示例代碼感興趣,可以看我下面的兩個地址:
淺談12306核心模型設計思路和架構設計
http://www.cnblogs.com/netfocus/p/5187241.html
12306購票領域建模示例代碼:
https://github.com/tangxuehua/enode,具體看ENode開源項目中的E12306案例代碼。
如果大家對這個領域感興趣,可以訪問我的博客。我博客中錄制了大量的視頻介紹,視頻介紹匯總地址:
http://www.cnblogs.com/netfocus/p/4707789.html
謝謝大家!