談一下關於CQRS架構如何實現高性能

轉自:http://www.cnblogs.com/netfocus/p/4055346.html

CQRS架構簡介

前不久,看到博客園一位園友寫了一篇文章,其中的觀點是,要想高性能,需要儘量:避開網絡開銷(IO),避開海量數據,避開資源爭奪。對於這3點,我覺得很有道理。所以也想談一下,CQRS架構下是如何實現高性能的。

關於CQRS(Command Query Responsibility Segregation)架構,大家應該不會陌生了。簡單的說,就是一個系統,從架構上把它拆分爲兩部分:命令處理(寫請求)+查詢處理(讀請求)。然後讀寫兩邊可以用不同的架構實現,以實現CQ兩端(即Command Side,簡稱C端;Query Side,簡稱Q端)的分別優化。CQRS作爲一個讀寫分離思想的架構,在數據存儲方面,沒有做過多的約束。所以,我覺得CQRS可以有不同層次的實現,比如:

  1. CQ兩端數據庫共享,CQ兩端只是在上層代碼上分離;這種做法,帶來的好處是可以讓我們的代碼讀寫分離,更好維護,且沒有CQ兩端的數據一致性問題,因爲是共享一個數據庫的。我個人認爲,這種架構很實用,既兼顧了數據的強一致性,又能讓代碼好維護。
  2. CQ兩端數據庫和上層代碼都分離,然後Q的數據由C端同步過來,一般是通過Domain Event進行同步。同步方式有兩種,同步或異步,如果需要CQ兩端的強一致性,則需要用同步;如果能接受CQ兩端數據的最終一致性,則可以使用異步。採用這種方式的架構,個人覺得,C端應該採用Event Sourcing(簡稱ES)模式纔有意義,否則就是自己給自己找麻煩。因爲這樣做你會發現會出現冗餘數據,同樣的數據,在C端的db中有,而在Q端的db中也有。和上面第一種做法相比,我想不到什麼好處。而採用ES,則所有C端的最新數據全部用Domain Event表達即可;而要查詢顯示用的數據,則從Q端的ReadDB(關係型數據庫)查詢即可。

我覺得要實現高性能,可以談的東西還有很多。下面我想重點說說我想到的一些設計思路:

避開資源爭奪

秒殺活動的例子分析

我覺得這是很重要的一點。什麼是資源爭奪?我想就是多個線程同時修改同一個數據。就像阿里秒殺活動一樣,秒殺開搶時,很多人同時搶一個商品,導致商品的庫存會被併發更新減庫存,這就是一個資源爭奪的例子。一般如果資源競爭不激烈,那無所謂,不會影響性能;但是如果像秒殺這種場景,那db就會抗不住了。在秒殺這種場景下,大量線程需要同時更新同一條記錄,進而導致MySQL內部大量線程堆積,對服務性能、穩定性造成很大傷害。那怎麼辦呢?我記得阿里的丁奇寫過一個分享,思路就是當MySQL的服務端多個線程同時修改一條記錄時,可以對這些修改請求進行排隊,然後對於InnoDB引擎層,就是串行的。這樣排隊後,不管上層應用發過來多少並行的修改同一行的請求,對於MySQL Server端來說,內部總是會聰明的對同一行的修改請求都排隊處理;這樣就能確保不會有併發產生,從而不會導致線程浪費堆積,導致數據庫性能下降。這個方案可以見下圖所示:

如上圖所示,當很多請求都要修改A記錄時,MySQL Server內部會對這些請求進行排隊,然後一個個將對A的修改請求提交到InnoDB引擎層。這樣看似在排隊,實際上會確保MySQL Server不會死掉,可以保證對外提供穩定的TPS。

但是,對於商品秒殺這個場景,還有優化的空間,就是Group Commit技術。Group Commit就是對多個請求合併爲一次操作進行處理。秒殺時,大家都在購買這個商品,A買2件,B買3件,C買1件;其實我們可以把A,B,C的這三個請求合併爲一次減庫存操作,就是一次性減6件。這樣,對於A,B,C的這三個請求,在InnoDB層我們只需要做一次減庫存操作即可。假設我們Group Commit的每一批的size是50,那就是可以將50個減操作合併爲一次減操作,然後提交到InnoDB。這樣,將大大提高秒殺場景下,商品減庫存的TPS。但是這個Group Commit的每批大小不是越大越好,而是要根據併發量以及服務器的實際情況做測試來得到一個最優的值。通過Group Commit技術,根據丁奇的PPT,商品減庫存的TPS性能從原來的1.5W提高到了8.5W。

