1. 程式人生 > >微服務架構之事件驅動架構

微服務架構之事件驅動架構

前言

為了解決傳統的單體應用(Monolithic Application)在可擴充套件性、可靠性、適應性、高部署成本等方面的問題,許多公司(比如Amazon、eBay和NetFlix等)開始使用微服務架構(Microservice Architecture)構建自己的應用。

微服務架構(維基百科):
微服務 (Microservices) 是一種軟體架構風格 (Software Architecture Style),它是以專注於單一責任與功能的小型功能區塊 (Small Building Blocks) 為基礎,利用模組化的方式組合出複雜的大型應用程式,各功能區塊使用與語言無關 (Language-Independent/Language agnostic) 的 API 集相互通訊。

但是,微服務架構在帶來一系列好處的同時,也帶來了若干挑戰。除了分散式系統固有的複雜性以外,微服務架構也深刻影響了應用和資料庫之間的關係,與傳統多個服務共享一個數據庫的方式不同,微服務架構每個服務都有自己的資料庫。對於開發者來說,這就為微服務中的資料管理提出了更高的要求。

微服務架構中的資料管理

在傳統的單體應用中,通常使用單個的關係型資料庫。這類資料庫所提供的事務語義,具備ACID特性。

ACID:
- Atomicity(原子性):一個事務中的操作是原子的,其中任何一步失敗,系統都能夠完全回到事務前的狀態
- Consistency(一致性):資料庫的狀態始終保持一致
- Isolation(隔離性):多個併發執行的事務不會互相影響
- Durability(永續性):事務處理結束後,對資料的修改是永久的

應用得益於資料庫的這些特性,能夠用簡單的方式對資料進行修改與讀取,而無需花費太多精力考慮資料一致性問題。

但是,在微服務架構下,為了在微服務之間建立鬆耦合的關係,通常每一個微服務都會擁有自己獨立的資料庫,僅僅通過對外暴露的API來進行資料交換。這種情況下,我們就要面臨分散式資料管理帶來的挑戰。也就是說,在實現業務邏輯時,如何保證服務之間的資料一致性

實時一致性

我們首先考慮在系統中實現實時一致性的情況。比如以一個銀行系統為例,客戶通常會有一個儲蓄賬戶和一個理財賬戶。現在,考慮客戶從自己的儲蓄賬戶向理財賬戶轉賬10000元的場景。

假設現在有兩張表 deposit_account 和 finance_account,分別用於儲存儲蓄賬戶和理財賬戶的資訊,使用者的ID是201。那麼,在單一資料庫場景下,通過資料庫事務可以很容易完成這個操作:

Begin transaction
    update deposit_account_table set amount=amount-10000 where userId=201;
    update finance_account amount=amount+10000 where userId=1;
End transaction
commit;

這樣在單體應用中,由於所有資料都是儲存在同一個資料庫中,通過資料庫提供的ACID特性,就可以輕鬆實現資料的實時一致性。

但是,在微服務架構中,可能的設計是存在兩個服務:儲蓄服務(Deposit Service)和理財服務(Finance Service),假設由儲蓄服務負責處理客戶的轉賬請求。而如下圖所示,這兩個服務都分別維護自己的資料,因此儲蓄服務無法直接訪問理財服務的資料,而只能通過API去修改客戶的餘額。

此時,為了滿足訂單服務與客戶服務之間的實時一致性要求,可以採用分散式事務,比如基於兩階段提交協議(Two-phase commit, 2PC)的實現來做到這一點。(關於2PC,已經有大量的研究成果和成功實踐經驗,本文將不再做太多闡述,具體可自行參見相關文獻和資料)

根據CAP定理,我們追求實時一致性時,通常需要犧牲掉部分可用性。比如以上場景中,當 Finance Service 由於軟硬體故障或網路問題而不可用的時候,系統將無法為使用者提供內部轉賬服務。

此外,作為典型的同步操作,2PC也存在著比較比較嚴重的效能問題,並不適合高併發場景。因此,在資料一致性上我們需要尋求其他的解決方案。

最終一致性

如果我們考慮只保證系統的最終一致性,那麼就可以避免使用2PC,從而提高系統可用性和效能。

仍然以以上的使用者內部賬戶之間的轉賬服務為例。當用戶從儲蓄賬戶向理財賬戶轉賬時,減少儲蓄賬戶的金額與增加理財賬戶的金額這兩個動作,可以無需在一個事務裡面完成,而是分成兩步:
0. 儲蓄服務減去儲蓄賬戶中的金額,並生成一個憑證(訊息)傳送給理財服務;
0. 理財服務收到憑證後,在理財賬戶中增加相應的金額。

