事件驅動架構在 vivo 內容平臺的實踐

語言: CN / TW / HK

一、什麼是事件驅動架構

當下,隨著微服務的興起,容器化技術的發展,以及雲原生、serverless 概念的普及,事件驅動再次引起業界的廣泛關注。

所謂事件驅動的架構,也就是使用事件來實現跨多個服務的業務邏輯。事件驅動架構是一種設計應用的軟體架構和模型,可以最大程度減少耦合度,很好地擴充套件與適配不同型別的服務元件。在這一架構裡,當有重要事件發生時,比如更新業務資料,某個服務會發布事件,其它服務則訂閱這些事件;當某一服務接收到事件就可以執行自己的業務流程,更新業務資料,同時釋出新的事件觸發下一步。

事件的釋出與訂閱,需要依賴於一個可靠的訊息代理。見下圖:

圖片

當然,事實上有不少軟體專案都使用了訊息佇列,但是這裡需要明確的是,對訊息佇列的使用並不意味著你的專案就一定是事件驅動架構,很多專案只是由於技術方面的驅動,小範圍地採用了某些訊息佇列的產品而已。偌大一個系統,如果你的訊息佇列只是用作郵件傳送的通知,那麼這樣系統自然談不上採用了事件驅動架構。

在採用事件驅動架構時,我們需要考慮業務的建模、事件的設計、上下文的邊界以及更多技術方面的因素,這個系統工程應該如何從頭到尾的落地,是需要經過思考和推敲的。總而言之,“事件驅動架構”的設計並不是一件易事。本文在後面有個例子供參考。

另外,如果盲目使用事件驅動設計架構,就有可能要承擔中斷業務邏輯的風險,因為這些業務邏輯具有概念上的高度內聚,卻採用瞭解耦機制將它們聯絡在一起。換句話說,就是將原本需要組織在一起的程式碼強行分離,並且這樣難於定位處理流程,還有資料一致性保證等問題。為了防止我們的程式碼變成一堆複雜的邏輯,我們應當在某些明確場景下使用事件驅動架構。以經驗來講,以下三 種場景可以使用事件驅動開發:

  • 元件的解耦

  • 執行非同步任務

  • 跟蹤狀態的變化

二、什麼時候使用事件驅動架構

2.1 元件的解耦

當服務(或元件) A 需要執行服務 B 中的業務邏輯,相比於直接呼叫,我們可以向事件代理(事件分發器)中傳送一個事件。服務 B 通過監聽分發器中的特殊事件型別,然後當這類事件被接收到時去執行它。

這意味著服務 A 和服務 B 都依賴於事件代理和事件,而無需關注彼此實現:即完成它們的解耦。見下圖:

基於這種松耦合,服務可以用不同的語言實現。解耦後的服務能夠輕鬆地在網路上相互獨立地擴充套件,通過動態新增或刪除事件生產者和消費者來修改他們的系統,而不需要更改任何服務中的任何邏輯。

2.2 執行非同步任務

有時我們會有一系列需要執行的業務邏輯,但是由於它們需要耗費相當長的執行時間,所以我們不想看到使用者耗費時間去等待這些邏輯處理完成。在這種情況下,最好將它們作為非同步任務來執行,並立即向用戶返回一條資訊,通知其稍後繼續處理相關操作。

比如,內容欄位的檢查等入庫流程可以採用“同步”執行處理,但是執行內容理解則採用”非同步“任務去處理。在這種情況下,我們所要做的是觸發一個事件,將事件加入到任務佇列中,直到一個服務能夠獲取並執行這個任務。此時,相關的業務邏輯是否處在同一個上下文中環境中並不重要,不管怎麼說,業務邏輯都是被執行了。

2.3 跟蹤狀態的變化

在傳統的資料儲存方式中,我們通過實體模型存資料。當這些實體模型中的資料發生變化時,我們只需更新資料庫中的行記錄來表示新的值。這裡有個問題,就是業務上我們無法準確儲存資料的變更和修改時間。但是在事件驅動架構中,可以通過事件溯源將包含修改的內容存入到事件裡。下面會詳細討論“事件溯源“。

三、為什麼使用事件驅動架構

當大家談論事件驅動架構時,比如大家說自己恰好在最近的專案中採用了事件驅動架構,實際上,他們可能在談論下面這四種模式中的一種或者幾種:

  • 事件通知

  • 事件承載狀態轉移

  • 事件溯源

  • CQRS

