為什么微服務架構需要聚合
學習架構不僅僅是為了成為一名合格的架構師,同時也可以在設計、開發、部署一個系統、甚至一個模塊時能夠更合理地考慮到其內部的權衡取舍,以及與周邊系統的耦合和隔離問題。當然在自己能力不足的情況下,"抄",絕對是個捷徑。偉大的明代著名科學家徐光啟就曾說過:"欲求超勝,必先會通。會通之前,必先翻譯"。
譯自:Why Your Microservices Architecture Needs Aggregates。
微服務可以將我們的東西組織成一個考慮周到且定義明確的單元。
一體式架構通常意味着組織中的每個工程師都會涉及到應用的每一部分,且業務體與其他實體緊密耦合,微服務讓我們朝着不同的方向邁進。工程師團隊應該專注於自身的業務領域,業務實體應該只和同領域的實體相耦合。
對領域的描述總是說起來容易,做起來難。例如有界上下文就是一個最近流行的模式,可以幫助我們組織工程師團隊,並在更高層面對業務領域進行划分。
類似地,聚合模式可以幫助我們在更低的層面聚合數據。最初將這種模式定義為按照事務對相關實體進行分組的方式。
此外,它還為我們提供了分解一體式數據架構的藍圖,本質上是將高內聚的實體划分為單一的、原子性的組。
當然好處還遠不止此。有趣的是,聚合模式似乎不像其他分布式軟件設計模式那樣廣為人知,被廣泛討論或普遍實現。但它是構建微服務的基本單元。
預先進行聚合設計可以幫助我們避免各種問題,如例如實體之間的偶然依賴關系或引用泄漏,這些問題通常會妨礙對系統的擴展。下面看下什么是聚合。
聚合
聚合是Eric Evans在他的書中Domain-Driven Design提出的一種設計模式,盡管書中沒有明確地討論微服務體系結構或分布式系統,但已經對這些話題進行了闡述。
一個聚合定義為一個自包含的實體組,作為一個獨立的原子的單元。對任意實體的修改都可能會影響到整個聚合。每個聚合的構成如下:
- 邊界。這是實體之間的界限,界定了哪些實體屬於聚合,哪些不屬於。
- 實體。組中包含的業務對象實體。
- 根。每個聚合會向外部暴露一個實體。聚合外部的對象僅可以引用聚合根,不能直接訪問其他聚合內部的實體。
示意圖如下:

上圖中最外層的橢圓表示聚合的邊界,里面是聚合根(紫色圓形)以及其他實體(綠色圓形)。
由於外部只能通過根來訪問聚合,因此在聚合內部,只有根才能引用其他實體(非根實體之間不能相互引用)。
聚合根
換句話說,根服務是聚合與外界交互的代表,因此應該選擇最合適的實體作為根。幸運的是,實體的選擇通常比較簡單。很多聚合都擁有一個清晰的、主要的實體,該實體上附加了很多其他實體。
下面展示一個簡化的例子:用戶聚合。

