事件溯源(Event Sourcing)和命令查詢責(zé)任分離(CQRS)經(jīng)驗
這篇文章是實現(xiàn)一個基于 CQRS 和事件溯源原則的應(yīng)用程序,描述這個過程的方式,我相信分享我面臨的挑戰(zhàn)和問題可能對一些人有用。特別是如果你正在開始自己的旅程。

一、業(yè)務(wù)背景
項目的背景與空中交通管理(ATM)領(lǐng)域相關(guān)。我們?yōu)橐粋€ ANSP(航空導(dǎo)航服務(wù)提供商)設(shè)計了一個解決方案,負責(zé)控制特定地理區(qū)域。這個應(yīng)用程序的目標(biāo)很簡單:計算并持久化飛行數(shù)據(jù)。流程大致如下。
在飛機穿越其領(lǐng)空之前的幾個小時,ANSP會收到來自 Eurocontrol 的信息,這個組織負責(zé)管理整個歐洲的航空交通。這些信息包含計劃數(shù)據(jù),如飛機類型、起飛地點、目的地、請求的航路等。一旦飛機到達了 ANSP 的 AOR(責(zé)任區(qū)域,ANSP負責(zé)控制和監(jiān)控航班的區(qū)域),我們就可以從各種來源接收輸入:航跡更新(飛行當(dāng)前位置是什么)、修改當(dāng)前航路的請求、由軌跡預(yù)測系統(tǒng)觸發(fā)的事件、來自沖突檢測系統(tǒng)的警報等。
雖然我們可能需要同時處理多個潛在的并發(fā)請求,但在吞吐量方面,與 PayPal 或 Netflix 沒法相提并論。
盡管如此,該應(yīng)用程序是安全關(guān)鍵環(huán)境的一部分。在發(fā)生關(guān)鍵故障的情況下,我們不會損失金錢或客戶,但我們可能會失去人的生命。因此,實現(xiàn)一個可靠、響應(yīng)迅速且具有彈性的系統(tǒng),以保證數(shù)據(jù)一致性和完整性,顯然是首要任務(wù)。
二、CQRS、事件溯源
這兩種模式實際上都相當(dāng)容易理解。
1.CQRS
CQRS(命令查詢責(zé)任分離)是一種將寫入(命令)和讀?。ú樵儯┓蛛x的方式。這意味著我們可以有一個數(shù)據(jù)庫來管理寫入部分。而讀取部分(也稱為視圖或投影)是從寫入部分派生出來的,可以由一個或多個數(shù)據(jù)庫來管理(取決于我們的用例)。大多數(shù)情況下,讀取部分是異步計算的,這意味著兩個部分并不嚴格一致。我們稍后會回到這一點。
CQRS 背后的想法之一是數(shù)據(jù)庫很難同時高效地處理讀寫操作。這可能取決于軟件供應(yīng)商的選擇、應(yīng)用的數(shù)據(jù)庫調(diào)優(yōu)等。例如,Apache Cassandra 在持久化數(shù)據(jù)方面效率很高,而 Elasticsearch 對搜索非常出色。使用 CQRS 真的是利用解決方案的優(yōu)勢的一種方式。
此外,我們還可以決定處理不同的數(shù)據(jù)模型。再次強調(diào),這取決于需求。例如,在報告視圖上使用的模型,另一個在寫入部分持久化期間效率高的非規(guī)范化模型等。
關(guān)于視圖,我們可能決定實現(xiàn)一些與消費者無關(guān)的視圖(例如公開特定的業(yè)務(wù)對象),或者一些專門針對消費者的視圖。
2.事件溯源
根據(jù) Martin Fowler 對事件溯源的定義:
確保應(yīng)用狀態(tài)的所有更改都存儲為事件序列
這意味著我們不存儲對象的狀態(tài)。相反,我們存儲影響其狀態(tài)的所有事件。然后,要檢索對象狀態(tài),我們必須讀取與該對象相關(guān)的不同事件,并逐個應(yīng)用它們。
3.CQRS + 事件溯源
這兩種模式經(jīng)常被組合在一起。在 CQRS 之上應(yīng)用事件溯源意味著將每個事件持久化在我們應(yīng)用程序的寫入部分。然后,讀取部分是從事件序列派生出來的。
在我看來,實現(xiàn) CQRS 時并不需要事件溯源。然而,反之則不一定成立。
事實上,對于大多數(shù)用例,在實現(xiàn)事件溯源時需要 CQRS,因為我們可能希望以 O(1) 的時間復(fù)雜度檢索狀態(tài),而不必計算 n 種不同的事件。一個例外是簡單的審計日志用例。在這里,我們不需要管理視圖(也不需要狀態(tài)),因為我們只對檢索日志序列感興趣。
三、領(lǐng)域驅(qū)動設(shè)計
領(lǐng)域驅(qū)動設(shè)計(DDD)是一種處理與領(lǐng)域模型相關(guān)的軟件復(fù)雜性的方法。它由 Eric Evans 在 2004 年的《Domain-Driven Design: Tackling Complexity in the Heart of Software》一書中引入。
我們不會介紹所有不同的概念,但如果你對此不熟悉,我強烈建議你去看一看。不過,我們將只介紹在 CQRS/事件溯源應(yīng)用程序環(huán)境中有用的概念。
DDD 帶來的第一個概念是聚合(Aggregate)。聚合是一組領(lǐng)域?qū)ο?,從?shù)據(jù)變更角度來看,它們被視為一個單元。在聚合內(nèi)部的事務(wù)必須保持原子性。
與此同時,聚合通過不變式來強制執(zhí)行自己的數(shù)據(jù)一致性和完整性。不變式就是一個規(guī)則,無論如何變化,它都必須保持為真。例如,標(biāo)準(zhǔn)終端到達航線(STAR,基本上是著陸前的預(yù)定義航線)始終與給定機場相關(guān)聯(lián)。一個不變式必須強制執(zhí)行這樣一個規(guī)則:目的機場不能在沒有更改 STAR 的情況下被修改,并且這個 STAR 與該機場是有效的。
此外,作為聚合的外觀對象(處理輸入并將業(yè)務(wù)邏輯委托給子對象的對象)被稱為聚合根。
關(guān)于組成聚合的對象,我們需要區(qū)分實體和值對象。實體是具有標(biāo)識的對象,它不是由其屬性定義的。一個人的年齡會隨著時間的推移而改變,但他/她仍然是同一個人。另一方面,值對象僅由其屬性定義。不同城市的地址是不同的地址。前者是可變的,而后者是不可變的。此外,實體可以有自己的生命周期。例如,一個航班首先準(zhǔn)備起飛,然后是空中飛行,最后著陸。
在模型定義中,實體應(yīng)盡可能簡單,并專注于其標(biāo)識和其生命周期。在 CQRS/事件溯源應(yīng)用程序的上下文中,實體是一個關(guān)鍵元素,因為大多數(shù)情況下,在聚合內(nèi)進行的更改是基于它們的生命周期。例如,至關(guān)重要的是確保每個實體實現(xiàn)了一個函數(shù),用于確定它是否與另一個實體實例相等。這可以通過比較標(biāo)識符或一組相關(guān)屬性來實現(xiàn),從而保證了一個標(biāo)識。
既然我們已經(jīng)了解了實體的概念,讓我們回到不變式。為了定義它們,我們使用了受 BDD(行為驅(qū)動開發(fā))格式啟發(fā)的語言:
Given [entity] at state [state]
When [event] occurs
We shall [rules]領(lǐng)域驅(qū)動設(shè)計(DDD)是一種處理與領(lǐng)域模型相關(guān)的軟件復(fù)雜性的方法。它由 Eric Evans 在 2004 年的《Domain-Driven Design: Tackling Complexity in the Heart of Software》一書中引入。
我們不會介紹所有不同的概念,但如果你對此不熟悉,我強烈建議你去看一看。不過,我們將只介紹在 CQRS/事件溯源應(yīng)用程序環(huán)境中有用的概念。
DDD 帶來的第一個概念是聚合(Aggregate)。聚合是一組領(lǐng)域?qū)ο?,從?shù)據(jù)變更角度來看,它們被視為一個單元。在聚合內(nèi)部的事務(wù)必須保持原子性。
與此同時,聚合通過不變式來強制執(zhí)行自己的數(shù)據(jù)一致性和完整性。不變式就是一個規(guī)則,無論如何變化,它都必須保持為真。例如,標(biāo)準(zhǔn)終端到達航線(STAR,基本上是著陸前的預(yù)定義航線)始終與給定機場相關(guān)聯(lián)。一個不變式必須強制執(zhí)行這樣一個規(guī)則:目的機場不能在沒有更改 STAR 的情況下被修改,并且這個 STAR 與該機場是有效的。
此外,作為聚合的外觀對象(處理輸入并將業(yè)務(wù)邏輯委托給子對象的對象)被稱為聚合根。
關(guān)于組成聚合的對象,我們需要區(qū)分實體和值對象。實體是具有標(biāo)識的對象,它不是由其屬性定義的。一個人的年齡會隨著時間的推移而改變,但他/她仍然是同一個人。另一方面,值對象僅由其屬性定義。不同城市的地址是不同的地址。前者是可變的,而后者是不可變的。此外,實體可以有自己的生命周期。例如,一個航班首先準(zhǔn)備起飛,然后是空中飛行,最后著陸。
在模型定義中,實體應(yīng)盡可能簡單,并專注于其標(biāo)識和其生命周期。在 CQRS/事件溯源應(yīng)用程序的上下文中,實體是一個關(guān)鍵元素,因為大多數(shù)情況下,在聚合內(nèi)進行的更改是基于它們的生命周期。例如,至關(guān)重要的是確保每個實體實現(xiàn)了一個函數(shù),用于確定它是否與另一個實體實例相等。這可以通過比較標(biāo)識符或一組相關(guān)屬性來實現(xiàn),從而保證了一個標(biāo)識。
既然我們已經(jīng)了解了實體的概念,讓我們回到不變式。為了定義它們,我們使用了受 BDD(行為驅(qū)動開發(fā))格式啟發(fā)的語言:

