DDD之異步事件

語言: CN / TW / HK

【按】“DDD診所”是Thoughtworks DDD社區的一項活動,通過對同事們在實施DDD過程中遇到的問題進行分析和解答,共同提高開發水平。我們將其中一些典型案例整理成文供大家參考。之後也會考慮在適當的時候將這一形式對外部開放。

就診日期:2021年11月1日

患者:某零部件管理系統

診金:0元(免費義診)

【患者主訴】

  • 某製造企業為其經銷商的售後部門開發了售後服務平台。本系統是該平台中的“零件”部分,服務於售後業務的“零件部門”。零件部門的業務包括售後單的零件準備、零件外銷、零件採購以及零件管理等,目前採用微服務架構。患者的監護人從之前的團隊接手該系統後,發現了一系列問題。
  • 患者監護人梳理了當前系統的架構(主要是微服務間的關係),發現系統已經成了一個大泥球。架構圖如下所示。

  • 大量異步事件導致系統難以維護
  • 系統存在數據不一致性以及莫名其妙的性能問題
  • 在實現層面,同時採用了 Spring內置的事件總線和Message Queue兩個機制。發佈消息時,先發布一條Spring Event,Spring Event 的監聽器再發消息到MQ。也就是説所有註冊監聽的服務都當做進程內的Spring Event處理,再由Spring和MQ打交道。如下圖。

【病理分析】

患者的病情還是比較複雜的,需要幾個方面的綜合治療。但這次就診時間有限,於是醫生首先詢問了患者的監護人,哪個問題是最緊迫的。監護人認為是異步事件機制。所以這次集中分析這個問題。(為了討論方便,本文假定微服務和限界上下文是一一對應的,會將兩者混合使用,不做區分。兩者不一一對應的情況將在以後另文分析。)

“圖1 當前系統架構”中有大量的MQ(消息隊列),所以我們首先懷疑患者很可能過渡使用了異步事件。當然還需要通過進一步診斷來求證。

很多朋友知道,領域事件是DDD領域模型的重要組成部分,又看到很多大廠大量採用了異步機制來處理領域事件,而一些書上也強調了異步機制的使用,因此就認為採用異步機制處理領域事件是理所當然的。那麼這種理解完全正確嗎?讓我們從頭説起。

“領域事件是DDD領域模型的重要組成部分”這一點是完全正確的。事實上,在Evans 2003年的《領域驅動設計》一書中並沒有提領域事件。但在DDD之後的發展過程中,很多專家指出了領域事件的重要性。在2015年,Evans又寫了一本小冊子“ Domain-Driven Design Reference ”簡要總結了DDD中的各個模式,在其中增加了領域事件(Domain Events)。( 這裏 有該書的一個正在翻譯的中文版)

病理分析1:領域事件首先是一個業務概念

在DDD中,領域事件首先是一個業務概念而不是技術概念。例如要開發一個人壽保險系統,那麼“保單已提交”、“保單已核保通過”等,都是領域專家可以理解的領域事件,並且也是開發人員和領域專家必須達成一致(並形成統一語言)的業務概念。至於同步異步、消息中間件等,都是技術概念,在領域建模時不需要重點考慮。不過,開發人員可以詢問領域專家,如果核保通過後,用户是否必須立即看到這一結果呢?遲幾秒鐘可以嗎?這些問題背後可能隱含了是否可以採用異步最終一致性這一技術問題。但與領域專家討論時,用的仍然是業務語言,只不過反映的是非功能性需求。

領域事件可以通過事件風暴、用例分析等方式來識別。完成了領域模型後,在進一步的設計中,才需要考慮技術層面。例如,事件的顯式實現還是隱式實現、是否採用事件驅動架構、同步集成還是異步集成、是否考慮最終一致性、採用輕量級事件總線還是消息中間件等等。下面逐一討論。

病理分析2:顯式實現 vs 隱式實現

顯式實現

領域事件的顯式實現指的是為每個領域事件創建單獨的類,一個領域事件被觸發時,要為這個事件創建相應類的對象。

定義“保單已核保通過”這一領域事件的偽代碼如下(保險單的英文是policy,核保的英文是underwrite):

class PolicyUnderwirted extends DomainEvent {
    ……
}

發佈這一領域事件的偽代碼:

eventBus.publish(new PoliycUnderwrited(underwritingResult))

隱式實現

領域事件的隱式實現指的是並不為領域事件創建單獨的類。相反,領域事件的觸發,隱含在某一段代碼邏輯中,例如改變數據庫中數據記錄狀態的代碼,或者調用另一個上下文API的代碼。