注意我們的聚合及其根的名稱都叫"User"。User實體可能包含的屬性,如名和姓,性別,出生日期,可能還會包括國民身份以及其他少量標量字段。
User
和它關聯的信息(Email
(address), Phone
(number), 和(mailing) Address
)是一對多的關系。除了上面描述的內容外,在外面的聚合中可能還會包含其他用於代表用戶偏好的實體。
很顯然,User
實體作為了聚合的根。除了名稱相同外,User實體包含了有關用戶的核心信息。此外,它還是聚合中產生其他實體的實體。即,如果移除了Phone
,則聚合本身會被保留下來。這種場景下,脫離了User
上下文的Phone
是毫無意義的。但如果移除了User
實體,那么聚合中的其他實體就會變得沒有意義,成為微服務架構中沒有目的性的孤兒實體。
User實體是可以從外部直接訪問聚合的唯一實體。以ReST為例,意味着我們可以提供如下路徑:
/users/{user-identifier}
但不能提供如下路徑(不能直接訪問電話實體):
/users/phones/{phone-identifier}
其他聚合可以保存到User
的引用,如Order
聚合可能會保存每個發起Order
的User
,每個User
必須分配一個全局唯一標識符。
值對象
相比之下,其他實體僅需要本地標識符,聚合可以通過標識符消除其自身的歧義。如可以使用1
,2
,3
來標識User
的Phone
。
這是因為Phone對外並無意義,其他任何聚合都不會單純地請求Phone
2
,僅會檢索用戶b4664e12–2b5b-47c8-b349–41e81848758f
使用的Phone
2
。
但即使這樣,也應該限制發生的范圍,其他聚合不能永久保存到用戶手機的引用。
回到ReST的例子,我們認為對一個手機的可以接受的引用如下(通過用戶來訪問其手機):
/users/{user-identifier}/phones/{phone-identifier}
但很多支持的實體其實都是值對象,即基於它們的值,而不是引用來標識對象。
比如Email
,我們可能考慮給每個郵件地址分配一個數字ID,但實際上me@myaddress.com本身就可以作為一個實體對象,如果該字符串發生了變化,則它就變成了一個全新的郵件地址。
上述方式也同樣適用於Phone
(由未格式化的數組構成)以及(郵寄)Address
,但由於一個(郵寄)地址可以有多種表示形式(例如,34 N. Main St. 和34 North Main Street),這種情況可能會有些棘手。實際上,為了使用Address
來表示一個值對象,我們需要用某種規范化的地址組件格式來作為其標識。
再回到ReST示例中,我們可能完全不需要聯系信息實體的ID,而是像這樣簡單地將它們作為一個組來進行訪問:
/users/{user-identifier}/phones
注意此處並沒有統一的答案,具體取決於對實體的處理行為。
本節展示了如何使用值對象來檢索實體,值對象可以使用單獨的標識符體系,也可以根據實體的性質,使用其名稱作為標識符。甚至可以在索引時忽略標識符,具體情況具體解決。同時注意非根實體之間不能相互引用
聚合,事務邊界以及不變量(invariants)
早先我們提到,應該將聚合視為一個原子單元。對任何包含的實體的改動,都可能會影響到整個聚合。因此,聚合定義了對包含的實體進行更改的事務邊界。
這意味着什么?通常我們會建立規則來管理在修改一個實體時發生的事情。在很多場景下,如果以某種特定的方式修改某種類型的某個實體,則必須同時修改另一個實體。或者,可能只能在特定環境下才能修改某個給定的實體。我們將這種規則稱為不變量。不變量必須獨立存在於一個聚合的上下文中。如果修改實體X需要同時修改實體Y,則實體X和實體Y必須包含在相同的聚合中。
類似地,如果基於實體Y和Z的運算結果可能會導致拒絕對實體X進行編輯,則這三個實體必須包含到相同的聚合中。
或者更准確地說,如果將一個不變量散布到多個聚合中,那么我們將無法保證不變量執行的一致性。
以前面的User
聚合為例,假設我們允許用戶選擇一種首選的溝通方式:可能是特定的郵件地址,電話號碼或郵寄地址。
這樣,我們就可以給三種實體類型添加"best-contact"的布爾字段。如果一個用戶一開始將郵件地址作為最佳聯系方式,並在后續將電話號碼作為最佳聯系方式,此時會發生兩件事:
- 郵件地址的
best-contact
設置為false
。 - 電話號碼的
best-contact
設置為true
。
顯然,Email
和Phone
實體必須歸屬於User
聚合。如果它們分別屬於不同的聚合,那么"更新最佳聯系方式"的操作就不能在一條事務中完成(相反,會涉及兩個聚合,兩條調用)
注意術語"事務",它並不指代數據庫事務。很多場景中,會通過數據庫來對實體進行變更,但也可以通過內存或其他機制。同時所有必需的更改都是通過對聚合執行單次調用而發生的。因此,這里隱含的是我們已經定義了相應的API。
在上述例子中,我們不期望調用者顯示地更新best-contact字段,因此不能使用如下ReST路徑:
PUT /users/{user-identifier}/phones/{id}/isBestContact // boolean passed in the body
而應該使用如下路徑:
PUT /users/{user-identifier}/bestContact // ID passed in the body
通過這種方式,我們可以認為聚合和不變量體現了高內聚的概念:將可能會同時變動的元素分為一組。
如何定義聚合
正確定義聚合可以幫助我們拆分歷史數據模型,界定邊界為灰色(最好情況)或根本不存在邊界的主要實體,以及組合那些需要一前一后發生變更的實體。
但如何定義自己的聚合呢?有一些可以采用的方法,但都遵循如下基本步驟:
確定系統中的主要實體
首先需要結合業務知識和常識來確定高級實體,這些高級實體是我們業務領域的基本組成部分。在我們的系統中,用戶是主要實體,而不是電話號碼。其他例子如:
- 訂單
- 產品
- 分類賬簿
- 庫存
如果無法確定一個給定的實體否是足夠"高級"來代表一個聚合,則可以思考一下:是否需要確保該實體的全局身份;是否需要全局地將該實體的實例與所有其他實例進行區分(甚至在實例具有相同值的情況下)?或者僅僅關心實體的值。
一旦確定了系統中的關鍵實體,就可以確定聚合中其他可能的候選者,再確認與根實體緊密關聯的實體。
為了實現上述目的,需要牢記如下內容:
- 如果沒有根實體,其他實體將沒有任何意義。
- 此外,其他實體通常都是值對象
- 在確定屬於聚合的實體時,應該查找不變量(管理不同實體交互的規則)。我們應該盡量將涉及相同不變量的實體歸為一組。
一些聚合比較明顯,可以很容易通過實體形成聚合,其他則不那么直接。例如兩個參與者:Order
和Order Item
。Order
s 代表客戶在線上的采購總數,而Order Item
(代表訂單中的特定產品的采購)又構成了Order
。毫無疑問,我們會將Order
s 作為聚合,以此跟蹤發生的Order
,並通過請求該聚合隨時對組件進行檢查。
那么是否可以將Order Item
作為聚合呢?這取決於我們的設計,Order Item
可能會將許多其他實體組合在一起,且其他聚合可能會保存到Order Item
的引用。
一個Order
可能會具有與Order Item
相關的不變量,即當添加一條Order Item
時,可能需要重新計算訂單的總價。
或者必須限制采購項目的數目或類型,這表明Order
應該是一個包含OrderItem
s的聚合。
對聚合的划分取決於具體的業務,通常在確定聚合根之前會進行幾次迭代,遍歷各種場景。
對根實體的確認是比較難的,本節提供了一種確認思路,即:是否需要保證某個實體是全局性地,意味着該實體需要與外部進行交互。但有些情況取決於具體的業務,通過不斷的迭代和嘗試來確定一個聚合是否合理。
為什么聚合
下面讓我們更深刻地理解什么是聚合,以及探索確定聚合的方式。顯然,在設計聚合前需要做一些期工作。 那么,為什么要關心這些准備動作呢?
當定義領域驅動設計模型時,埃文斯(Evans)幾乎完全聚焦於聚合,並將其作為不變量事務的執行機制。但這種模式(使用一個外部可訪問的引用來標識實體的原子集合)也適用於微服務架構的其他方面。
除了提供不變量的執行,聚合還可以幫助我們避免如下問題:
- 實體間不必要的依賴
- 對象的引用泄露
- 數據組之間缺少明顯的邊界
下面看下這些問題對應的例子,以及如何使用聚合來解決這些問題。
微服務和數據模式設計
首先看下典型的一體式數據庫。過去很多年中,我們開發了一個大型的數據庫模式,且到處都是外鍵引用。
從任意表開始跟蹤所有的外鍵引用,都可能會遍歷整個模式。