注:概念來源2017年GOTO Conference上Martin Fowler分享的The many meanings of Event-Driven architecture。

3.1 事件通知

假設我們現在想要設計一個簡易的內容平臺,包含三部分:

  • 內容引入系統

  • 作者微服務

  • 關注中心

當內容創作者通過內容引入系統上傳影片之後,會觸發如下的一個呼叫流程見下圖:

  1. 內容引入系統收到創作者上傳的影片,執行入庫流程;

  2. 內容引入系統呼叫作者微服務的API,增加“影片-創作者”的從屬關係;

  3. 作者服務呼叫關注中心的API,讓關注中心給關注了這個創作者的其他使用者傳送作者影片更新的通知。

上面這個呼叫流程,不可避免地建立了下面的依賴關係:

  • 內容引入系統依賴於作者微服務的API,雖然內容引入系統其實不太關心作者微服務的業務。

  • 作者微服務依賴於關注中心的API,雖然作者微服務也不關心關注中心的業務和處理流程。

這種依賴關係很有可能並不是我們所期望的。內容引入系統是一個比較通用的業務,不同型別的內容引入系統很可能會有相似功能,如欄位型別檢查、入內容庫、啟動高敏稽核等。作者服務則是一個非常專業的系統,如不同源、不同型別的內容關於作者的業務邏輯是不同的。讓一個通用的系統依賴於一個專業的系統,不管從設計角度,還是後續系統維護角度,都是不一個好的方案。作者微服務可能會經常根據業務需求做變更,但內容引入系統相對穩定,而上面這種依賴關係讓我們難以在“不對內容引入系統做調整的情況”下隨意更改作者微服務。

從架構層面,我們希望讓作者微服務依賴於內容引入系統,讓一個專業的系統依賴於一個穩定的、通用的系統,增加系統的穩定性。這個時候我們可以藉助於“事件通知”。見下圖:

優點

  • 架構更健壯。如果加入佇列的事件能夠在源元件中執行,但在其它元件中由於 bug 導致其無法執行(由於將其加入到佇列任務中,它們可以在 bug 修復後再執行)。

  • 業務處理減少延遲。當用戶無需等待所有的邏輯都執行完成時,可以將這類工作加入到事件佇列。

  • 便於系統擴充套件,能夠讓元件的研發團隊獨立開發,加快專案進度、降低功能難度、減少問題發生並且更有組織性。

  • 將資訊封裝在“事件”裡,便於系統內傳播。

缺點

  • 如果沒有合理使用,可能使我們的程式碼變成“麵條式”程式碼。

  • 資料一致性問題。由於流程依賴於最終的一致性,因此通常不支援ACID事務,因此重複或亂序事件的處理會使服務程式碼更加複雜,並且難以測試和除錯所有情況。

“事件通知”的缺點和優點相對應,正是因為它提供了很好的解耦能力,我們會比較難通過閱讀程式碼去得到整個系統和流程的全貌。因為這些邏輯之間的關係不再是之前的依賴關係。這將會是一個挑戰。

3.2 事件承載狀態轉移

我們在使用事件通知時,事件裡面往往不會包含下游系統處理這個事件需要的所有資訊。比如當內容發生下架變更時,內容平臺會生成一個“內容下架“的事件,但當下遊系統處理這個事件時,往往還需要知道,該內容上個狀態是什麼,是誰觸發下架等資訊,才能完成後續處理。所以不可避免地,下游系統在處理這個事件時,往往還需要通過平臺服務來獲取這些額外資訊。

為了解決這個問題,我們引入一個種新的模式,叫做“事件承載狀態轉移”。簡單來說,就是讓事件的消費方自己保留一份在業務處理過程中需要用到的上游系統的資料。比如讓下游系統保留一份在處理內容狀態變更事件時所需要用到的內容變更前的狀態,避免回頭去平臺查詢。

優點

  • 架構更健壯。減少事件消費方對生產方的額外依賴(獲取事件處理所需資料);

  • 業務處理減少延遲。增加事件消費方系統的響應速度,因為不再需要呼叫平臺API以獲取事件處理所需資料;

  • 無需擔心被查詢元件的負載(尤其是遠端元件)。

缺點

  • 儘管現在資料儲存已經不再是問題根源,依然會儲存多個只讀的資料副本,一致性進一步被破壞;

  • 增加資料處理的複雜度,即使處理邏輯符合規範,它也需要額外處理和維護外部資料的本地副本業務邏輯。