從上面這個例子,我們可以看到阿里是如何在實際場景中,通過優化MySQL Server來實現高併發的商品減庫存的。但是,這個技術一般人還真的不會!因爲沒多少人有能力去優化MySQL的服務端,排隊也不行,更別說Group Commit了。這個功能並不是MySQL Server自帶的,而是需要自己實現的。但是,這個思路我想我們都可以借鑑。

CQRS如何實現避免資源競爭

那麼對於CQRS架構,如何按照這個思路來設計呢?我想重點說一下我上面提到的第二種CQRS架構。對於C端,我們的目標是儘可能的在1s內處理更多的Command,也就是數據寫請求。在經典DDD的四層架構中,我們會有一個模式叫工作單元模式,即Unit of Work(簡稱UoW)模式。通過該模式,我們能在應用層,一次性以事務的方式將當前請求所涉及的多個對象的修改提交到DB。微軟的EF實體框架的DbContext就是一個UoW模式的實現。這種做法的好處是,一個請求對多個聚合根的修改,能做到強一致性,因爲是事務的。但是這種做法,實際上,沒有很好的遵守避開資源競爭的原則。試想,事務A要修改a1,a2,a3三個聚合根;事務B要修改a2,a3,a4;事務C要修改a3,a4,a5三個聚合根。那這樣,我們很容易理解,這三個事務只能串行執行,因爲它們要修改相同的資源。比如事務A和事務B都要修改a2,a3這兩個聚合根,那同一時刻,只能由一個事務能被執行。同理,事務B和事務C也是一樣。如果A,B,C這種事務執行的併發很高,那數據庫就會出現嚴重的併發衝突,甚至死鎖。那要如何避免這種資源競爭呢?我覺得我們可以採取三個措施:

讓一個Command總是隻修改一個聚合根

這個做法其實就是縮小事務的範圍,確保一個事務一次只涉及一條記錄的修改。也就是做到,只有單個聚合根的修改纔是事務的,讓聚合根成爲數據強一致性的最小單位。這樣我們就能最大化的實現並行修改。但是你會問,但是我一個請求就是會涉及多個聚合根的修改的,這種情況怎麼辦呢?在CQRS架構中,有一個東西叫Saga。Saga是一種基於事件驅動的思想來實現業務流程的技術,通過Saga,我們可以用最終一致性的方式最終實現對多個聚合根的修改。對於一次涉及多個聚合根修改的業務場景,一般總是可以設計爲一個業務流程,也就是可以定義出要先做什麼後做什麼。比如以銀行轉賬的場景爲例子,如果是按照傳統事務的做法,那可能是先開啓一個事務,然後讓A賬號扣減餘額,再讓B賬號加上餘額,最後提交事務;如果A賬號餘額不足,則直接拋出異常,同理B賬號如果加上餘額也遇到異常,那也拋出異常即可,事務會保證原子性以及自動回滾。也就是說,數據一致性已經由DB幫我們做掉了。

但是,如果是Saga的設計,那就不是這樣了。我們會把整個轉賬過程定義爲一個業務流程。然後,流程中會包括多個參與該流程的聚合根以及一個用於協調聚合根交互的流程管理器(ProcessManager,無狀態),流程管理器負責響應流程中的每個聚合根產生的領域事件,然後根據事件發送相應的Command,從而繼續驅動其他的聚合根進行操作。