事實上,我們過去慣常的做法就是這種隱式實現,只不過沒有從領域事件的角度去考慮。如果要追求模型和實現的對應關係,可以建立每一個領域事件和實現該事件的代碼(例如某個Application Service)的對照表,以便管理。

兩種方式的權衡

在實踐中,一個系統往往同時採用隱式和顯式兩種方式。由於顯式實現往往更加複雜,基於奧卡姆剃刀原理(如無必要勿增實體),應首選隱式方式,只在必要的情況下才採用顯式方式。以下是常見的需要採用顯式方式的場合:

  • 希望採用事件溯源(Event Sourcing),將領域事件直接存儲到數據庫;
  • 希望將領域事件存儲為日誌,以便審計;
  • 希望採用事件驅動架構(見下文)。

在本病例中,由於早期開發者不瞭解領域事件有顯式和隱式兩種實現方式,而是誤以為都應該採用顯式實現,所以反而無謂的增加了系統的複雜性。

既然採用事件驅動架構是使用顯式實現方式的一種場合,我們有必要對事件驅動架構做簡要的分析。

病理分析3:是否採用事件驅動架構

什麼是事件驅動架構

事件驅動架構的詳細描述可以參考相關資料,這裏用一個示意圖簡要説明。

在上圖中,核保通過後,“核保上下文”通過消息總線發佈一個“保單已核保通過”事件。“出單上下文”監聽到這個事件後,會檢查保單是否已經繳費,如果是,則將保單置為生效狀態並打印保單(即“出單”)。“通知上下文”監聽到該事件,則向特定的干係人發出短信或郵件通知。

事件驅動架構起到了在軟件的上下游組件間解耦的作用。上游(例如核保上下文)只需要發佈事件,不需要關心哪些下游(例如出單上下文)會處理這個事件。下游只需要關心特定的事件,而不需要知道這些事件是由哪個上游發佈的。因此上下游就可以獨立演化。例如,如果增加一個關心該事件的下游組件,上游組件不必做任何修改。

需要對事件驅動架構進行有效的管理

儘管理論上,上述架構起到了事件上下游的解耦作用,但帶來了一個新的“陷阱”。由於上下游之間互相併不知情,那麼當上遊發佈一個事件時,到底在整體上對系統會發生哪些影響就難以掌握,例如會不會導致性能問題或數據不一致問題,當系統出現缺陷的時候也難以快速定位。因此 採用事件驅動架構時,必須以一定的方式將事件的發佈和訂閲關係管理起來 。可以採用手動或自動兩種方式進行管理。

手動方式,指的是在系統設計時,人工將每個事件的發佈者和訂閲者通過一個表格進行文檔化。開發時需保持這一文檔和代碼實現相一致。這樣,只需要查閲該表格,就可以知道每個事件的來龍去脈。

自動方式,指的是將事件的發佈和訂閲關係定義在一個配置文件中,程序運行時,讀取該文件,根據文件的內容進行運行時的事件註冊和訂閲。另一方面,配置文件的內容又可以通過便於閲讀的文檔方式呈現出來,從而達到文檔和實現一體化。

在本病例中,為了追求鬆耦合,大量使用了事件驅動架構,但是沒有進行相應的管理,反而造成了維護的困難。

什麼時候採用事件驅動架構

事件驅動架構在解耦的同時,也帶來了實現和管理上的複雜性。同樣根據奧卡姆剃刀原理,應該只在必要的時候採用。

通常,如果上下文間採用異步集成機制,那麼使用事件驅動架構是比較合適的。如果採用同步集成,則只有在解耦的收益大於複雜性的代價時,才應採用。下面接着討論什麼時候採用同步集成,什麼時候採用異步集成。

病理分析4:同步集成 vs 異步集成

兩種方式各有利弊,要根據具體的場合進行取捨。如果採用同步方式,由於上游要等待下游的返回才能進行下一步操作,所以帶來了兩個問題:一是等待過程中CPU空轉加上內存中要保持線程,造成資源的浪費;二是當併發請求較多時,可能會嚴重降低系統的可用性。異步方式的利弊與此相反。

根據患者監護人的説法,該系統併發量不大,即使採用同步集成,也未必會帶來可用性的問題。當初之所以採用異步方式,僅僅是因為這種技術比較“先進”。

在本病例中,不分場合地大量採用異步集成,造成了不必要的複雜性和系統維護的困難。

在確實需要異步集成的地方,則可能需要處理事務的最終一致性。

病理分析5:是否考慮事務的最終一致性

這個問題可以分為兩個子問題:第一,是否需要維護事務的一致性;第二,在需要維護事務一致性的前提下,採用強一致性還是最終一致性。我們先來討論第一個問題。