應(yīng)用程序設(shè)計
簡而言之,應(yīng)用程序接收命令并發(fā)布內(nèi)部事件。這些事件被持久化到事件存儲中,并發(fā)布給處理程序,這些處理程序負責(zé)更新視圖。我們還可以決定在視圖之上實現(xiàn)一個服務(wù)層(稱為讀處理程序)。
現(xiàn)在,讓我們詳細看看不同的場景。
2.聚合創(chuàng)建
命令處理程序接收一個 CreateFlight 命令,并在領(lǐng)域存儲庫中檢查實例是否存在。這個領(lǐng)域存儲庫管理聚合實例。它首先在緩存中進行檢查,如果對象不存在,則會在事件存儲中進行檢查。事件存儲是一個用于持久化事件序列的數(shù)據(jù)庫。我會稍后詳細說明我認為一個好的事件存儲是什么。在這種情況下,事件存儲仍然為空,因此存儲庫不會返回任何內(nèi)容。
命令處理程序負責(zé)觸發(fā)不變式。在出現(xiàn)失敗的情況下,我們可以同步拋出異常來指示業(yè)務(wù)問題。否則,命令處理程序?qū)l(fā)布一個或多個事件到事件總線。事件的數(shù)量取決于內(nèi)部數(shù)據(jù)模型的粒度。在我們的場景中,我們假設(shè)發(fā)布了一個單一的 FlightCreated 事件。
在此事件上觸發(fā)的第一個組件是領(lǐng)域處理程序。這個組件負責(zé)根據(jù)實現(xiàn)的邏輯更新領(lǐng)域聚合。通常,邏輯被委托給聚合根(充當(dāng)外觀,但也可以將底層邏輯委托給子域?qū)ο螅?。請記住,聚合必須始終保持一致,并且還必須通過驗證不變式來強制執(zhí)行數(shù)據(jù)完整性。
如果處理程序成功(未引發(fā)業(yè)務(wù)錯誤),則事件將被持久化到事件存儲中,并且緩存將使用最新的聚合實例進行更新。
然后,觸發(fā)視圖處理程序來更新其對應(yīng)的視圖。就像在普通的發(fā)布-訂閱模式中一樣,視圖可以只訂閱它感興趣的事件。也許在我們的情況下,視圖 2 是唯一對 FlightCreated 事件感興趣的視圖。
3.聚合更新
第二種情景是更新現(xiàn)有的聚合。在接收到 UpdateFlight 命令時,命令處理程序會請求存儲庫返回最新的聚合實例(如果有的話)。
如果實例已經(jīng)在緩存中,則無需與事件存儲交互。否則,存儲庫將觸發(fā)所謂的重新裝載過程。
這個過程是根據(jù)存儲的事件序列計算聚合實例的當(dāng)前狀態(tài)的一種方式。從事件存儲中檢索的每個事件(比如 FlightCreated、DepartureUpdated 和 ArrivalUpdated)都會被發(fā)布到事件總線。第一個領(lǐng)域處理程序觸發(fā) FlightCreated 時會實例化一個新的聚合(根據(jù)事件本身提供的信息,在內(nèi)存中創(chuàng)建一個新的對象實例)。然后其他領(lǐng)域處理程序(由 DepartureUpdated 和 ArrivalUpdated 事件觸發(fā))將更新剛剛創(chuàng)建的聚合實例。最終,我們能夠根據(jù)存儲的事件計算出狀態(tài)。
一旦計算出狀態(tài),對象實例就會被放入緩存并返回給命令處理程序。然后,其余的流程與聚合創(chuàng)建情景相同。
關(guān)于重新裝載過程還有一件事需要補充。如果一個聚合不在緩存中,而我們?yōu)橐粋€特定的聚合實例存儲了 1000 個事件,那么會花費很長時間來計算其狀態(tài)。有一個已知的緩解措施叫做快照。
我們可以決定在每 n 個事件中持久化聚合的當(dāng)前狀態(tài)作為一個快照。這個快照也會包含在事件存儲中的位置。然后,重新裝載過程將簡單地從最新的快照開始,并從指定的位置繼續(xù)??煺者€可以根據(jù)其他策略類型創(chuàng)建(如果重新裝載時間超過某個閾值等)。
4.如何處理事件?
我想再回顧一下我們對命令和事件的區(qū)分。首先,有必要區(qū)分內(nèi)部事件和外部事件。外部事件是由另一個應(yīng)用程序產(chǎn)生的,而內(nèi)部事件是由我們的應(yīng)用程序生成的(基于外部命令)。
我們就如何在我們的應(yīng)用程序中技術(shù)性地處理外部事件進行了一場有趣的辯論。我的意思是,真正的事件指的是已經(jīng)在過去發(fā)生的事情(比如雷達軌跡)。
實際上有兩種可能的處理方法:
- 第一種方法是將事件視為命令。這意味著我們必須首先通過一個命令處理程序,驗證不變式,然后生成一個內(nèi)部事件。
- 第二種方法是繞過命令處理程序,直接將事件持久化到事件存儲中。畢竟,如果我們談?wù)摰氖且粋€真實事件,那么驗證不變式等操作實際上是沒有什么用的。然而,檢查事件的語法仍然很重要,以確保我們不會污染事件存儲。
如果我們選擇第二個選項,可能會有興趣在聚合重新裝載期間實現(xiàn)規(guī)則。
讓我們舉一個雷達軌跡發(fā)布飛行位置的例子。如果生產(chǎn)者無法保證消息的順序,我們還可以持久化一個時間戳(由生產(chǎn)者生成),并以這種方式計算狀態(tài):
if event.date > latestEventDate { // Compute the state
latestEventDate = event.date} else { // Discard the event}這個規(guī)則將確保狀態(tài)僅基于最新生成的事件。這意味著持久化一個事件不一定會影響當(dāng)前狀態(tài)。
在第一種方法中,在持久化事件之前會實現(xiàn)這樣的規(guī)則。
5.事件模型
在事件存儲中持久化的事件是否需要創(chuàng)建一個統(tǒng)一的模型?在我看來,答案是否定的(至少大部分情況下是)。
首先,因為我們可能希望隨著時間推移持久化不同的模型版本。在這種情況下,我們必須實現(xiàn)一種策略,將一個模型版本的事件映射到另一個模型版本。
我想用一個具體的例子來說明另一個好處。假設(shè)一個應(yīng)用程序接收來自系統(tǒng) A 和系統(tǒng) B 的事件。這兩個系統(tǒng)基于各自的數(shù)據(jù)模型發(fā)布飛行事件。如果我們創(chuàng)建一個通用數(shù)據(jù)模型 C,我們需要在持久化事件之前將 A 轉(zhuǎn)換為 C 和 B 轉(zhuǎn)換為 C。然而,在項目的某個階段,我們只對來自 A 和 B 的某些信息感興趣。這意味著 C 只是 A 和 B 的一個子集。
但是如果以后我們需要對應(yīng)用程序進行一些改進,并管理來自 A 和 B 的額外元素怎么辦?因為事件是使用 C 格式持久化的,所以這些元素就會被簡單地丟失。另一方面,如果我們決定持久化 A 和 B 格式,我們可以簡單地對命令處理程序進行一些改進,以管理這些元素。
四、最終一致性
1.理論
最終一致性是由 CQRS(大多數(shù)情況下)引入的一個概念。理解其影響和后果非常重要。
首先,值得一提的是有不同的一致性級別。
最終一致性是一個模型,我們可以確保數(shù)據(jù)會被復(fù)制(從 CQRS 應(yīng)用程序的寫入部分到讀取部分)。問題在于我們無法確切保證何時復(fù)制完成。這會受到各種因素的影響,比如整體吞吐量、網(wǎng)絡(luò)延遲等。這是最弱的一致性形式,但提供了最低的延遲。
在 CQRS 應(yīng)用程序中應(yīng)用最終一致性意味著在某個時刻,寫入部分可能與讀取部分不同步。
相反地,我們可以找到強一致性模型。除非我們在分布式系統(tǒng)中使用相同的數(shù)據(jù)庫來管理讀取和寫入,或者我們通過使用兩階段提交向惡魔出賣了我們的靈魂,否則在分布式系統(tǒng)中我們不應(yīng)該達到這種一致性級別。
最接近的實現(xiàn)方法是,如果我們有兩個不同的數(shù)據(jù)庫,那就在單個線程中管理所有操作。這個線程將負責(zé)將數(shù)據(jù)持久化到寫入數(shù)據(jù)庫和讀取數(shù)據(jù)庫(們)。一個線程還可以專門用于單個聚合實例,并按順序處理傳入的命令。然而,如果在同步視圖時發(fā)生瞬態(tài)錯誤,會有什么影響?我們需要補償其他視圖和 CQRS 應(yīng)用程序的寫入部分嗎?我們需要實現(xiàn)錯誤重試循環(huán)嗎?我們需要通過暫停命令處理程序來停止新的傳入事件,應(yīng)用斷路器模式嗎?解決顯然會發(fā)生的瞬態(tài)錯誤是很重要的(凡是可能出錯的地方遲早會出錯)。
在最終一致性和強一致性兩種一致性模型之間,我們可以找到許多不同的模型:因果一致性、順序一致性等。舉例來說,客戶端單調(diào)一致性模型僅在會話(應(yīng)用程序或服務(wù)實例)內(nèi)保證強一致性。因此,實現(xiàn) CQRS 應(yīng)用程序并不只是在最終一致性和強一致性之間做出選擇。
我個人的觀點是:由于我們幾乎無法保證強一致性,讓我們盡可能地接受最終一致性。然而,前提是要精確理解其對系統(tǒng)其余部分的影響。
2.例子
讓我們看一個我在項目中遇到的具體例子。
其中一個挑戰(zhàn)是管理每架飛機的唯一標(biāo)識符。我們不得不處理來自外部系統(tǒng)(公司外部)的事件,這些系統(tǒng)中的標(biāo)識符并不相同。對于一個通道,標(biāo)識符是一個復(fù)合標(biāo)識符(出發(fā)機場 + 出發(fā)時間 + 飛機標(biāo)識符 + 到達機場),而另一個通道則發(fā)送每架飛機的唯一標(biāo)識符(但第一個通道不知道)。我們的目標(biāo)是管理我們自己的唯一標(biāo)識符(稱為 GUFI,即全局唯一飛行標(biāo)識符),并確保每個事件都對應(yīng)于正確的 GUFI。
最簡單的解決方案是確保每個傳入的事件都在我們應(yīng)用程序的特定視圖中進行查找,以關(guān)聯(lián)相應(yīng)的 GUFI。但如果這個視圖是最終一致的呢?在最壞的情況下,我們可能會有與同一飛行相關(guān)的事件,但使用不同的 GUFIs 進行存儲(相信我,這是一個問題)。
一個解決方案可能是將這個 GUFI 的管理委托給另一個強一致性的服務(wù)。
在一次問答環(huán)節(jié)中,Greg Young 提供了另一個解決方案。我們可以實現(xiàn)一種緩沖區(qū),其中只包含我們應(yīng)用程序處理的 n 個最新事件。如果視圖中不包含我們正在尋找的數(shù)據(jù),我們必須在這個緩沖區(qū)中檢查,以確保它不是剛剛在視圖之前接收到的。n 越大,減輕寫入和讀取之間的這種不一致性窗口的機會就越大。
這個緩沖區(qū)可以使用像 Hazelcast、Redis 等解決方案進行分布式處理,也可以局部于應(yīng)用程序?qū)嵗?。在后一種情況下,我們可能需要實現(xiàn)一個分片機制,使用哈希函數(shù)將相關(guān)對象的事件始終分發(fā)到相同的應(yīng)用程序?qū)嵗ㄗ詈檬鞘褂靡环N一致性哈希函數(shù),以便輕松擴展)。
五、并發(fā)管理
幾個月前我已經(jīng)創(chuàng)建了一篇文章,描述了使用事件源管理并發(fā)更新的好處。
簡而言之,擁有事件存儲可能會幫助我們找到比悲觀或樂觀方法更聰明的解決方案來處理并發(fā)更新。
此外,在數(shù)據(jù)模型中應(yīng)用正確的粒度也是項目成功的關(guān)鍵。
六、選擇事件存儲
我們可以決定使用任何類型的數(shù)據(jù)庫來持久化事件序列。然而,最優(yōu)解往往是為事件源構(gòu)建的解決方案。
例如,隔離一個聚合實例是必須考慮的事情。假設(shè)所有事件都存儲在一個單一表中。這個表會隨著時間不斷增長,在聚合重建時,我們將不得不過濾與一個特定聚合實例相關(guān)的事件。重建一個聚合的時間將取決于持久化的事件總數(shù),即使其中一些事件與我們感興趣的實例無關(guān)。一個好的解決方案可能是為每個聚合實例擁有一個表/存儲桶,以隔離事件。我們稱這個概念為流(stream)。一個流總是與一個聚合實例相關(guān)聯(lián)(在大多數(shù)用例中)。
以下是我們考慮選擇事件存儲時的要求:
(1) 寫入:
- 恒定的寫入延遲:無論流的大小如何,持久化事件的延遲都必須保持恒定
- 原子性:可以在單個事務(wù)中追加多個事件
- TTL 管理:根據(jù)創(chuàng)建日期自動丟棄事件
- 無模式:可以存儲多種事件類型和版本
(2) 讀?。?/p>
- 按寫入順序讀取事件
- 從特定序列號讀?。ㄒ驗榭煺眨?/li>
- 在給定流中保持恒定的讀取性能,不受其他流的影響
- 圖形用戶界面(GUI)
- 緩存管理
(3) 并發(fā):
- 樂觀并發(fā)模型
- 冪等性管理
(4) 產(chǎn)品監(jiān)控
(5) 解決方案支持
(6) 安全性:
- 加密(傳輸)
- 身份驗證
- 授權(quán)管理
(7) 擴展性
(8) 備份
每個上下文都是獨特的,我相信你會有自己的要求,但這至少可能是一個起點。
七、結(jié)論
CQRS 和事件源并非魔法。在開始你的旅程之前,理解這兩種模式的許多影響至關(guān)重要。否則,在技術(shù)和功能層面都很容易造成徹底的混亂。
然而,一旦你對約束和缺點有了明確的理解,CQRS 和/或事件源可能是許多問題的很好解決方案。






