轉賬的例子,涉及到的聚合根有:兩個銀行賬號聚合根,一個交易(Transaction)聚合根,它用於負責存儲流程的當前狀態,它還會維護流程狀態變更時的規則約束;然後當然還有一個流程管理器。轉賬開始時,我們會先創建一個Transaction聚合根,然後它產生一個TransactionStarted的事件,然後流程管理器響應事件,然後發送一個Command讓A賬號聚合根做減餘額的操作;A賬號操作完成後,產生領域事件;然後流程管理器響應事件,然後發送一個Command通知Transaction聚合根確認A賬號的操作;確認完成後也會產生事件,然後流程管理器再響應,然後發送一個Command通知B賬號做加上餘額的操作;後續的步驟就不詳細講了。大概意思我想已經表達了。總之,通過這樣的設計,我們可以通過事件驅動的方式,來完成整個業務流程。如果流程中的任何一步出現了異常,那我們可以在流程中定義補償機制實現回退操作。或者不回退也沒關係,因爲Transaction聚合根記錄了流程的當前狀態,這樣我們可以很方便的後續排查有狀態沒有正常結束的轉賬交易。具體的設計和代碼,有興趣的可以去看一下ENode源代碼中的銀行轉賬的例子,裏面有完整的實現。

對修改同一個聚合根的Command進行排隊

和上面秒殺的設計一樣,我們可以對要同時修改同一個聚合根的Command進行排隊。只不過這裏的排隊不是在MySQL Server端,而是在我們自己程序裏做這個排隊。如果我們是單臺服務器處理所有的Command,那排隊很容易做。就是只要在內存中,當要處理某個Command時,判斷當前Command要修改的聚合根是否前面已經有Command在處理,如果有,則排隊;如果沒有,則直接執行。然後當這個聚合根的前一個Command執行完後,我們就能處理該聚合根的下一個Command了;但是如果是集羣的情況下呢,也就是你不止有一臺服務器在處理Command,而是有十臺,那要怎麼辦呢?因爲同一時刻,完全有可能有兩個不同的Command在修改同一個聚合根。這個問題也簡單,就是我們可以對要修改聚合根的Command根據聚合根的ID進行路由,根據聚合根的ID的hashcode,然後和當前處理Command的服務器數目取模,就能確定當前Command要被路由到哪個服務器上處理了。這樣我們能確保在服務器數目不變的情況下,針對同一個聚合根實例修改的所有Command都是被路由到同一臺服務器處理。然後加上我們前面在單個服務器裏面內部做的排隊設計,就能最終保證,對同一個聚合根的修改,同一時刻只有一個線程在進行。

通過上面這兩個設計,我們可以確保C端所有的Command,都不會出現併發衝突。但是也要付出代價,那就是要接受最終一致性。比如Saga的思想,就是在最終一致性的基礎上而實現的一種設計。然後,基於以上兩點的這種架構的設計,我覺得最關鍵的是要做到:1)分佈式消息隊列的可靠,不能丟消息,否則Saga流程就斷了;2)消息隊列要高性能,支持高吞吐量;這樣才能在高併發時,實現整個系統的整體的高性能。我開發的EQueue就是爲了這個目標而設計的一個分佈式消息隊列,有興趣的朋友可以去了解下哦。

Command和Event的冪等處理

CQRS架構是基於消息驅動的,所以我們要儘量避免消息的重複消費。否則,可能會導致某個消息被重複消費而導致最終數據無法一致。對於CQRS架構,我覺得主要考慮三個環節的消息冪等處理。

Command的冪等處理