在分佈式環境下是否需要維護事務的一致性

這仍然取決於具體的應用場景,下面舉例説明。

在前面提到的保險例子,就不需要維護事務的一致性。如下圖。

“出單上下文”監聽到“保單已核保通過”事件後,會檢查保單是否已經繳費,如果沒有,則不會出單(這時什麼都不會做)。而即使不出單,也不會影響“保單已核保通過”這一事實,因此無所謂事務的回滾,所以不需要事務的一致性。

“通知上下文”的情況略有不同。假設由於電信服務提供商的故障,導致通知短信沒有成功發出。在業務上,短信沒有發出不是一個關鍵性錯誤,由於短信沒有發出就回滾核保結果反而是不合理的。這時只需要重發短信就可以了。因此,也不需要事務的一致性。

而下面的典型電商場景則需要事務的一致性。

“庫存上下文”監聽到“訂單上下文”發佈的“訂單已提交”事件後,要嘗試扣減庫存。如果由於商品數量不足導致失敗,則訂單提交也要回滾。因此需要維護事務的一致性。

在需要維護事務一致性的前提下,接着考慮以下問題。

採用強一致性還是最終一致性

在《實現領域驅動設計》一書中,作者建議,在聚合內部採用強一致性,跨聚合的操作則採用最終一致性。這種一刀切的説法值得商榷。

我們在實踐中的體會是,在同一個微服務中,即使跨聚合,多數也採用強一致性。這是因為一個微服務往往對應唯一的數據庫。只要事務粒度設計得當,避免長事務,那麼利用數據庫本身的事務機制(可能還要結合樂觀鎖或悲觀鎖)來實現強一致性就不會有問題。當然在少數情況下,業務確實要求粒度較大的事務,為了在技術上避免長事務,則在同一個微服務中也應採用最終一致性。

在跨微服務的分佈式環境下,則要像上文討論的那樣,確定採用同步集成還是異步集成。如果既要維護事務的一致性,又要採用異步集成,那麼就必須採用最終一致性了。

實現最終一致性的方法有多種,常見的是TCC和Saga模式,以及它們的各種變體。這方面的資料很多,就不贅述了。

在本病例中,微服務間採用了異步集成,但是在應該維護事務一致性的場合,沒有采用最終一致性機制。這是導致數據不一致的重要原因之一。

病理分析6:採用輕量級事件總線還是消息中間件

這裏説的輕量級事件總線,指的是由框架或程序庫提供的,不依賴專門的消息中間件的機制。例如Spring自帶的事件總線或Guava提供的事件總線。

採用輕量級總線的好處是不依賴第三方中間件,開發和部署比較簡單。不足的地方是缺乏一些更強大的功能。比如説,專門的消息中間件往往提供消息的持久化功能,在意外斷電或斷網的時候,消息不會丟失,當故障恢復後,未消費的消息會自動重發。然而輕量級總線不具備這樣的功能。

不論採用哪種機制,對於同一個事件的發佈,一般沒有必要同時採用兩種。

在本病例中,同時採用了輕量級事件總線和消息中間件,從而給系統帶來了額外的複雜性。由於增加了一個環節,無論在開發還是運行中,出錯的可能性都提高了。

【診斷結論與治療方案】

患者罹患晚期“ 異步事件綜合徵 ”,但經過適當的治療,仍有望康復。

需要進行三項手術治療。

第一項是“異步事件同步術”:

  1. 找到系統中最急需改進的部分,如缺陷最多、數據最不一致、最難維護、需求變化最頻繁的部分。
  2. 針對這些部分,分析是否需要異步集成。在分析過程中除了主觀判斷,還要以數據為依據。例如,先收集併發訪問量和性能數據。如果併發訪問量不大並且穩定,性能也沒有問題,則考慮用事件的隱式實現以及同步集成替換異步集成。
  3. 替換過程應該是漸進的,一個環節一個環節地替換。替換完一個環節後,要在生產環境監測性能和可用性。如果沒有問題,再替換下一個環節。

第二項是“事務最終一致術”:

對於確實需要異步,並且同時需要保證事務一致性的部分,逐步引入實現最終一致性的技術。

第三項是“異步機制簡化術”:

由於系統已經使用了消息中間件,因此可取消對Spring事件總線的使用,逐步改為只使用消息中間件一種機制。

術後保養:

對於異步事件,應採用手工或自動化的方式,將事件的發佈和訂閲機制管理起來,以便後續的維護和排錯。

時間關係,本次治療只針對異步事件相關的病徵。要使患者徹底康復,還要進一步診斷微服務劃分是否合理。這就留待下一次複診時解決吧。