A small but very monolithic database schema
即使使用單一的代碼庫,這樣做也是不對的。
例如,當通過數據庫調用檢索一個Order
時,應該返回多少數據?顯然,Order
詳情包含狀態、ID和下單日期。那么是否需要返回所有的Order
物品?物品從哪里寄出以及寄到哪里?是否需要User
對象來表示下單者和接收者?如果是,那么應該需要與User
一同返回多少數據?
在轉向微服務的過程中,我們將對代碼庫和數據模式一並進行拆分,這將是面臨的最困難的一步。幸運的是,聚合思維為我們設計數據微服務和關聯的數據庫模型提供了藍圖和堅實的指導方針,相比漫無目的地對服務進行組合,聚合模式可以幫助我們確認:
- 根實體
- 附加到根實體的值對象
- 用於跨實體維護數據一致性的不變量
雖然后續仍然有很多工作要做,且通常需要很多次迭代才能確定聚合,但為我們提供了一個很好的指導方針,一旦形成聚合,就可以更加自信地做到這一點(微服務化)。
共享
大多數數據庫都支持大流量處理。但即使是最高性能的數據庫,其處理能力也是有限的。當數據庫中的數據流太多時,可以有如下選擇:
一個常見的方式是分片,描述了一種水平擴展數據庫的方法。當對數據庫進行分片時,會創建多個數據庫模式副本,並將數據切分到這些副本中。
例如,如果創建了4個分片,則每個分配大概會保存四分之一的數據。所有分配的模式都是相同的,即包含相同的表,外鍵以及其他約束等。

