比較微服務中的分散式事務模式
譯自:Distributed transaction patterns for microservices compared
作為Red Hat的顧問架構師,曾有幸參與過無數個客戶專案。每個客戶都存在各自的挑戰,但我發現其中存在一定的共性。其中,客戶最想了解的一件事情是如何在多個記錄系統中協調寫操作。解答這個問題通常需要耐心地解釋雙寫、分散式事務、替代方案、可能的故障場景以及各個方式的缺點等等。這時候客戶通常會意識到將一體式應用切分為微服務是一個漫長且艱難的過程,需要一定的取捨。
本文不會深入討論事務系統,概括了在協調寫入多個資源時會用到的主要方法和模式。過去,你可能對其中一種或多種方法有良好或不好的體驗,但在合適的上下文、合適的限制條件下,這些方法都能夠發揮其各自的優點。
雙寫問題
可以預見,在需要寫入多個記錄系統時可能會遇到雙寫問題。該需求可能不夠明確,在分散式系統設計過程中可以以不同的方式來表達該需求,例如:
- 你已經為每個任務選擇了合適的工具,現在需要更新NoSQL資料庫、查詢索引以及單個業務事務的快取
- 你設計的服務需要更新其資料庫,並向其他服務傳送此次變更
- 你可能有跨多個服務邊界的業務事務
- 由於使用者會重試失敗的呼叫,因此你不得不實現冪等服務操作
本文中使用了一個簡單的場景來評估在分散式事務中處理雙寫的多種方式,該場景中,一個客戶端應用會呼叫一個微服務。在圖1中,A 服務需要更新其資料庫,但同時需要呼叫B服務來執行一個寫操作。在我們的討論中,不關注資料庫的型別以及服務到服務互動所使用的協議。
如果A服務寫入資料庫,然後向佇列中給B服務傳送通知(將這種方式稱為本地提交-然後釋出(local-commit-then-publish ) ),但這種方式有一定概率無法保證可靠性。當A服務寫入其資料庫,然後向佇列傳送訊息,A服務有一定概率在提交後且傳送訊息前傳送崩潰,導致系統處於不一致狀態。如果在寫入資料庫前傳送訊息(將這種方式稱為釋出-然後本地提交( publish-then-local-commit)),但此時仍然有一定概率發生資料庫寫入失敗或時序問題(在A服務提交到資料庫前,B服務接收到了該事件)。上述兩種場景都涉及對資料庫和佇列的雙寫,這也是下面需要探究的核心問題。在下面章節中,我將介紹幾種方法來應對這種一直都存在的挑戰。
一體式模組
將應用作為一體式模組進行開發,聽起來可能在開架構演進的倒車,但實際上這種方式並沒有什麼問題。它不是微服務模式,但可以看作是微服務的例外,可以謹慎地與微服務組合在一起。當對強一致性的寫入需求大於微服務的獨立部署和擴充套件時,就可以考慮採用一體式模組架構。
使用一體式架構並不意味著系統不好或缺乏設計。顧名思義,它傳達了使用一個開發單元、以模組方式進行設計的系統。注意,這是有意設計和實現的一體式模組,而非隨時間意外導致的一體式的後果。在有目的性的一體式模組架構中,每個模組都會遵循微服務原則,每個模組都封裝了所有對其資料的訪問操作,並以記憶體方法呼叫的方式來暴露和消費操作。
一體式架構
使用這種方式,必須要將兩個微服務(A服務和B服務)轉化為可以部署到一個共享執行時的模組庫。然後這兩個微服務就可以共享相同的資料庫例項。由於服務以庫的形式部署到相同的執行時中,因此就可以讓這兩個服務參與到相同的事務中。由於模組共享相同的資料庫例項,因此可以使用一個本地事務一次性提交或回滾所有操作。由於我們期望在更大規模的部署中以庫來部署服務,並參與到現有的事務中,因此在部署方法上也會存在一定的差異。
即使在一體式架構中,也有辦法隔離程式碼和資料。例如,可以將模組劃分到不同的包、構建模組和原始碼庫中,並由不同的團隊負責。可以根據命名規範、schemas、資料庫例項或資料庫伺服器來對錶進行分組,以此來隔離部分資料。圖2描述了應用中不同的程式碼和資料隔離級別,靈感來自Axel Fontaine的主題演講: 巨集偉的一體式模組。
最後看下如何在一個現有的事務中加入一個執行時以及封裝好的(可以使用其他模組的)服務。相比典型的微服務,所有這些限制使得模組之間的耦合更加緊密,但好處是,封裝的服務可以啟動一個事務,並呼叫模組來(在一個操作中)執行資料的更新、提交或事務回滾,而無需擔心區域性故障或最終一致性。
如圖3所示,我們將A服務和B服務轉換為模組,並部署到一個共享的執行時中(或使用其中的一個服務作為共享的執行時)。資料庫表也共享了同一個資料庫例項,但對錶進行了分組隔離,並由不同的庫服務管理。
一體式模組的優劣勢
在一些行業中,該架構帶來的好處要遠比快速交付和快速變更更加重要。表1概括了一體式模組架構的優劣勢:
表1:一體式模組的優劣勢
優勢 | 使用本地事務來保證資料一致性、讀寫一致性、回滾等,事務語義比較簡單 |
---|---|
劣勢 | 1. 共享執行時下無法進行獨立部署和模組擴充套件,且無法進行故障隔離 2. 資料庫中的表的邏輯隔離性不強,後續可能會發展為一個共享的整合層 3. 需要在開發階段協調模組的耦合性和共享事務上下文,這樣增加了服務間的耦合性 |
舉例 | 1. 執行時,如 Apache Karaf 和 WildFly,它們允許模組化和動態部署服務 2. Apache Camel的 direct 和direct-vm 元件,它們允許通過記憶體呼叫暴露操作,並支援通過JVM程式保留事務上下文3. Apache Isis是一個很好的一體式模組架構的例子。它通過為你的Spring Boot 應用自動生成UI和REST API來支援領域驅動的應用開發 4. Apache OFBiz是另一個一體式模組和麵向服務的架構(SOA)的例子。它是一個全面的企業資源規劃系統,擁有數百個表格和服務,可以自動化企業業務流程,其模組化架構可以讓開發者快速理解並進行定製。 |
分散式事務通常是最後的手段,用於:
- 當寫入不同的資源時無法達到最終一致性
- 當需要寫入各種各樣的資料來源
- 當需要處理一次性訊息,但無法對系統進行重構來實現冪等的操作
- 當整合了實現二階段提交規範的第三方黑盒系統或遺留系統
在上述場景中,如果不考慮可擴充套件性,我們可能會使用考慮分散式事務。
實現二階段提交架構
二階段提交需要一個分散式事務管理器(如Narayana),以及一個可靠的儲存層來儲存事務日誌。你可能會用到可參與分散式事務的(帶相關XA驅動的)相容DTP XA的資料來源,如RDBMS、訊息代理和快取等。如果正好有一個可用的資料來源,但執行在一個動態環境中,如kubernetes,你還需要一個類operator的機制來保證只能存在一個分散式事務管理器。事務管理器必須是高可用的,且能夠一直訪問事務日誌。
在實現時,可以參考用了kubernetes有狀態模式的Snowdrop Recovery Controller,它實現了單例模式,並使用PV來儲存事務日誌。這種型別中,還可以為SOAP web服務引入如Web Services Atomic Transaction這樣的規範。這些技術的共同點是它們都實現了XA規範,並有一箇中央事務協調器。
圖4中,A服務使用分散式將所有的變更提交到其資料庫,然後將訊息傳送到一個佇列,期間不會有訊息重複或訊息丟失。類似地,B服務使用分散式事務(在一條事務中)來消費訊息並提交到資料庫B,且不會有資料重複。或者B服務可以不使用分散式事務,轉而使用本地事務,並實現冪等消費模式。更正式一點,可以在一條事務中使用WS-AtomicTransaction協調A資料庫和B資料庫的寫入,從而避免最終一致性,但目前這種方式比較少見。
二階段提交的優劣勢
二階段提交協議提供了類似一體式模組中的本地事務保證,但也有例外。由於原子更新中涉及到兩個或多個不同的資料來源,資料來源可能因各種原因產生故障或阻塞事務。但由於中央協調器的存在,相比其他方式(後面討論),可以方便地發現分散式系統的狀態。
表2:二階段提交的優劣勢
優勢 | 1:標準方式,使用開箱即用的事務管理器以及資料來源 2:強資料一致性 |
---|---|
劣勢 | 1:可擴充套件性限制 2:當事務管理器故障時可能會導致恢復失敗 3:支援的資料來源有限 4:動態環境中需要儲存和單例模式 |
舉例 | 1: Jakarta Transactions API 2:WS-AtomicTransaction 3:JTS/IIOP 4:eBay’s GRIT 5:Atomikos 6:Narayana 7:訊息代理,如Apache ActiveMQ 8:實現了XA規範的關係型資料來源,記憶體資料庫如Infinispan |
編制(Orchestration)
一體式模組中,使用本地事務來了解系統的狀態。而在基於二階段提交協議的分散式事務中,需要保證狀態的一致性,唯一例外是當事務協調器故障時可能會發生無法恢復的失敗。但如果我們想降低一致性需求,同時仍然需要了解整體分散式系統的狀態並從一個地方進行協調,這時可以考慮使用編制模式,使用其中一個服務作為協調器和整體分佈狀態變更的協調者。編制器服務負責呼叫其他服務,直到達到期望的狀態或在故障時採取正確的動作,編制器使用它的本地資料庫來跟蹤狀態變更,並負責恢復與狀態變更有關的故障。
實現編制架構
最有名的編制技術的實現是BPMN規範實現,如 jBPM 和Camunda專案。對這類系統的需求並沒有隨著分散式架構(如微服務或無服務)而消亡,反而在增加。可以看下最新的有狀態編制引擎,它們並沒有遵循這類規範,但卻提供了相似的有狀態行為,如Netflix的Conductor, Uber的Cadence, 和 Apache的Airflow。無服務有狀態功能,如Amazon StepFunctions, Azure Durable Functions, 和Azure Logic Apps都屬於這類。此外還有很多開源庫,可以幫助實現有狀態協調和回滾行為,如Apache Camel的Saga 模式實現和NServiceBus Saga
圖5展示了將A服務作為有狀態協調器,負責呼叫B服務,並在需要時通過補償操作執行故障恢復。這種方法的關鍵特性是A服務和B服務都有本地事務邊界,但A服務瞭解並負責編制整體互動流程,這也是為什麼其邊界會涉及B服務的後端。在實現方面,可以設定同步互動(如圖所示),或在服務間使用訊息佇列(這種情況下也可以使用二階段提交)。
編制的優劣勢
編制是一種可能會涉及重試和回滾來讓分散式系統達到最終一致狀態的方法,編制要求參與的服務能夠提供冪等操作來讓協調器重試某個操作。參與的服務必須提供可恢復的後端,這樣協調器可以通過回滾來恢復整體狀態。這種方式的最大好處是能夠通過本地事務讓可能不支援分散式事務的各種服務達到一致性狀態。協調器和參與的服務僅需要本地事務,且總能夠通過查詢協調器瞭解到系統的狀態(即使是部分一致的狀態)。
表3:編制的優劣勢
優勢 | 1. 在各種分散式元件中協調狀態 2. 不需要XA事務 3.可以在協調器層面瞭解到分散式狀態 |
---|---|
劣勢 | 1. 複雜的分散式程式設計模型 2. 參與的服務可能要提供冪等補償操作 3. 最終一致性 4. 補償操作也可能無法執行故障恢復 |
舉例 | 1. jBPM 2. Camunda 3. MicroProfile Long Running Actions 4. Conductor 5. Cadence 6. Step Functions 7. Durable Functions 8. Apache Camel Saga pattern implementation 9. NServiceBus Saga pattern implementation 10. The CNCF Serverless Workflow specification 11. Homegrown implementations |
編排(Choreography)
可以看到,到目前為止,單個業務操作可能會涉及多個服務間的呼叫,且端到端的業務事務處理並沒有明確的時間。為了處理這種場景,編制模式使用一箇中央控制器服務來告訴參與者應該做什麼。
編制的替代方案是編排,它也是一種服務協調模式,但不需要中央控制點來協調參與者之間的事件互動。這種模式下,每個服務會執行本地事務,然後釋出事件並觸發其他服務的本地事務。由系統中參與的每個元件決定業務事務的工作流(而不會依賴中央控制點)。在過去,服務間互動時經常會使用非同步訊息層來實現編排方式。圖6展示了編排模式架構。
編排下的雙寫
為了讓基於訊息的編排能夠正常工作,每個參與的服務需要執行一個本地事務並通過向訊息設施中釋出命令或事件來觸發下一個服務。類似地,其他參與的服務需要消費訊息並執行本地事務,其本身就是在更高階別的雙寫問題中的雙重寫問題。當通過開發一個帶雙寫的訊息層來實現編排方式時,需要將其設計為一個跨本地資料庫和訊息代理的二階段提交,或者可以使用 分佈-然後本地提交 或 本地提交-然後釋出 的模式:
- 釋出-然後本地提交:首先嚐試釋出一個訊息,然後提交到本地事務。雖然這種方式聽起來不錯,但實際中會有很多挑戰。例如,你可能需要釋出一個本地事務提交時生成的ID,但這種方式下無法首先獲取到這個ID。且本地事務可能會失敗,但無法回滾已釋出的訊息。這種方式缺乏讀寫一致語義,大多數場景下並不適用。
- 本地提交-然後釋出:首先提交到本地事務,然後釋出訊息。這種方式在本地事務提交之後且訊息釋出前有很小的概率會出現故障。但即使這樣,你也可以通過讓服務實現冪等和重試來解決這種問題,即重新提交本地事務併發布訊息。如果你可以控制下游消費者並使其冪等時,就可以考慮使用這種方式(同時也是一個不錯的選項)。
無雙寫的編排
各種實現了編排的架構都會限制每個服務只能用本地事務寫入單個資料來源。下面看下如何在無雙寫場景下工作。
假設A服務接收到請求,並寫入A資料庫。B服務週期性輪詢服務A並檢測新的變更。當它讀取到變更時,B服務會使用此次變更更新其資料庫以及對應的索引或時間戳。此時兩個服務僅會使用本地事務寫入各自的資料庫並進行提交。圖7展示了這種方式,也可以稱為服務編排,或稱之為使用了良好的舊資料流水線。
最簡單的場景下,B服務會連線到A服務的資料庫,並讀取由A服務負責的表。業界會嘗試使用共享表來避免這種耦合,但這種情況下,任何A服務的實現變更都有可能會影響到B服務。我們可以對這種場景做稍許優化,如使用發件箱模式,給A服務分配一張表,作為公共介面。這張表僅包含B服務需要的內容,且易於查詢和跟蹤變更。如果還不夠好,可以讓B服務通過API管理層來查詢變更(而不通過直接連線A資料庫)。
從根本上講,上述方式都有相同的缺點:B服務必須要不斷輪詢A服務。這樣會導致不必要的、持續的系統負載以及在獲取變更時的不必要的延遲。通過輪詢微服務來獲取變更並不簡單,下面看下如何來優化這種架構。
帶Debezium的編排
一種提升編排架構的方式是使用像Debezium這樣的工具,這樣就可以使用A資料庫的事務日誌來捕獲資料變更(CDC)。圖8展示了這種方式。
Debezium可以監控資料庫的事務日誌,並向一個Apache Kafka topic中投遞相關的變更。使用這種方式時,B服務只需要監聽topic中的普通事件,而無需輪詢A服務的資料庫或使用APIs。取消使用輪詢資料庫的方式來獲取變更流,並在服務間引入佇列,使得分散式系統更可靠、可擴充套件,併為後續在新場景中引入新客戶提供了可能性。使用Debezium 為基於編制或編排的Sagq模式實現了發件箱模式。
這種方式的副作用是B服務可能會接收到重複的訊息。可以通過在業務邏輯層實現冪等或通過去重器(如Apache ActiveMQ Artemis的訊息去重探測或Apache Camel的冪等消費模式)來解決。
帶事件源的編排
事件源是另一種服務編排實現。這種方式下,會使用一系列狀態變更事件來儲存一個實體的狀態。當實體更新時,不會更新實體的狀態,而會將新事件附加到事件列表中。將新事件附加到事件儲存是一個在本地事務中完成的原子操作。這種方式的好處是事件儲存的行為類似訊息佇列,可以為其他服務提供事件消費的能力。
在我們的例子中,當轉為使用事件源時,需要將客戶請求儲存到一個僅支援附加的事件儲存中。A服務可以通過回放事件來修復當前狀態。事件源也需要允許B服務訂閱這些事件。使用這種機制,A服務可以將其儲存層作為與其他服務的互動層。這種方式非常簡潔,並解決了狀態變更時可靠釋出事件的問題,它引入了一種新的、很多開發者不熟悉的程式設計風格,併為狀態恢復和訊息壓縮上帶來了額外的複雜度,需要特定的資料儲存。
編排的優劣勢
除了用於檢索資料變更的機制,編排方式還解耦了寫操作,允許獨立擴充套件服務,並提升了整體系統的可靠性。這種方式的缺點是使用了去中心化的決策流,且很難發現全域性的分散式狀態。如果要在大規模服務中發現查詢了多個資料來源的請求狀態可能會比較困難。
表4:編排的優劣勢
優勢 | 1. 實現和互動解耦 2. 不需要事務協調器 3. 提升了擴充套件性和恢復能力 4. 近實時互動 5. 使用Debezium或類似工具時系統的開銷比較小 |
---|---|
劣勢 | 1. 系統的全域性狀態和協調邏輯分散到了所有參與者中 2. 最終一致性 |
舉例 | 1. Homegrown database or API polling implementations. 2. The outbox pattern 3. Choreography based on the Saga pattern 4. event sourcing 5. Eventuate 6. Debezium 7. Zendesk's Maxwell 8. Alibaba's Canal 9. Linkedin's Brooklin 10. Axon Framework 11. EventStoreDB |
並行流水線
編排模式中沒有中心點來請求系統狀態,但服務的狀態會在分散式系統中進行傳播。編排建立了一系列用於處理服務的流水線,因此當一個訊息達到一個整個流程中的特定的步驟時,說明它已經完成了前面的步驟。但如果我們解除這個限制並獨立處理所有的步驟會怎麼樣?這種場景下,B服務可能會直接處理一個請求,而不關心該請求是否已經被A服務處理。
在並行流水線中,我們增加了一個路由服務來接受請求,並在單個本地事務中通過訊息代理將其轉發到A服務和B服務。從這步開始,兩個服務都可以獨立且並行處理請求。
這種模式很容易實現,它僅適用於服務之間沒有時間繫結的情況。例如,無論A服務是否處理了相同的請求,B服務都可以處理該請求。而且,這種方式需要一個額外的路由服務或一個同時瞭解A服務和B服務的客戶端來轉發訊息。
Listen to yourself
Listen to yourself是一種輕量替代方式,其中一個服務作為路由器。使用這種替代方式,當A服務接收到一個請求時,不需要將其寫入資料庫,只需要將該請求釋出到訊息系統,最終該訊息會轉發給B服務和A服務本身。圖11 展示了這種模式。
未寫入資料庫的原因是避免雙寫,一旦一個訊息進入訊息系統,後續會將該訊息傳送給B服務,且可以在一個完全隔離的事務上下文中,將訊息反送給A服務。隨著加處理流程的扭曲,A服務和B服務可能會獨立處理請求並寫入各自的資料庫。
並行流水線的優劣勢
表5:並行流水線的優劣勢
優勢 | 簡單,並行處理下的可擴充套件架構 |
---|---|
劣勢 | 需要解耦服務間的時間繫結,且難以瞭解到全域性系統狀態 |
舉例 | Apache Camel的multicast 和splitter(並行處理) |
如何選型分散式事務策略
正如你看到的,在微服務架構中處理分散式事務時並不存在正確或錯誤的模式。每種模式都有其優劣勢。每種模式都解決了一些問題,但同時又引入了其他問題。圖12給出了上文討論過的雙寫模式下的主要特性。
不管選擇那種方式,你需要解釋和記錄決策背後的動機以及對選擇的長期架構後果負責,還可能需要從實施和維護系統從團隊中獲得支援。圖13給出了根據其資料一致性和可擴充套件性屬性得出的評估結果。
下面根據可擴充套件性和可用性從高到低對各種方法進行評估。
高:並行流水線和編排
如果你的步驟暫時是解耦的,那麼可以選擇並行流水線方法來執行這些步驟。你可以在系統的某一部分(而不是整個系統)中採用這種模式。下面,假設處理步驟中存在時間耦合,且特定操作和服務必須以一定順序執行,此時你可能會考慮使用編排方式。使用服務編排,可以建立一個可擴充套件的、事件驅動架構,訊息在去中心化的編排流程中流轉。這種場景下,可以使用Debezium 和 Apache Kafka來實現發件箱模式。
中:編制和二階段提交
如果編排不合適,你可能需要一箇中央點來負責協調和做出決策,此時可以考慮編制。這是一個比較流行的架構,可以使用標準的和自定義開源實現。但標準的實現可能會強制你使用特定的事務語義,使用自定義的編制實現可以在期望的資料一致性和可擴充套件性之間進行權衡。
低:一體式模組
到這一步,說明你可能對資料一致性有非常強的要求。在這種情況中,使用二階段提交的分散式事務可以在某些特定資料來源下工作,但它們很難在(為可擴充套件性和高可用性設計的)動態雲環境上保證可靠性。此時,你可能會使用一體式模組方式,這種方式保證的資料的高度一致性,但執行時和資料來源是耦合的。