這一點,我想不難理解。比如轉賬的例子中,假如A賬號扣減餘額的命令被重複執行了,那會導致A賬號扣了兩次錢。那最後就數據無法一致了。所以,我們要保證Command不能被重複執行。那怎麼保證呢?想想我們平時一些判斷重複的操作怎麼做的?一般有兩個做法:1)db對某一列建唯一索引,這樣可以嚴格保證某一列數據的值不會重複;2)通過程序保證,比如插入前先通過select查詢判斷是否存在,如果不存在,則insert,否則就認爲重複;顯然通過第二種設計,在併發的情況下,是不能保證絕對的唯一性的。然後CQRS架構,我認爲我們可以通過持久化Command的方式,然後把CommandId作爲主鍵,確保Command不會重複。那我們是否要每次執行Command前線判斷該Command是否存在呢?不用。因爲出現Command重複的概率很低,一般只有是在我們服務器機器數量變動時纔會出現。比如增加了一臺服務器後,會影響到Command的路由,從而最終會導致某個Command會被重複處理,關於這裏的細節,我這裏不想多展開了,呵呵。有問題到回覆裏討論吧。這個問題,我們也可以最大程度上避免,比如我們可以在某一天系統最空的時候預先增加好服務器,這樣可以把出現重複消費消息的情況降至最低。自然也就最大化的避免了Command的重複執行。所以,基於這個原因,我們沒有必要在每次執行一個Command時先判斷該Command是否已執行。而是只要在Command執行完之後,直接持久化該Command即可,然後因爲db中以CommandId爲主鍵,所以如果出現重複,會主鍵重複的異常。我們只要捕獲該異常,然後就知道了該Command已經存在,這就說明該Command之前已經被處理過了,那我們只要忽略該Command即可(當然實際上不能直接忽略,這裏我由於篇幅問題,我就不詳細展開了,具體我們可以再討論)。然後,如果持久化沒有問題,說明該Command之前沒有被執行過,那就OK了。這裏,還有個問題也不能忽視,就是某個Command第一次執行完成了,也持久化成功了,但是它由於某種原因沒有從消息隊列中刪除。所以,當它下次再被執行時,Command Handler裏可能會報異常,所以,健壯的做法時,我們要捕獲這個異常。當出現異常時,我們要檢查該Command是否之前已執行過,如果有,就要認爲當前Command執行正確,然後要把之前Command產生的事件拿出來做後續的處理。這個問題有點深入了,我暫時不細化了。有興趣的可以找我私聊。

Event持久化的冪等處理

然後,因爲我們的架構是基於ES的,所以,針對新增或修改聚合根的Command,總是會產生相應的領域事件(Domain Event)。我們接下來的要做的事情就是要先持久化事件,再分發這些事件給所有的外部事件訂閱者。大家知道,聚合根有生命週期,在它的生命週期裏,會經歷各種事件,而事件的發生總有確定的時間順序。所以,爲了明確哪個事件先發生,哪個事件後發生,我們可以對每個事件設置一個版本號,即version。聚合根第一個產生的事件的version爲1,第二個爲2,以此類推。然後聚合根本身也有一個版本號,用於記錄當前自己的版本是什麼,它每次產生下一個事件時,也能根據自己的版本號推導出下一個要產生的事件的版本號是什麼。比如聚合根當前的版本號爲5,那下一個事件的版本號則爲6。通過爲每個事件設計一個版本號,我們就能很方便的實現聚合根產生事件時的併發控制了,因爲一個聚合根不可能產生兩個版本號一樣的事件,如果出現這種情況,那說明一定是出現併發衝突了。也就是一定是出現了同一個聚合根同時被兩個Command修改的情況了。所以,要實現事件持久化的冪等處理,也很好做了,就是db中的事件表,對聚合根ID+聚合根當前的version建唯一索引。這樣就能在db層面,確保Event持久化的冪等處理。另外,對於事件的持久化,我們也可以像秒殺那樣,實現Group Commit。就是Command產生的事件不用立馬持久化,而是可以先積累到一定的量,比如50個,然後再一次性Group Commit所有的事件。然後事件持久化完成後,再修改每個聚合根的狀態即可。如果Group Commit事件時遇到併發衝突(由於某個聚合根的事件的版本號有重複),則退回爲單個一個個持久化事件即可。爲什麼可以放心的這樣做?因爲我們已經基本做到確保一個聚合根同一時刻只會被一個Command修改。這樣就能基本保證,這些Group Commit的事件也不會出現版本號衝突的情況。所以,大家是否覺得,很多設計其實是一環套一環的。Group Commit何時出發?我覺得可以只要滿足兩個條件了就可以觸發:1)某個定時的週期到了就可以觸發,這個定時週期可以根據自己的業務場景進行配置,比如每隔50ms觸發一次;2)要Commit的事件到達某個最大值,即每批可以持久化的事件個數的最大值,比如每50個事件爲一批,這個BatchSize也需要根據實際業務場景和你的存儲db的性能綜合測試評估來得到一個最適合的值;何時可以使用Group Commit?我覺得只有是在併發非常高,當單個持久化事件遇到性能瓶頸時,才需要使用。否則反而會降低事件持久化的實時性,Group Commit提高的是高併發下單位時間內持久化的事件數。目的是爲了降低應用和DB之間交互的次數,從而減少IO的次數。不知不覺就說到了最開始說的那3點性能優化中的,儘量減少IO了,呵呵。

Event消費時的冪等處理