我們會發現以上過程在第1步完成之後,第2步完成之前,儲蓄賬戶與理財賬戶之間實際上是存在短時間的資料不一致的。但是,只要最終第2步能夠完成,系統的資料就仍然能夠保持一致性,這就是我們所說的最終一致性。

在最終一致性這個前提下,即使理財服務在某段時間內不可用,系統仍然能夠能為使用者提供內部轉賬服務,從而提高了系統的可用性。

而這樣一種基於最終一致性的解決方案,就是本文將要介紹的事件驅動的架構(Event-driven Architecture)

事件驅動的架構

所謂事件驅動的架構,也就是使用事件來實現跨多個服務的業務邏輯

在這一架構裡,當有重要事件發生時,比如更新業務資料,某個微服務會發布事件,其它微服務則訂閱這些事件;當某一微服務接收到事件就可以更新自己的業務資料,同時釋出新的事件觸發下一步更新。而事件的釋出與訂閱,則依賴於一個可靠的訊息代理(Message Broker)。

以上文的場景為例,在事件驅動的架構中,從儲蓄賬戶轉賬到理財賬戶的過程如下:
0. 儲蓄服務將使用者的儲蓄賬戶中的金額減少10000,併發布“向理財賬戶轉賬”事件;
0. 理財服務獲取“轉賬到理財賬戶”事件, 更新理財賬戶,將理財賬戶的金額增加10000,併發布“理財賬戶轉入”事件;
0. 儲蓄服務獲取“理財賬戶轉入”事件,結束本次轉賬交易。

在這裡需要考慮的一個問題,就是轉賬失敗處理。比如以上第2步如果因為“理財賬戶被凍結無法轉入資金”之類的原因失敗了,理財服務就應該釋出“理財賬戶轉入失敗”事件,儲蓄服務獲取到該事件後,需要對儲蓄賬戶進行回滾,將減少的金額重新增加回去。

以上的過程與傳統的資料管理基於ACID模型不一樣的是,它是基於BASE模型的。

BASE:
- Basically Available(基本可用):系統在出現不可預知的故障的時候,允許損失部分可用性,但不等於系統不可用
- Soft State(軟狀態):允許系統中的資料存在中間狀態,並認為該中間狀態的存在不會影響系統的整體可用性
- Eventually Consistent(最終一致性):系統保證最終資料能夠達到一致

事件釋出

在事件驅動的架構中,跨服務完成業務邏輯的一個關鍵點是每個服務自動更新資料庫和釋出事件,也就是要以原子粒度更新資料庫和釋出事件。例如,儲蓄服務必須在對儲蓄賬戶表進行更新,然後釋出“向理財賬戶轉賬”事件,這兩個操作需要原子化實現。如果服務在更新資料庫之後、釋出事件之前崩潰,系統會變得不一致。

保證資料更新與事件釋出原子化的方法,有以下幾種:
- 使用本地事務釋出事件
- 挖掘資料庫事務日誌
- 使用事件源

使用本地事務釋出事件

一個實現原子化的方法是使用本地事務來更新業務實體和事件列表,由一個獨立程序來發布事件。具體來說,就是在儲存業務實體狀態的資料庫中,使用一個事件表來充當訊息佇列。應用啟動一個(本地)資料庫事務,更新業務實體的狀態,在事件表中插入一個事件,並提交該事務。一個獨立的訊息釋出執行緒或程序查詢該事件表,將事件釋出到訊息代理,並標註該事件為已釋出。下圖展示了這一設計。

儲蓄服務更新儲蓄賬戶的餘額,然後在事件表中插入“轉賬到理財賬戶”的事件。事件釋出執行緒或程序在事件表中查詢未釋出的事件併發布,然後更新事件表,將該事件標記為已釋出。

這種方法的優點是:
- 使用本地事務,保證了資料被更新時事件一定能夠被髮布
- 實現簡單,只需要系統具備本地事務的能力即可實現

這種方法的一個缺點是,資料更新操作與所要釋出的事件之間的對應關係,是由應用的開發者實現的,因此有很大可能出錯。

挖掘資料庫事務日誌

實現原子化的另一種方式是由執行緒或者程序通過挖掘資料庫事務或提交日誌來發布事件。應用更新資料庫,資料庫的事務日誌會記錄這些變更。事務日誌挖掘執行緒或程序讀取這些日誌,並把事件釋出到訊息代理。

比如一個B2C的電商網站,就可以通過挖掘訂單資料的更新日誌,來進行事件釋出。如下圖所示:

這一方法的範例是開源的 LinkedIn Databus 專案。Databus 挖掘 Oracle 事務日誌併發布與之對應的事件,LinkedIn 則使用 Databus 維持各種來源的資料儲存與記錄系統一致。