With sharding, we horizontally scale by splitting a large schema into multiple smaller, identical schemas
高效分片的關鍵是分片鍵。分片鍵是一個通用標識符,通過哈希或模數函數來確定其歸屬於哪個分片。
例如,如果我們嘗試更新一個用戶,我們可以對用戶的ID進行哈希,然后對4取模(假設有4個分片)來確定從哪個分片來查找該用戶。
如果對一個典型的一體式數據庫模式進行分片,這將是一個幾乎不可能的任務。為什么?是因為在我們的一體式模式中包含大量關聯的外鍵。例如,我們可能有一個從ORDER
表到USER
表的外鍵(代表下訂單的用戶)。
現在我們使用一個User
ID
12345
來確定從哪個分片查找該用戶,12345 % 4 = 1, 因此可以在Shard 1中查找User
。但如果ORDER
記錄(ID為6543
)保存了的到該USER
記錄的外鍵,6543 % 4 = 3,因此會在Shard 3中查找該ORDER
記錄。由於存在外鍵,因此不可能使用這種方式實現(會訂單和下訂單的用戶不在同一個分片中)。
上面是一個一體式數據庫示例,我們可以使用微服務的數據模式來將我們從中解放出來。
假設我們創建了一個User
服務(類似之前的例子),一個User實體關聯了0..n個郵件地址,郵寄地址以及電話號碼。底層數據模式如下:

現在,假設我們沒有采用聚合的概念,直接提供了訪問所有實體的方法:
GET /users/{user-id}
GET /users/phones/{phone-id}
GET /users/emails/{email-id}
GET /users/emails/{email-id}
一年之后,由於數據庫中的數據過多,我們需要對其進行分片。那么可以嗎?
下例展示了我們的4個USER
分片,一個ID為12345
的USER
記錄(12345 % 4 = Shard 1),以及關聯的PHONE_NUMBER
記錄(ID為235
,235 % 4 = Shard 3)