CQRS架構圖中,事件持久化完成後,接下來就是會把這些事件發佈出去(發送到分佈式消息隊列),給消費者消費了,也就是給所有的Event Handler處理。這些Event Handler可能是更新Q端的ReadDB,也可能是發送郵件,也可能是調用外部系統的接口。作爲框架,應該有職責儘量保證一個事件儘量不要被某個Event Handler重複消費,否則,就需要Event Handler自己保證了。這裏的冪等處理,我能想到的辦法就是用一張表,存儲某個事件是否被某個Event Handler處理的信息。每次調用Event Handler之前,判斷該Event Handler是否已處理過,如果沒處理過,就處理,處理完後,插入一條記錄到這個表。這個方法相信大家也都很容易想到。如果框架不做這個事情,那Event Handler內部就要自己做好冪等處理。這個思路就是select if not exist, then handle, and at last insert的過程。可以看到這個過程不像前面那兩個過程那樣很嚴謹,因爲在併發的情況下,理論上還是會出現重複執行Event Handler的情況。或者即便不是併發時也可能會造成,那就是假如event handler執行成功了,但是last insert失敗了,那框架還是會重試執行event handler。這裏,你會很容易想到,爲了做這個冪等支持,Event Handler的一次完整執行,需要增加不少時間,從而會最後導致Query Side的數據更新的延遲。不過CQRS架構的思想就是Q端的數據由C端通過事件同步過來,所以Q端的更新本身就是有一定的延遲的。這也是CQRS架構所說的要接收最終一致性的原因。

關於冪等處理的性能問題的思考

關於CommandStore的性能瓶頸分析

大家知道,整個CQRS架構中,Command,Event的產生以及處理是非常頻繁的,數據量也是非常大的。那如何保證這幾步冪等處理的高性能呢?對於Command的冪等處理,如果對性能要求不是很高,那我們可以簡單使用關係型DB即可,比如Sql Server, MySQL都可以。要實現冪等處理,只需要把主鍵設計爲CommandId即可。其他不需要額外的唯一索引。所以這裏的性能瓶頸相當於是對單表做大量insert操作的最大TPS。一般MySQL數據庫,SSD硬盤,要達到2W TPS應該沒什麼問題。對於這個表,我們基本只有寫入操作,不需要讀取操作。只有是在Command插入遇到主鍵衝突,然後纔可能需要偶爾根據主鍵讀取一下已經存在的Command的信息。然後,如果單表數據量太大,那怎麼辦,就是分表分庫了。這就是最開始談到的,要避開海量數據這個原則了,我想就是通過sharding避開大數據來實現繞過IO瓶頸的設計了。不過一旦涉及到分庫,分表,就又涉及到根據什麼分庫分表了,對於存儲Command的表,我覺得比較簡單,我們可以先根據Command的類型(相當於根據業務做垂直拆分)做第一級路由,然後相同Command類型的Command,根據CommandId的hashcode路由(水平拆分)即可。這樣就能解決Command通過關係型DB存儲的性能瓶頸問題。其實我們還可以通過流行的基於key/value的NoSQL來存儲,比如可以選擇本地運行的leveldb,或者支持分佈式的ssdb,或者其他的,具體選擇哪個,可以結合自己的業務場景來選擇。總之,Command的存儲可以有很多選擇。

關於EventStore的性能瓶頸分析

通過上面的分析,我們知道Event的存儲唯一需要的是AggregateRootId+Version的唯一索引,其他就無任何要求了。那這樣就和CommandStore一樣好辦了。如果也是採用關係型DB,那隻要用AggregateRootId+Version這兩個作爲聯合主鍵即可。然後如果要分庫分表,我們可以先根據AggregateRootType做第一級垂直拆分,即把不同的聚合根類型產生的事件分開存儲。然後和Command一樣,相同聚合根產生的事件,可以根據AggregateRootId的hashcode來拆分,同一個AggregateRootId的所有事件都放一起。這樣既能保證AggregateRootId+Version的唯一性,又能保證數據的水平拆分。從而讓整個EventStore可以無限制水平伸縮。當然,我們也完全可以採用基於key/value的NoSQL來存儲。另外,我們查詢事件,也都是會確定聚合根的類型以及聚合根的ID,所以,這和路由機制一直,不會導致我們無法知道當前要查詢的聚合根的事件在哪個分區上。