另一個範例則是 AWS DynamoDB 採用的流機制。AWS DynamoDB 是一個可管理的 NoSQL 資料庫,其中每個 DynamoDB 流包括 DynamoDB 表在過去 24 小時之內的時序變化,包括建立、更新和刪除操作。應用能夠讀取這些變更,將其作為事件釋出。

這種方法的優點是:
- 要釋出的事件直接來源於資料庫的事務日誌,因此不會出錯
- 應用無需關注事件的釋出,簡化了應用開發者的工作

但是這種方法也有一些缺點:
- 事務日誌的格式與所使用的資料庫相關,因此事件挖掘 的實現會由於資料庫的種類或版本的變化而隨之需要修改
- 由於是直接從資料庫的更新記錄生成事件,因此可能會無法逆向推斷出業務邏輯,因此並不適合於所有場景(比如前文所述的轉賬場景)

使用事件源

事件源採用一種截然不同的、以事件為中心的方法來儲存業務實體——不同於儲存實體的當前狀態,應用儲存的是狀態改變的事件序列。每當業務實體的狀態改變,新事件就被附加到事件列表,並且應用可以通過事件回放來重構實體的當前狀態。鑑於儲存事件是一個單一的操作,因此本質上也是原子化的。

要了解事件源如何執行,可以以儲蓄服務為例。在傳統的方法中,每次轉賬交易都會更新儲蓄賬戶表的記錄。而使用事件源的時候,儲蓄服務以狀態更改事件的方式儲存使用者的儲蓄賬戶,每個事件都包含足夠的資料去重建儲蓄賬戶狀態。

事件長期儲存在事件倉庫(Event Store),使用 API 新增和檢索實體的事件。同時,事件倉庫起到類似上文提及的訊息代理的作用,通過 API 讓服務訂閱事件,將所有事件傳達到所有感興趣的訂閱者。所以,事件倉庫可以認為是資料庫與訊息代理的綜合體,是事件源方法的支柱。

事件源方法有如下的優點:
- 事件即狀態,釋出事件就是在更新狀態,因此天然具有原子性,並且不會出錯
- 由於儲存的是事件,而不是域物件,因此避免了物件關係抗阻不匹配的問題(object‑relational impedance mismatch problem)
- 由於儲存了所有的業務狀態更新事件,因此可以通過事件回放推斷出任一時間點的業務實體狀態

事件源方法也有以下這些缺點:
- 要實現一個可靠和高效能的事件倉庫並不是一件容易的事情
- 應用程式碼需要根據事件倉庫的 API 進行重寫
- 事件倉庫只直接支援通過主鍵查詢業務實體,因此對於複雜檢視的查詢比較困難(可以通過CQRS方法解決,具體參見下文)

命令查詢分離(CQRS)

在事件源方法中,不再直接儲存任何業務實體的狀態,而是代之以狀態變更事件。在進行復雜檢視的查詢時,如果還按照與命令操作同樣的方式,將會遇到一些困難。比如要發起如下的一個同時涉及儲蓄賬戶和理財賬戶的查詢操作:

SELECT *
FROM DEPOSIT_ACCOUNT deposit, FINANCE_ACCOUNT finance
WHERE
    deposit.user_id = finance.user_id
    AND finance.state = 'active'
    AND deposit.amount > 100000
    AND finance.amount > 5000

在非事件源的方式下,可以很容易的從儲蓄賬戶表和理財賬戶表查詢到相應資料。但是在事件源方式下,事件倉庫中儲存的是一系列事件,並且只能通過主鍵(比如 deposit_account.id 或 finance_account.id)去查詢相應的業務實體,此時要處理類似 deposit.amount > 100000 這樣的查詢條件以及條件組合時,是非常複雜和低效的。

為了解決這一問題,可以採用CQRS方法,將命令與查詢分離。命令操作仍然通過各服務的 API 以更新事件列表的方式進行,而查詢操作則通過一個統一的檢視查詢服務(View Query Service)完成。

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

結論

在微服務架構中,每個微服務都有其私有資料儲存,不同的微服務可能使用不同的資料庫。這種架構帶來便利的同時,也給分散式資料管理帶來挑戰,其中最大的挑戰就是在實現跨服務的業務邏輯時,如何保持服務之間的資料一致性

對於許多應用,解決方案就是使用事件驅動的架構。事件驅動的架構帶來的挑戰是如何原子化地更新狀態和釋出事件。有幾個方法可以做到這一點,包括把資料庫用作訊息佇列、事務日誌挖掘和事件源。

參考文獻