我們遇到了與一體式數據模式相同的問題(本應在同一個分片中進行查找的用戶和用戶的手機號,被分散到了分片1和3中)。
由於沒有提供一個根,並將根作為對外暴露的唯一實體,導致可能在后續數據庫分片后出現數據不一致的問題。使用聚合時,可以看作聚合中所有的實體使用了同一個ID,后續數據庫分片后,聚合中的實體也會存在相同的數據庫中。
如果我們正確定義了User 聚合,就可以保證每個請求會經過根實體,這樣根實體的ID就決定了每個實體的位置(包括電話號碼)。
在我們上面的例子中,與user ID 12345
關聯的所有的實體(郵件地址,郵寄地址,電話號碼和根實體本身)都存儲到了分片1。
消息傳遞
現在討論一下有界上下文,它是域驅動設計中另一個非常有用的模式。此外,它可以幫助我們理解如何在微服務架構使用消息傳遞(而不是同步API調用)。
在有界上下文中任意時間發生的事件將會被發布到像Kafka這樣的事件總線中,然后由其他有界上下文中的服務消費。

那么問題來了,"消息中應該包含哪些內容"?例如,一個User
添加了一個電話號碼。一旦該修改提交到了數據庫,我們將會把這次編輯作為一個消息進行發布。
但什么才會被發布呢?通常,我們會發布被修改的數據的狀態。因此僅需要簡單地發布新的電話號碼即可:

上述可能就夠了,但很難判斷消息的消費者可能還需要哪些信息。例如有些消費則可能會需要了解是否新的電話號碼是User
的主電話號碼。

但如果已經給出了主電話號碼為false,但消費者又需要知道哪個才是主電話號碼?我們可能會發送所有的電話號碼,但如果另一個消費者需要通過電子郵件通知該User
已經對該修改進行了處理,那么是否應該發送User
的所有電子郵件?

如果這樣的化,處理將永遠不會結束,且永遠不會得到正確的處理方式。
一種可選方式是簡單地在消息中發送被修改的實體的ID。任何消費者可以調用事件發送者來獲取具體的事件內容。

不幸的是,這種方式有兩個問題:
- 有時會導致檢索到錯誤的數據。假設修改了實體123,並發布了對應的消息,然后又對該實體進行了修改。之后,某個消費者消費了第一個事件,並請求實體123。該消費者將不會獲得首次修改。如果消費者僅關心最新的修改,則這么實現可能是沒有問題的。但作為生產者事件,我們無法知道消費者是否需要(在現在和未來)跟蹤單個變更。
- 更糟糕的是,它使得已解耦的事件驅動架構(因為跨有界上下文的調用而)變為了一個強耦合的系統。
那么應該如何傳遞我們的消息呢?
事實證明,如果我們接受了聚合,就會有明確的答案。 每當更改聚合時,都應將該聚合作為消息傳遞。由於聚合作為一個原子單元,任何對聚合的一部分的修改都會被認為對整個聚合進行了修改。
消息中是如何表示聚合的,具體取決於所在的組織。可能是一個簡單的JSON結構,或可能使用Avro模式表達。聚合的數據可能是加密的。不管數據格式如何,在“聚合”的思考和設計中都會遇到諸如此類的問題。
總的思路就是將"聚合"作為一個原子單元進行傳遞。如果僅僅使用全局標識符來傳遞消息(本質上類似一個指針),則可能會遇到讀寫不一致的問題。
重試
消息傳遞的概念通常會涉及重試。基於消息的事件驅動架構的一個亮點就是恢復能力(以自動重試的方式)。
這意味着什么?當發布消息到如Kafka這樣的事件總線時,就可以被下游消費者所消費。大多數情況下會順利進行。但有些情況下,消費者可能會遇到消息消費的問題:
- 可能是因為消費者的數據庫暫時不可用,導致消費者無法正確處理事件。
- 或者可能是因為暫時無法使用安全設備,導致消費者無法解密消息。
這類情況下,消費者在當前消息處理完之前將無法繼續處理下一個消息,且消費者能夠對處理的消息進行確認。這些行為默認會發生在Kafka等系統上。 實際上,消費者將繼續嘗試,直到成功為止。
通常這是期望的行為,一般也能夠相對快速地解決相應的問題。同時,對下一條消息進行處理是沒有意義的,因為該消息也很可能會發生相同的問題。
但還是會存在第二類問題:當消息本身存在問題時(可能是因為消息在傳遞中出現了損壞,或包含一個特殊的字符,或沒能通過某些有效性校驗)。這種情況下,消費者會多次嘗試消費消息,但永遠不會成功。
當檢測到這類問題時,消費者可能會把當前消息放到一邊,例如將其放到一個特殊的隊列中,並繼續處理后續的消息。
但這種方式也存在問題。我們期望確保最終能夠處理掉"壞的"消息,即使需要一些手動操作。但如果在消費者處理一個消息的同時,消息中的數據發生了變化,新的變更將會因為重新處理"壞的"消息而被覆蓋掉。
下圖展示了這個問題:

Bounded Context 1中實體123的"foo"的值變為了"bar",然后發布了一個表示此次變更的消息,由於Bounded Context 2中的消費者無法解析該消息,因此將其放到了一個特殊的隊列中。

后來Bounded Context 1中的實體123的"foo"的值變為了"baz",然后發布一個從"bar"變為"baz"的消息,此時Bounded Context 2消費的消息中的實體123的值為"baz"。

再后來修復了初始的消息(如移除了一個錯誤字符),然后重新發送到Bounded Context 2,該消息中的實體123的值為"bar"。
這是一個處理順序的問題。通常,我們需要保證按照事件發送的順序進行處理。但在上述場景下,則無法按序處理事件。
如果我們圍繞聚合來定義數據,則可以知道知道消費者可能收到的消息的變更范圍。換句話說,接收到的任何消息都描述了一個新版本的聚合。且可以通過根實體的全局唯一標識符(GUID)來確認聚合。因此,如果消費者在確認無法在沒有人工介入的情況下無法處理某個消息時,就可以將該消息放到一個獨立的隊列中,它可以使用該GUID來表示被擱置的消息。如果碰到了更多包含相同聚合的消息,則可以將這些消息放到相同的隊列中。然后可以在原始問題解決(例如可能需要更新消費者來處理奇怪的Microsoft Word特殊字符)前繼續按照上述邏輯處理消息。如果問題解決,消費者就可以處理這些被擱置的消息。
可以肯定地說,構建這些重試機制並不容易,但使用聚合,最起碼是可行的。
本節展示了如何使用聚合的GUID作為全局唯一標識符來緩存來自特定聚合的(無法繼續處理的)消息。這樣就可以繼續處理來自其他聚合的消息。在聚合的問題解決之后,就可以繼續處理該聚合之前被擱置的消息。
緩存
如果沒有很好地定義有界數據結構,緩存可能會因此變得笨重。大多數緩存操作,如哈希映射,它們允許使用一個標識符來關聯一堆數據,並通過傳遞該標識符來對這些數據進行檢索。
如果我們沒有圍繞聚合來定義數據結構,則可能會很難確定需要緩存的數據類型。假設一個經常被訪問,但很少被修改的系統,在這種系統中,我們可能會期望緩存請求結果來最大程度地減少對數據庫的訪問次數,但應該緩存哪些內容呢?
我們可能會簡單地對每次請求的結果進行緩存。回到User的例子,這意味着我們會緩存如下結果:
- 對特定用戶的查詢
- 對特定電話號碼的查詢
- 對一組郵件地址的查詢
- 對特定用戶的婚姻狀況的查詢