設計存儲時的重點考慮點

在設計command, event的存儲時,我認爲主要考慮的應該是提高整體的吞吐量,而不是追求單機存儲的性能。因爲假如我們的系統平均每秒產生1W個事件,那一天就是8.64億個事件。已經是很大的數據量。所以,我們必須要對command, event這種進行分片。比如我們設計864個表,那每個表每天產生100W條記錄,這是在可以接受的範圍內。然後,我們一旦分了864個表了,肯定會把它們分佈在不同的物理數據庫上。這樣就是多個物理數據庫同時提供存儲服務,可以整體提高存儲的吞吐量。我個人比較傾向於使用MySQL來存儲即可,因爲一方面MySQL是開源的,各種分庫分表的成熟做法比較多。另一方面,關係型數據庫相比Mongodb這種,自己更熟悉,能更好的控制。比如數據擴容方案可以自己做,不像MongoDB這種,雖然它都幫我們搞定了大數據存儲,但一旦出了問題,也許自己無法掌控。另一方面,關於RT,即單條數據存儲時的響應時間,這個我覺得不管是關係型數據庫還是NoSQL,最終的瓶頸都是在磁盤IO。NoSQL之所以這麼快,無非就是異步刷盤;而關係型DB不是很快,因爲它要保證數據的落地,要保證數據的更高級別的可靠性。所以,我覺得,要在保證數據不會丟失的情況下,儘量提高RT,可以考慮使用SSD硬盤。另一方面,我覺得由於我們已經做了分庫分表了,所以單個DB的壓力不會太大,所以一般局域網內的RT也不會延遲很大,應該可以接受。

聚合根的內存模式(In-Memory)

In-Memory模式也是一種減少網絡IO的一種設計,通過讓所有生命週期還沒結束的聚合根一直常駐在內存,從而實現當我們要修改某個聚合根時,不必再像傳統的方式那樣,先從db獲取聚合根,再更新,完成後再保存到db了。而是聚合根一直在內存,當Command Handler要修改某個聚合根時,直接從內存拿到該聚合根對象即可,不需要任何序列化反序列化或IO的操作。基於ES模式,我們不需要直接保存聚合根,而是隻要簡單的保存聚合根產生的事件即可。當服務器斷電要恢復聚合根時,則只要用事件溯源(Event Sourcing, ES)的方式恢復聚合根到最新狀態即可。

瞭解過actor的人應該也知道actor也是整個集羣中就一個實例,然後每個actor自己都有一個mailbox,這個mailbox用於存放當前actor要處理的所有的消息。只要服務器不斷電,那actor就一直存活在內存。所以,In-Memory模式也是actor的一個設計思想之一。像之前很轟動的國外的一個LMAX架構,號稱每秒單機單核可以處理600W訂單,也是完全基於in-memory模式。不過LMAX架構我覺得只要作爲學習即可,要大範圍使用,還是有很多問題要解決,老外他們使用這種架構來處理訂單,也是基於特定場景的,並且對編程(代碼質量)和運維的要求都非常高。具體有興趣的可以去搜一下相關資料。

關於in-memory架構,想法是好的,通過將所有數據都放在內存,所有持久化都異步進行。也就是說,內存的數據纔是最新的,db的數據是異步持久化的,也就是某個時刻,內存中有些數據可能還沒有被持久化到db。當然,如果你說你的程序不需要持久化數據,那另當別論了。那如果是異步持久化,主要的問題就是宕機恢復的問題了。我們看一下akka框架是怎麼持久化akka的狀態的吧。

  1. 多個消息同時發送給actor時,全部會先放入該actor的mailbox裏排隊;
  2. 然後actor單線程從mailbox順序消費消息;
  3. 消費一個後產生事件;
  4. 持久化事件,akka-persistence也是採用了ES的方式持久化;
  5. 持久化完成後,更新actor的狀態;
  6. 更新狀態完成後,再處理mailbox中的下一個消息;