3.3 事件溯源

有些時候我們不但關心繫統當前的狀態,我們還關心如何變成當前這個狀態的,但是資料庫僅僅簡單地儲存實體的當前狀態。事件溯源可以幫助我們解決這個問題。

事件溯源是一個特別的思路,它並不持久化實體物件,而是隻把初始狀態和每次變更的事件記錄下來,並在記憶體中根據事件還原實體物件的最新狀態,mysql主從備份用到的binary log以及redis的aof持久化機制,都可以認為是“事件溯源”的實現。

事件溯源在做完資料庫更新之後,它將事件的傳送操作轉換為往資料庫或者日誌系統中寫入一條事件記錄,其它節點通過查詢資料庫或者檔案系統,來得到這些事件,並通過回放來確保資料的最終一致性。

優點

  • 可以呈現一個完整的變動歷史;

  • 提供更方便的debug手段;

  • 可以回溯到任何一個歷史狀態;

  • 方便修改當前事件;

缺點

  • 要實現一個可靠和高效能的事件倉庫(儲存的事件記錄)並不是一件容易的事情,應用程式碼需要根據事件庫的 API 進行重寫。

3.4 CQRS

CQRS全稱是Command Query Responsibility Segregation。簡單來說,就是針對系統的讀寫操作,使用不同的資料模型、API介面、安全機制等,來達到對讀寫操作的完全隔離,滿足不同的業務需求。見下圖:

圖片

根據儲存在事件庫中的事件集合,可以計算得到每個業務實體的狀態,這些狀態以物化檢視的方式儲存在一個數據庫中。當有新的事件產生時,也同樣會自動更新檢視。這樣,檢視查詢服務就可以像查詢普通的資料庫資料一樣實現各種查詢場景。具體的設計可參考下圖所示:

四、事件驅動架構在內容平臺中的實踐

在當今社會,內容“橫行”的時代,內容平臺企業需要有極強的靈活性和應變能力。特別是在中國這樣一個內容行業(如影片)飛速發展的市場裡,企業要求平臺能夠快速地對內容業務需求做出應對,否則就會喪失先發優勢。這有點類似於現代戰爭條件下,各國都要求部隊具備快速反應能力,這種能力主要體現在平臺能夠通過快速開發或者重用 / 整合現有資源來達到快速響應業務需求。

隨著內容行業業務越來越龐大複雜,所涉及的儲存型別、處理器、賬號體系、效率工具、資料和結算系統等非常多,這就要求平臺有很強的整合能力以及對異構環境的適配能力。

最後,由於內容行業的發展日新月異,特定型別的內容業務(如小影片)都會在其初中期發展後迎來一個快速膨脹期,業務量和業務型別會急劇增加,這也要求平臺有很好的可擴充套件性。相關平臺架構見下圖:

4.1 建立事件

事件其實是DDD(領域驅動設計)中的一個概念,表示的是在一個領域中所發生的一次對業務有價值的事情,落到技術層面就是任何影響業務流程或者狀態的改變。事件具有自己的屬性,比如發生的時間、發生了什麼、事件之間的關係、狀態以及變化,事件也可以生成新的事件,根據不同的事件生成新的業務事件。在建立事件時,首先需要記錄事件的一些通用資訊,比如唯一標識ID和建立時間等,為此建立事件基類ContentEvent:

public abstract class AbstractContentEvent {
    private String eventId;
    private String publisher;
    private String receiver;
    private Long publishTime;      
}public abstract class AbstractContentEvent {    private String eventId;    private String publisher;    private String receiver;    private Long publishTime;      }

在一般場景下,事件一般隨著聚合根(也是DDD的一個概念,這裡泛指影片id)狀態的更新而產生,另外,在事件的消費方,有時我們希望監聽發生在某個聚合根下的所有事件,為此建議為每一個聚合根物件建立相應的事件基類,其中包含聚合根videoId,比如對於影片(Video)類,建立VideoEvent:

public class VideoEvent extends AbstractContentEvent {
    private final String videoId;
}

然後對於實際的影片事件,統一繼承自VideoEvent,比如對於影片引入的VideoInputEvent事件;

public class VideoInputEvent extends VideoEvent {
    private Article article; // 影片基本資訊
}

影片域事件的繼承鏈見下圖;