注意緩存會復制數據。假設我們緩存了一個用戶對象,但同時也緩存了獨立的聯系信息和聯系信息組,以及用戶獨立的對象字段。最終會需要大量內存來保存這些數據。當緩存了無效的數據時,可能會出現嚴重問題。
例如緩存的電話號碼發生了變化,如假設在先前的例子中,"best contact"標志從false變為了true,此時需要校驗緩存的電話號碼。但是否需要校驗緩存的用戶對象,以及其他聯系方式的"best contact"是否由true變為了fasle。
如果我們使用聚合,則不需要擔心這些問題。使用聚合,我們只需要緩存一個緩存key:聚合的GUID。當檢索聚合時,我們會對其進行緩存。當聚合的任何屬性發生變化時,對整個聚合進行校驗即可。(此時緩存的不是內容,而是索引方式,當然也可以緩存整個聚合)
服務授權
在我之前所在的公司向微服務邁進時,我領導了一個團隊,負責實施服務到服務的數據級別的授權。換句話說,我們已經解決了"是否允許服務A允許訪問服務B"的問題,還需要解決"是否允許服務A從服務B請求實體123"的問題。
這意味着我們需要了解當前的用戶代理(例如,哪個客戶發起的請求),像JWTs這類認證代理就是這么做的。我們可以在執行服務到服務的調用時,在一個token中傳入用戶ID。
同時我們也需要了解是否允許該用戶代理查看特定的實體。在我們的場景中,可能存在大量潛在的實體。此外,一個用戶可能需要查看他們擁有的文檔,或可能通過其他用戶的授權來訪問文檔(例如,通過第三方授權方式)。
我們的目的是提供一個通用的、插件化的解決方案,同時需要避免通過重復(同步)調用某個獨立的服務來確定一個用戶是否有權限訪問某個特定的實體。
出於上述原因,我們決定在啟動過程中,對允許給定用戶訪問的項目做一次確定,並在用戶token中包含這些商品的ID。

對於上述情況來說,如果不圍繞聚合來設計我們的微服務,則有可能是行不通的(有可能無法訪問潛在的實體列表)。
但是由於我們已經在使用聚合方面進行了前期規划,因此我們通過聚合根的ID來約束可以查找任何實體。這樣我們僅需要授權給特定用戶的聚合。
上例使用userId作為GUID,聚合了與用戶相關的所有信息。並以此來檢索該用戶的其他信息(如可以訪問的文檔)。
跟蹤變更
有時候,我們需要對變更的數據進行跟蹤。過去,我們通過實現數據庫活動觸發的變更數據捕獲(CDC)系統來記錄數據的變更。最近,組織傾向於捕獲業務實體的變更,而不是數據庫行的變更。此時我們面臨着一個問題:"哪些數據需要快照,以及以后如何使用"?
你可能已經猜到了,答案是圍繞聚合來設計數據。任何時間對任何實體進行變更時,都會記錄一個新版本的聚合,這個過程並不簡單,但更加准確。
回想一下,聚合的最初目的是在事務上強制執行不變量(invariants)。因此聚合的每個快照都表示此類事務的執行結果。
后續對變更的檢索也更直接。如果需要查找歷史User
的聯系方式,我們不需要跨多CDS表來收集變更。相反,只需要訪問聚合表,各個聚合之間的差異也變得無關緊要。 我們只是將一個版本的聚合與另一個版本進行比較。
其他方面
上述並沒有詳盡地列出圍繞聚合設計實體可以幫助我們解決的各類挑戰。毫無疑問, 應用聚合模式會使我們以系統的方式預先思考哪些實體屬於同一實體。最終,我們會將操作約束到具有單個訪問點的,定義明確的原子組。 我們不會因實體之間的偶然依賴關系而感到厭煩,也不會各種引用泄漏而妨礙我們實施擴展方案。
需要注意的一點就是,聚合是與業務息息相關的,且對一個聚合的確認也不是一蹴而就的,有時需要進行多次協商和迭代才能達到一個滿意的結果。但架構就是一個軟件的骨架,不好的架構將可能后患無窮。