1.必須要高性能;
2.支援聚合根事件的并發持久化,要確定單個聚合根執行個體不會儲存版本号相同的事件;
經過了一番調研,發現用檔案存儲事件非常合适。要確定高性能,我們可以順序寫檔案(append),然後随機讀檔案。之是以要随機讀檔案是因為在當某些command由于操作同一個聚合根而遇到并發沖突的時候,架構需要擷取該聚合根的所有最新的事件,然後通過event sourcing重建出最新的聚合根,然後再重試這些遇到并發沖突的command。經過測試,順序寫檔案和随機讀檔案都非常高效,每秒100w次順序寫和每秒10w次随機讀在我的筆記本上不是問題;因為在enode中,domain是基于in-memory架構的,是以我們很少會從eventstore讀取事件。是以重點是要優化持久化事件的性能。而讀事件隻有在command遇到并發沖突的時候或系統重新開機的時候,才有可能需要從eventstore讀取事件。是以每秒10w次随機讀取應該不是問題。當然,關于檔案如何寫,見下面的遺留問題的分析。
另外一個就是刷磁盤的問題。我們知道,通過檔案流寫入資料到檔案後,如果不flush檔案流,那資料有可能還沒刷到磁盤。是以必須定時flush檔案流,出于性能和可靠性的權衡,選擇定時1s刷一次磁盤,通過異步線程刷盤。實際上,大部分nosql産品都是如此,比如redis的fsync可以指定為每隔1s刷一次aof日志到磁盤。這樣做唯一的問題是斷電後可能丢失1s的資料,但這個可以通過在伺服器上配置ups備用電源確定斷電後伺服器還能工作,來確定斷電後還能支援足夠的時間確定我們把檔案流的資料刷到磁盤。這樣既解決性能問題,也能保證不丢失資料。
首先,每個聚合根執行個體有多個事件,每個時刻,每個聚合根可能都會産生多個事件然後要儲存到eventstore中。為什麼呢?因為我們的domain model所在的應用伺服器一般是叢集部署的,是以完全有可能同一個聚合根在不同的機器上在被同時在做不同的修改,然後産生的事件的版本号是相同的,進而就會導緻并發修改同一個聚合根的情況了。
是以,我們主要要確定的是,對同一個聚合根執行個體,産生的事件如果版本号相同,則隻能有一個事件能儲存成功,其他的認為并發沖突,需要告訴外部有并發沖突了,然後由外部決定接下來該如何做。那麼如何保證這一點呢?
前面說到,所有聚合根的事件都是順序的方式append到同一個檔案,append事件到檔案這個步驟本身沒辦法檢查是否有并發沖突,檔案隻能幫我們持久化資料,不負責檢查是否有并發沖突。那如何檢查并發沖突呢?思路就是在記憶體設計一個dictionary,dictionary的key為聚合根id,value儲存目前聚合根産生的事件的最大版本号,也就是最後一個事件的版本号。
然後有兩個辦法可以實作并發沖突的檢測:
所有的事件進入eventstore伺服器後,先通過一個concurrentqueue進行排隊。所有事件并發進入concurrentqueue,然後concurrentqueue的消費者為單線程。然後我們在單線程内一個個取出concurrentqueue中的事件,然後根據dictionary裡的内容一個個判斷目前事件是否有版本沖突,如果沒沖突,則先将事件寫入檔案,再更新dictionary裡目前聚合根的最大版本号;這個方式沒問題,隻是效率不是非常高,因為這樣相當于對所有的聚合根執行個體的處理都線性化了。實際上,我們希望的是,隻有對同一個聚合根執行個體的操作是線性化的,而對不同聚合根執行個體之間,完全可以并行處理;那怎麼做呢?見第二種思路。
首先,所有的事件不必排隊了,可以并行處理。但是對于每一個聚合根執行個體的事件的處理,需要通過原子鎖的方式(cas原理)做并發控制。關鍵思路是,通過一個字段存儲每個聚合根的目前版本号資訊,版本号資訊中設計一個狀态位用來控制同一時刻隻能有一個線程在更改目前聚合根的版本資訊。以此來實作對同一個聚合根的處理的線性化。然後,目前修改版本狀态成功的線程,能夠進一步做持久化事件的邏輯,但持久化事件之前還需要判斷目前事件的版本是否已經是老的版本了(目前事件的版本一定等于目前聚合根的最大版本号+1),以此來確定同一個聚合根的事件序列一定是連續遞增的。具體的實作思路見如下的demo代碼。
從上圖可以看出,開啟4個線程,并行操作4個聚合根,每個聚合根産生10個不同版本的事件(事件版本号連續遞增),每個事件重複産生10w次,隻花了大概1s時間。另外,最後每個聚合根的目前版本号以及所對應的事件也都是正确的。是以,可以看出,性能還不錯。4個線程并行處理,每秒可以處理400w個事件(當然實際肯定沒這麼高,這裡是因為大部分處理都被compareexchange方法判斷掉了。是以,隻有沒并發的情況,才是理想情況下的最快的性能點,因為每個事件都會做持久化和更新目前版本的邏輯,上面的代碼主要是為了驗證并發情況下是否會産生重複版本的事件這個功能。),且能保證不會持久化重複版本的事件。明天有空把持久化事件替換為真實的寫檔案流的方式,看看性能會有多少,理論上隻要寫檔案流夠快,那性能應該依舊很高。
上圖中的commitlog檔案相當于我上面提到的用來存儲事件的文本檔案,commitlog在metaq消息隊列中是用來存儲消息的。index檔案相當于用來存儲事件在commitlog中的位置和長度。在metaq中,則是用來存儲消息在commitlog中的位置和長度。是以,從存儲結構的角度來看,metaq的消息存儲和eventstore的事件存儲的結構一緻;但不一樣的是,metaq在存儲消息時,不需要做并發控制,所有消息隻要append消息到commitlog即可,所有的index檔案也隻要append寫入即可,關于metaq具體更詳細的設計我還沒深入研究,有興趣的朋友也可以和我交流。而eventstore則必須對事件的版本号做并發控制,這是最大的差別。另外,實際上,事件的索引資訊可以隻需要維護在記憶體中即可,因為這些索引資訊在eventstore啟動時總是可以通過commitlog還原出來。當然我們維護一份index檔案也可以,隻是會增加事件持久化時的複雜度,這裡到底是否需要這個index檔案,我需要再研究下metaq後才能更進一步明确。
這篇文章洋洋灑灑,都是思路性的東西,希望大家看了不會枯燥,呵呵。歡迎大家提出自己的意見和建議!