從上面的過程,我們可以看出,akka框架本質上也實現了避免資源競爭的原則,因爲每個actor是單線程處理它的mailbox中的每個消息的,從而就避免了併發衝突。然後我們可以看到akka框架也是先持久化事件之後,再更新actor的狀態的。這說明,akka採用的也叫保守的方式,即必須先確保數據落地,再更新內存,再處理下一個消息。真正理想的in-memory架構,應該是可以忽略持久化,當actor處理完一個消息後,立即修改自己的狀態,然後立即處理下一個消息。然後actor產生的事件的持久化,完全是異步的;也就是不用等待持久化事件完成後再更新actor的狀態,然後處理下一個消息。

我認爲,是不是異步持久化不重要,因爲既然大家都要面臨一個問題,就是要在宕機後,恢復actor的狀態,那持久化事件是不可避免的。所以,我也是認爲,事件不必異步持久化,完全可以像akka框架那樣,產生的事件先同步持久化,完成後再更新actor的狀態即可。這樣做,在宕機恢復actor的狀態到最新時,就只要簡單的從db獲取所有事件,然後通過ES得到actor最新狀態即可。然後如果擔心事件同步持久化有性能瓶頸,那這個總是不可避免,這塊不做好,那整個系統的性能就上不去,所以我們可以採用SSD,sharding, Group Commit, NoSQL等方法,優化持久化的性能即可。當然,如果採用異步持久化事件的方式,確實能大大提高actor的處理性能。但是要做到這點,還需要有一些前提的。比如要確保整個集羣中一個actor只有一個實例,不能有兩個一樣的actor在工作。因爲如果出現這種情況,那這兩個一樣的actor就會同時產生事件,導致最後事件持久化的時候必定會出現併發衝突(事件版本號相同)的問題。但要保證急羣衆一個actor只有一個實例,是很困難的,因爲我們可能會動態往集羣中增加服務器,此時必定會有一些actor要遷移到新服務器。這個遷移過程也很複雜,一個actor從原來的服務器遷移到新的服務器,意味着要先停止原服務器的actor的工作。然後還要把actor再新服務器上啓動;然後原服務器上的actor的mailbox中的消息還要發給新的actor,然後後續可能還在發給原actor的消息也要轉發到新的actor。然後新的actor重啓也很複雜,因爲要確保啓動之後的actor的狀態一定是最新的,而我們知道這種純in-memory模式下,事件的持久化時異步的,所以可能還有一些事件還在消息隊列,還沒被持久化。所以重啓actor時還要檢查消息隊列中是否還有未消費的事件。如果還有,就需要等待。否則,我們恢復的actor的狀態就不是最新的,這樣就無法保證內存數據是最新的這個目的,這樣in-memory也就失去了意義。這些都是麻煩的技術問題。總之,要實現真正的in-memory架構,沒那麼容易。當然,如果你說你可以用數據網格之類的產品,無分佈式,那也許可行,不過這是另外一種架構了。

上面說了,akka框架的核心工作原理,以及其他一些方面,比如akka會確保一個actor實例在集羣中只有一個。這點其實也是和本文說的一樣,也是爲了避免資源競爭,包括它的mailbox也是一樣。之前我設計ENode時,沒了解過akka框架,後來我學習後,發現和ENode的思想是如此接近,呵呵。比如:1)都是集羣中只有一個聚合根實例;2)都對單個聚合根的操作的Command做排隊處理;3)都採用ES的方式進行狀態持久化;4)都是基於消息驅動的架構。雖然實現方式有所區別,但目的都是相同的。

小結

本文,從CQRS+Event Sourcing的架構出發,結合實現高性能的幾個要注意的點(避開網絡開銷(IO),避開海量數據,避開資源爭奪),分析了這種架構下,我所想到的一些可能的設計。整個架構中,一個Command在被處理時,一般是需要做兩次IO,1)持久化Command;2)持久化事件;當然,這裏沒有算上消息的發送和接收的IO。整個架構完全基於消息驅動,所以擁有一個穩定可擴展高性能的分佈式消息隊列中間件是比不可少的,EQueue正是在向這個目標努力的一個成果。目前EQueue的TCP通信層,可以做到發送100W消息,在一臺i7 CPU的普通機器上,只需3s;有興趣的同學可以看一下。最後,ENode框架就是按照本文中所說的這些設計來實現的,有興趣的朋友歡迎去下載並和我交流哦!不早了,該睡了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章