在建立事件時,需要注意兩點:

  1. 事件本身應該是不變的;

  2. 事件應該攜帶與事件發生時相關的上下文資料資訊,但是並不是整個聚合根的狀態資料。例如,在影片引入時可以攜帶影片的基本資訊article,而對於影片狀態更新的VideoStatusChangeEvent事件,則應該同時包含更新前後的狀態status:

public class VideoStatusChangeEvent extends VideoEvent {
    private String preStatus; //更新前的狀態
    private String status; // 更新後的狀態
}

4.2 釋出事件

釋出事件有多種方式,比如可以在應用程式中釋出。通常的業務處理過程都會更新資料庫然後釋出事件,這裡一個比較常見的場景是:需要保證資料庫更新和事件釋出之間的原子性,也即要麼二者都成功,要麼都失敗;當然也有不需要保證原子性的場景。如果需要保證原子性,以“內容引入”的業務流程為例,見下圖:

圖片

  • 接收內容;

  • 寫入內容表;

  • 寫入事件表,且和內容表的更新在同一個本地資料庫事務中;

  • 事務完成後,觸發事件的傳送;

  • 讀取事件表;

  • 將事件傳送到訊息佇列;

  • 傳送成功後,將記錄標註為“已傳送”;

4.3 消費事件

在消費事件時,除了完成基本的訊息處理邏輯外,我們需要重點關注以下三點:

  • 消費方的冪等性;

  • 消費方有可能進一步產生事件;

  • 消費方的資料一致性;

對於“冪等性”,事件的傳送機制保證的是“至少一次投遞”,這是有訊息中介軟體保證,技術選型時需要注意。為了能夠正確地處理重複訊息,要求消費方是冪等的,即多次消費事件與單次消費該事件的效果相同。保證“消費冪等性”的方法有很多,這裡介紹一種。在消費方建立一個事件表,用於記錄已經消費過的事件,在處理事件時,首先檢查該事件是否已經被消費過,如果是則不做任何消費處理。

對於第二點,依然沿用前文講到的“事件表”的方式。事實上,無論是處理服務請求,還是作為訊息的消費方,對於聚合根(videoId)來講都是無感知的,事件由聚合根產生進而由事件庫持久化,這些過程都與具體的業務操作源頭無關。

對於“資料一致性”,本質上是由第二點引出,事件驅動架構在業務物件之間通過非同步的訊息來同步狀態,有些訊息也可以同時釋出給多個服務,在“訊息引起了一個服務的同步”後可能會引起另外的訊息,事件會擴散開。嚴格意義上的事件驅動是沒有同步呼叫的,如何保證一致性,就要比非事件驅動架構要複雜,通常採用“cache aside”模式和“分散式鎖”來保證一致性。

綜上,在消費事件的過程中,應用程式需要更新業務表、事件記錄表,此時整個事件的釋出和消費過程見下圖;

圖片

五、總結

主流場景下,傳統面向服務(或以資料驅動)的平臺存在系統性不足,需要增強以下能力:

  • 在傳統資料整合基礎上需要進一步提升業務整合能力。

  • 需要提高整合平臺的業務敏捷性和反應能力。

  • 需要進一步實現業務系統間的解耦和高可靠性。

  • 需要進一步提升管控平臺的實時響應能力。

”事件驅動架構“天然地滿足了這些能力要求。事件驅動架構”天生“的優點,比如,封裝、高內聚和低耦合,還可以提升程式碼的可維護性、效能和業務增長的需求,通過事件溯源模式,還能提高系統資料的可靠性。

不過,事件驅動同樣存在弊端,因為無論是概念上的複雜度還是技術上的複雜度都增加了,當它被濫用時將導致災難性的後果。所以,在技術棧的選用方面,給出以下寄語:

1)不要“盲目的追新” 技術人員的喜好往往是什麼技術流行就追什麼技術。現在的技術發展快,前後端不斷湧現各種框架,我們恨不得把這些框架都用在自己的專案裡才行,按實際出發,按需所用,適當的預留技術預研的空間。

2)不要“按技術站隊,以結果反推“ 很多人把手段當成了目的,成為了框架的信徒。用了Java開發,你的設計就一定是面向物件嗎?用了Spring boot就是微服務了嗎?一定要技術和實際場景結合,架構師也要深入瞭解掌握技術,但是更多的是瞭解技術的優劣和使用場景,而不是簡單的生搬硬套。

作者:vivo網際網路伺服器團隊-Gao Xiang