微服务下分布式事务模式的详细对比
點擊上方“朱小廝的博客”,選擇“設為星標”
后臺回復"書",獲取
后臺回復“k8s”,可領取k8s資料
作為 Red Hat 咨詢架構師,我有幸參與了大量客戶項目。雖然每個客戶都面臨自己特有的挑戰,但是我發現其中有一些共同點。大多數項目都想知道如何協調對多個記錄系統的寫入。要回答這個問題,一般會涉及長篇累牘的解釋,包括雙重寫入(dual write)、分布式事務、現代化的替代方案以及每種方式可能出現的故障情況和缺點。這樣做通常會讓客戶意識到,將單體應用拆分為微服務架構是一個漫長和復雜的過程,而且通常都需要權衡。
本文不會深入介紹事務的細節,而是總結了向多個數據源協調寫入操作的主要方式和模式。我知道,你可能對這些方法有過美好或糟糕的經驗。但是實踐中,在正確的環境和正確的限制條件下,這些方法都能很好地工作。技術領導者要為自己的環境選擇最好的方式。
1雙重寫入的問題
關于你是否會面臨雙重寫入的問題有一個簡單的指標,那就是預期要不要向多個記錄系統進行寫入操作。這樣的需求可能并不明顯,在分布式系統設計的過程中,它可能會以不同的方式進行表述。比如說:
你已經為每項工作選擇了最佳工具,現在在一個業務事務中,你必須要更新一個 NoSQL 數據庫、一個搜索索引和一個緩存。
你所設計的服務必須要更新自己的數據庫,同時還要把變更相關的信息以通知的形式發送給另一個服務。
你的業務事務跨越了多個服務的邊界。
你可能需要以冪等的方式實現服務操作,因為服務的消費者必須要重試失敗的調用。
在本文中,我們將會使用一個很簡單的示例場景來評估在分布式事務中處理雙重寫入的各種方法。我們的場景是一個客戶端應用,它會在發生變更操作的時候,調用一個微服務。服務 A 要更新自己的數據庫,但是它還要調用服務 B 進行寫入操作,如圖 1 所示。至于數據庫的實際類型以及服務與服務之間進行交互的協議,這些對于我們的討論都無關緊要,因為問題都是一樣的。
微服務中的雙重寫入問題
我們簡要解釋一下為什么這個問題沒有簡單的解決方案。如果服務 A 寫入到了自己的數據庫,然后發送一個通知到隊列中供服務 B 使用(我們將這種方式稱為 local-commit-then-publish),這樣應用依然有可能無法可靠地運行。當服務 A 寫入到自己的數據庫,然后發送消息到隊列時,依然有很小的概率發生這樣的事情,即應用在提交到數據庫后,且在第二個操作之前,發生了崩潰,這樣的話,就會使系統處于一個不一致的狀態。如果消息在寫入到數據庫之前發送的話(我們將這種方式稱為 publish-then-local-commit),有可能出現數據庫寫入失敗,或者服務 B 接收到事件的時候,服務 A 還沒有提交到數據庫,這會出現時效性問題。不管是出現哪種情況,這種場景都會涉及到對數據庫和隊列的雙重寫入問題,這就是我們要探討的核心問題。在下面的章節中,我們將會討論針對這一長期存在的挑戰目前已有的各種解決方案。
2模塊化單體
將應用程序開發為模塊化單體看起來像一種權宜之計(hack),或是架構演化的一種倒退。但是,我發現它在實踐中能夠很好地運行。它不是一種微服務的模式,而是微服務規則的一個例外情況,能夠非常嚴謹地與微服務相結合。如果強寫入一致性是驅動性的需求,甚至要比獨立部署和擴展微服務的能力更重要時,那么我們就可以采用模塊化單體的架構。
采用單體架構并不意味著系統設計得很差或者是件壞事。它并不說明任何質量相關的問題。顧名思義,這是一個按照模塊化方式設計的系統,它只有一個部署單元。需要注意,這是一個精心設計和實現的模塊化單體,這與隨意創建并隨時間而不斷增長的單體是不同的。在精心設計的模塊化單體架構中,每個模塊都遵循微服務的原則。每個模塊會封裝對其數據的所有訪問,但是操作是以內存方法調用的方式進行暴露和消費的。
模塊化單體的架構
如果采用這種方式的話,我們必須要將兩個微服務(服務 A 和服務 B)轉換成可以部署到共享運行時的庫模塊(library module)。然后,讓這兩個微服務共享同一個數據庫實例。因為服務是在一個通用的運行中編寫和部署的,所以它們可以參與相同的事務。鑒于這些模塊共享同一個數據庫實例,所以我們可以使用本地事務一次性地提交或回滾所有的變更。在部署方法方面也有差異,因為我們希望模塊以庫的方式部署到一個更大的部署單元中,并參與現有的事務。
即便是在單體架構中,也有一些方式來隔離代碼和數據。例如,我們可以將模塊隔離成單獨的包、構建模塊和源碼倉庫,這些模塊可以由不同的團隊所擁有。通過將表按照命名規則、模式、數據庫實例,甚至數據庫服務器的方式進行分組,我們可以實現數據的部分隔離。圖 2 的靈感來源于 Axel Fontaine 關于偉大的模塊化單體的演講,它闡述了應用中不同的代碼和數據隔離級別。
應用程序的代碼和數據隔離級別
拼圖的最后一塊是使用一個運行時和一個包裝器服務(wrapper service),該服務能夠消費其他的模塊并將其納入到現有事務的上下文中。所有的這些限制使模塊比典型的微服務耦合更緊密,但是好處在于包裝器服務能夠啟動一個事務、調用庫模塊來更新它們的數據庫,并且以一個操作的形式提交或回滾事務,而不必擔心部分失敗或最終一致性的問題。
在我們的樣例中,如圖 3 所示,我們將服務 A 和服務 B 轉換為庫,并將它們部署到一個共享的運行時中,或者也可以將其中的某個服務作為共享運行時。數據庫的表也共享同一個數據庫實例,但是它會被拆分為一組由各自的庫服務管理的表。
具有共享數據庫的模塊化單體
模塊化單體的優點和缺點
在有些行業中,這種架構的收益遠比其他地方所看重的更快的交付以及更快的變更節奏重要得多。表 1 總結了模塊化單體架構的優點和缺點。
表 1:模塊化單體架構的優點和缺點
分布式事務通常是最后的方案,通常會在如下的情況下使用:
當對不同資源的寫入操作不允許最終一致性時;
當我們必須要寫入到不同種類的數據源時;
當我們需要確保對消息的處理有且僅有一次,而且無法重構系統以實現操作的冪等性時;
當與第三方黑盒系統或實現了兩階段提交規范的遺留系統進行集成時。
在這些情況下,如果可擴展性不是重要的關注點的話,我們可以考慮將分布式事務作為一種可選方案。
實現兩階段提交架構
兩階段提交技術要求我們有一個分布式事務管理器(如 Narayana)和一個可靠的事務日志存儲層。我們還需要能夠兼容 DTP XA 的數據源,以及能夠參與分布式事務的相關的 XA 驅動,比如 RDBMS、消息代理和緩存。如果你足夠幸運有合適的數據源,但是運行在一個動態環境中,比如 Kubernetes,那么你還需要有一個像 operator 這樣的機制,以確保分布式事務管理器只有一個實例。事務管理器必須是高可用的,并且必須能夠訪問事務日志。
就實現而言,你可以嘗試使用 Snowdrop Recovery Controller,它使用 Kubernetes StatefulSet 模式來實現單例,并使用持久化卷來存儲事務日志。在這個類別中,我還包含了適用于 SOAP Web 服務的 Web Services Atomic Transaction(WS-AtomicTransaction)等規范。所有這些技術的共同點在于它們實現了 XA 規范,并且有一個中心化的事務協調器。
在我們的樣例中,如圖 4 所示,服務 A 使用分布式事務提交所有的變更到自己的數據庫中,并且會提交一條消息到隊列中,這個過程中不會出現消息的重復和丟失。類似的,服務 B 可以使用分布式服務來消費消息,并在同一個事務中提交至數據庫 B,這個過程中也不會出現任何的重復數據。或者,服務 B 也可以選擇不使用分布式事務,而是使用本地事務并實現冪等的消費者模式。在本節中,一個更合適的例子是使用 WS-AtomicTransaction 在一個事務中協調對數據庫 A 和數據庫 B 的寫入,并完全避免最終一致性。但是,現在這種方式已經不太常見了。
跨數據庫和消息代理的二階段提交
兩階段提交架構優點和缺點
兩階段提交協議所提供的保障與模塊化單體中的本地事務類似,但有些例外情況。因為這里有兩個或更多的獨立數據源參與到原子更新之中,所以它們可能會以不同的方式失敗并阻塞整個事務。但是,由于存在一個中心化的協調者,相對于我下面將要討論的其他方式,我們還是能夠很容易地發現分布式系統的狀態。
表 2:兩階段提交的優點和缺點
3編排式
對于模塊化單體來講,我們會使用本地事務,這樣我們始終能夠知道系統的狀態。對基于兩階段提交的分布式事務,我們也能保證狀態的一致性。唯一的例外情況是事務協調者出現了不可恢復的故障。但是,如果我們想要減弱一致性的需求,而希望能夠了解整個分布式系統的狀態,并且能從一個地方對其進行協調,那么我們該怎么處理呢?
在這種情況下,我們可以考慮采取一種編排(orchestration)的方式,在這里,某個服務會擔任整個分布式狀態變更的協調者和編排者。編排者服務有責任調用其他的服務,直至它們達到所需的狀態,或者在它們出現故障的時候執行糾正措施。編排者使用它的本地數據庫來跟蹤狀態變更,并且要負責恢復與狀態變更的所有故障。
實現編排式架構
編排式技術最流行的實現是 BPMN 規范的各種具體實現,比如 jBPM 和 Camunda。對這種系統的需求并不會因為微服務或 Serverless 這樣的極度分布式架構的出現而消失,相反,這種需求還會增加。為了證明這一點,我們可以看一下較新的有狀態編排引擎,它們沒有遵循什么規范,但是卻提供了類似的有狀態行為,比如 Netflix 的 Conductor、Uber 的 Cadence 和 Apache 的 Airflow。像 Amazon StepFunctions、Azure Durable Functions 和 Azure Logic Apps 這樣的 Serverless 有狀態函數也屬于這個類別。還有一些開源庫允許我們實現有狀態的協調和回滾行為,如 Apache Camel 的 Saga 模式實現和 NServiceBus 的 Saga 功能。許多實現 Saga 模式的自定義系統也屬于這一類。
編排兩個服務的分布式事務
在我們的示例圖中,我們讓服務 A 作為有狀態的編排者,負責調用服務 B 并在需要的時候通過補償操作從故障中恢復。這種方式的關鍵特征是,服務 A 和服務 B 有本地事務的邊界,但是服務 A 有協調整個交互流程的知識和責任。這也是為什么它的事務邊界會接觸到服務 B 的端點。在實現方面,我們可以使用同步的交互,就像上圖所示,也可以在服務之間使用消息隊列(在這種情況下我們也可以使用兩階段提交)。
編排式的優點和缺點
編排式是一種最終一致的方法,它可能會涉及到重試和回滾才能使分布式系統達到一致的狀態。雖然避免了對分布式事務的需求,但是編排的方式要求參與的服務提供冪等的操作,以防協調者必須進行重試操作。參與的服務還必須要提供恢復端點,以防協調者決定執行回滾并修復全局狀態。這種方式的最大優點是,能夠僅通過本地事務就能驅動那些可能不支持分布式事務的異構服務達到一致的狀態。協調者和參與的服務只需要本地事務即可,而且始終能夠通過協調者查詢系統的狀態,即便它目前可能處于部分一致的狀態。在下面我所描述的其他方式中,是不可能實現這一點的。
表 3:編排式的優點和缺點
4協同式
從迄今為止的討論中,我們可以看到,一個業務操作可能會導致服務間的多次調用,并且一個業務事務完成端到端的處理所需的時間是不確定的。為了管理這一點,編排式(orchestration)模式會使用一個中心化的控制器服務,它會告訴參與者該做什么。
編排式的一種替代方案就是協同式(choreography),在這種風格的服務協調中,參與者在交換事件時沒有一個中心化的控制點。在這種模式下,每個服務會執行一個本地事務并發布事件,從而觸發其他服務中的本地事務。系統中的每個組件都要參與業務事務工作流的決策,而不是依賴一個中心化的控制點。在歷史上,協同式方式最常見的實現就是使用異步消息層來進行服務的交互。圖 6 說明了協同式模式的基本架構。
通過消息層進行服務協同化
具有雙重寫入的協同式
為了實現基于消息的服務協同,我們需要每個參與的服務執行一個本地事務,并通過向消息基礎設施發布一個命令或事件,以觸發下一個服務。同樣的,其他參與的服務必須消費一個消息并執行本地事務。從本質上來講,這就是在一個較高層級的雙重寫入問題中又出現了另一個雙重寫入的問題。當我們開發一個具有雙重寫入的消息層來實現協同式模式的時候,我們可以把它設計成跨本地數據庫和消息代理的一個兩階段提交。在前面,我們曾經介紹過這種方式。另外,我們也可以采用 publish-then-local-commit 或 local-commit-then-publish 模式:
Publish-then-local-commit:我們可以先嘗試發布一條消息,然后再提交本地事務。雖然這種方案聽起來不錯,但是它有一些切實的挑戰。舉例來說,在很多時候,我們需要發布一個由本地事務所生成的 ID,而這個 ID 此時還沒有生成,因此無法發布。另外,本地事務有可能會失敗,但是我們無法回滾已經發布的消息。這種方式缺乏“讀取自己的寫入”的語義,因此對于大多數場景來說,這并不是合適的方案。
Local-commit-then-publish:一個稍好一點的辦法是先提交本地事務,然后再發布消息。在本地事務提交之后和消息發布之前這里有很小的概率會出現故障。但即便是出現這樣的情況,你也可以把服務設計成冪等的并對操作進行重試。這意味著會再次提交本地事務并發布消息。如果你能控制下游的消費者并且確保它們是冪等的,那么這種方式就是行之有效的。總體而言,這是一個很好的實現方案。
無雙重寫入的協同式
實現協同式架構的各種實現方式都限制每個服務都要通過本地事務寫入到單一的數據源中,而不能寫入到其他的地方中。我們看一下,如何在避免雙重寫入的情況下實現這一點。
假設服務 A 接收到一個請求并要對數據庫 A 進行寫入操作,除此之外不再操作其他的數據源。服務 B 周期性地輪詢服務 A 并探測新的變更。當它讀取到變更時,服務 B 會基于變更更新自己的數據庫,并且會更新索引或時間戳來標記獲取到了變更。這里的關鍵在于,這兩個服務只對自己的數據庫進行寫入操作,并以本地事務的形式進行提交。如圖 7 所示,這種方式可以描述為服務協同(service choreography),或者我們也可以用非常古老的數據管道的術語來對其進行描述。至于可供選用的實現方案就更有趣了。
通過輪詢實現的服務協同
對于服務 B 來說,最簡單的場景就是連接到服務 A 的數據庫并讀取服務 A 的表。但是,業界會盡量避免共享數據表這種級別的耦合,原因在于:服務 A 的實現和數據模型的任意變更都可能干擾到服務 B。我們可以對這種場景做一些改進,例如使用發件箱(Outbox)模式,為服務 A 提供一個表作為公開接口。這個表可以只包含服務 B 所需的內容,它可以設計得易于查詢和跟蹤變更。如果你覺得這還不夠好的話,進一步的改進方案是讓服務 B 通過 API 管理層查詢服務 A 的所有變化,而不是直接連接數據庫 A。
從根本上來講,所有的這些變種形式都有一個相同的缺點:服務 B 需要不斷地輪詢服務 A。這種方式會給系統帶來不必要的持續負載,或者在接收變更時存在不必要的延遲。輪詢微服務的變更并不是常見的做法,那么我們看一下如何進一步改善這個架構。
使用 Debezium 的協同式
在改進協同式架構時,有一種方式很有吸引力,那就是引入像 Debezium 這樣的工具,它使用數據庫 A 的事務日志執行變更數據捕獲(change data capture,CDC)。這種方式如圖 8 所示。
通過變更數據捕獲實現的服務協同
Debezium 可以監控數據庫的事務日志,執行必要的過濾和轉換,并將相關的變更投遞到 Apache Kafka 的主題中。這樣的話,服務 B 就可以監聽主題中的通用事件,而不是輪詢服務 A 的數據庫或 API。我們通過這種方式,將數據庫輪詢轉換成了流式變更,并且在服務間引入了一個隊列,這樣會使得分布式系統更加可靠、可擴展,而且為新的使用場景會引入其他消費者提供了可能性。Debezium 提供了一種優雅的方式來實現發件箱模式,能夠用于基于編排式和協同式的 Saga 模式實現。
這種方式的一個副作用在于,服務 B 有接收到重復消息的可能性。這可以通過實現冪等的服務來解決,可以在業務邏輯層面來解決,也可以使用技術化的去重器(deduplicator,比如 Apache ActiveMQ Artemis 的重復消息探測或者 Apache Camel 的冪等消費者模式)。
使用事件溯源的協同式模式
事件溯源(event sourcing)是另外一種服務協同的實現模式。在這種模式下,實體的狀態會被存儲為一系列的狀態變更事件。當有新的更新時,不是更新實體的狀態,而是往事件的列表中追加一個新的事件。往事件存儲中追加新的事件是一個原子性的操作,會在一個本地事務中完成。如圖 9 所示,這種方式的好處在于,對于消費數據更新的其他服務來講,事件存儲的行為也是一個消息隊列。
通過事件溯源實現的服務協同
在我們樣例中,如果要轉換成使用事件溯源的話,要把客戶端的請求存儲在一個只能進行追加操作的事件存儲中。服務 A 可以通過重放(replay)事件重新構建當前的狀態。事件存儲需要讓服務 B 也訂閱相同的更新事件。通過這種機制,服務 A 使用其存儲層作為與其他服務的通信層。盡管這種機制非常整潔,解決了當有狀態變更時可靠地發布事件的問題,但是它引入了一種很多開發人員所不熟悉的編程風格,并且圍繞狀態重建和消息壓縮,會引入額外的復雜性,這需要專門的存儲。
協同式的優點和缺點
不管使用哪種方式來檢索數據變更,協同式的模式都解耦了寫入,能夠實現獨立的服務可擴展性,并提升系統整體的彈性。這種方式的缺點在于,決策流是分散的,很難發現全局的分布式狀態。要查看一個請求的狀態需要查詢多個數據源,這對于服務數量眾多的場景來說是一個挑戰。表 4 總結了這種方式的優點和缺點。
表 4:協同式的優點和缺點
5并行管道
在協同式模式中,沒有一個中心化的地方可以查詢系統的狀態,但是會有一個服務的序列,以便于在分布式系統中傳播狀態。協同式模式創建了一個處理服務的序列化管道,所以我們能夠知道當一個消息到達整個過程的特定步驟時,它肯定已經通過了前面的所有步驟。如果我們能夠放松這個限制,允許獨立地處理這些步驟的話,情況又會怎樣呢?在這種場景下,服務 B 在處理一個請求的時候,根本不用關心服務 A 是否已經處理過它。
在并行管道的方式中,我們會添加一個路由服務,該服務接收請求,并在一個本地事務中通過消息代理將請求轉發至服務 A 和服務 B。如圖 10 所示,從這個步驟開始,兩個服務可以獨立、并行地處理請求。
通過并行管道進行處理
盡管這種模式很容易實現,但是它只適用于服務之間沒有時間約束的場景。例如,服務 B 不管服務 A 是否已經處理過該請求,它都能夠對請求進行處理。同時,這種方式需要一個額外的路由服務,或者客戶端知道服務 A 和服務 B,從而能夠給它們發送消息。
監聽自身??
這種方式有一種輕量級的替代方案,被稱為“監聽自身(listen to yourself)”模式,在這里,其中有個服務會同時擔任路由。在這種替代方式下,當服務 A 接收到一個請求時,它不會寫入到自己的數據庫中,而是將請求發送至消息系統中,而消息的目標是服務 B 以及服務 A 本身。圖 11 闡述了這種模式。
監聽自身模式
在這里,不寫入數據庫的原因在于避免雙重寫入。當進入消息系統之后,消息會在完全獨立的事務上下文中進入服務 B,也會重新返回服務 A。通過這樣一個曲折的處理流程,服務 A 和服務 B 就可以獨立地處理請求,并寫入到各自的數據庫中了。
并行管道的優點和缺點
表 5:并行管道的優點和缺點
6如何選擇分布式事務策略
從本文的論述中,你可能已經猜到,在微服務架構中,處理分布式事務并沒有正確或錯誤的模式。每種模式都有其優點和缺點。每種模式都能解決一些問題,但是反過來又會產生其他的問題。圖 12 中的圖表簡單總結了我所闡述的各種雙重寫入模式的主要特征。
雙重寫入模式的特征
不管你采用哪種方式,都要闡述和記錄決策背后的動機,以及該選擇在架構上所帶來的長期影響。你還需要得到從長期實現和維護該系統的團隊那里獲取支持。在這里,我根據數據一致性和可擴展性特征來組織和評估本文所描述的各種方法,如圖 13 所示。
各個雙重寫入模式的數據一致性和可擴展性特征
我們從可擴展性最強、可用性最高的方法到可擴展性最差、可用性最低的順序來評估各種方法。
高:并行管道和協同式
如果你的步驟在時間上是解耦的,那么采用并行管道的方法來運行是很合適的。有可能你只能在系統的某些部分使用這種模式,而不是在整個系統中。接下來,假設步驟間存在時間方面的耦合性,特定的操作和服務必須要在其他的服務前執行,那么你可以考慮采用協同式的方式。借助協同式的服務,我們可以創建一個可擴展的、事件驅動的架構,在這里消息會通過一個去中心化的協同化過程在服務和服務之間流動。在這種情況下,使用 Debezium 和 Apache Kafka 的發件箱模式實現(如 Red Hat OpenShift Streams for Apache Kafka)特別有趣,而且越來越受歡迎
中等:編排式和兩階段提交
如果協同式模式不是很合適,你需要一個負責協調和決策的中心點,那么可以考慮采用編排式模式。這是一個流行的架構,有基于標準的和自定義的開源實現。基于標準的實現可能會強迫你使用某些事務語義,而自定義的編排式實現則允許你在所需的數據一致性和可擴展性之間進行權衡。
低:模塊化單體
如果你沿著圖示再往左走的話,那么很可能你對數據一致性有非常強烈的需求,而且對它所需的重大權衡有充分的思想準備。在這種情況下,針對特定數據源,通過兩階段提交的分布式事務是可行的,但是在專門為可擴展性和高度可用性設計的動態云環境中,它很難可靠地實現。如果是這樣的話,那么你可以直接采用比較老式的模塊化單體方式,同時伴以從微服務運動中學到的實踐。這種方式可以確保最高的數據一致性,但代價是運行時和數據源的耦合。
7
結論??
在具有數十個服務的大型分布式系統中,并不會有一個適用于所有場景的方式,我們需要將其中的幾個方法結合起來,應用于不同的環境中。我們可能會將幾個服務部署在一個共享的運行時上,以滿足對數據一致性的特殊需求。我們可能會選擇兩階段的提交來與支持 JTA 的遺留系統進行集成。我們可能會編排復雜的業務流程,并讓其余的服務使用協同式模式和并行處理。總而言之,你選擇什么策略并不重要,重要的是基于正確的原因,精心選擇一個策略,并執行它。
作者 |?Bilgin Ibryam
出品?|?RedHat?博客網站
想知道更多?掃描下面的二維碼關注我后臺回復"技術",加入技術群 后臺回復“k8s”,可領取k8s資料【精彩推薦】ClickHouse到底是什么?為什么如此牛逼!
原來ElasticSearch還可以這么理解
面試官:InnoDB中一棵B+樹可以存放多少行數據?
架構之道:分離業務邏輯和技術細節
星巴克不使用兩階段提交
面試官:Redis新版本開始引入多線程,談談你的看法?
喜馬拉雅自研網關架構演進過程
收藏:存儲知識全面總結
微博千萬級規模高性能高并發的網絡架構設計
總結
以上是生活随笔為你收集整理的微服务下分布式事务模式的详细对比的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 为什么我建议你现在学Vue3?
- 下一篇: 哦豁?这个程序员…… 有、东西!