萬字長文漫談分佈式事務實現原理

1 分佈式事務場景

1.1 事務核心特性

在聊分佈式事務之前,我們先理清楚有關於 “事務” 的定義.

事務 Transaction,是一段特殊的執行程序,其需要具備如下四項核心性質:

上面四項核心要素被稱爲事務的 acid 四大特性. 當事務的影響範圍侷限在一個關係型數據庫範圍內時,很多時候上述四項性質是能夠水到渠成地得到實現的,但是倘若事務涉及修改的對象是跨數據庫甚至跨服務跨存儲組件時,這個問題就開始變得複雜且有趣了,也就正式邁入我們今天所重點探討的 “分佈式事務” 領域的問題.

1.2 分佈式事務場景問題

下面我們通過一個常見的場景問題引出有關於分佈式事務的話題.

假設我們在維護一個電商後臺系統,每當在處理一筆來自用戶創建訂單的請求時,需要執行兩步操作:

從業務流程上來說,這個流程需要保證具備事務的原子性,即兩個操作需要能夠一氣呵成地完成執行,要麼同時成功,要麼同時失敗,不能夠出現數據狀態不一致的問題,比如發生從用戶賬戶扣除了金額但商品庫存卻扣減失敗的問題.

然而從技術流程上來講,兩個步驟是相對獨立的兩個操作,底層涉及到的存儲介質也是相互獨立的,因此無法基於本地事務的實現方式.

因此,這本質上就是我們今天所要談及的分佈式事務問題.

分佈式事務的實現難度是很高的,但是不用慌,辦法總比困難多,在業界針對於分佈式事務早已提出一套被廣泛認可應用的解決方案,這部分內容將在本文第 2、3 章內容中詳細展開介紹. 在這裏,我們需要明確所謂分佈式事務的實現中,其中所謂的數據狀態一致性是需要做出妥協的:

2 事務消息方案

首先,一類偏狹義的分佈式事務解決方案是基於消息隊列 MessageQueue(後續簡稱 MQ)實現的事務消息 Transaction Message.

2.1 RocketMQ 簡介

RocketMQ 是阿里基於 java 實現並託管於 apache 基金會的頂級開源消息隊列組件,其中事務消息 TX Msg 也是 RocketMQ 現有的一項能力. 本章將主要基於 RocketMQ 針對事務消息的實現思路展開介紹.

RocketMQ github 地址:https://github.com/apache/rocketmq

 有關於 RocketMQ 中 TX Msg 的有關介紹,可以參見官方文檔:https://rocketmq.apache.org/docs/4.x/producer/06message5/

在本章中,我亦會根據自己的個人理解,對這個流程加以描述和潤色.

2.2 基於 MQ 實現分佈式事務

我們知道在 MQ 組件中,通常能夠爲我們保證的一項能力是:投遞到 MQ 中的消息能至少被下游消費者 consumer 消費到一次,即所謂的 at least once 語義.

基於此,MQ 組件能夠保證消息不會在消費環節丟失,但是無法解決消息的重複性問題. 因此,倘若我們需要追求精確消費一次的目標,則下游的 consumer 還需要基於消息的唯一鍵執行冪等去重操作,在 at least once 的基礎上過濾掉重複消息,最終達到 exactly once 的語義.

依賴於 MQ 中 at least once 的性質,我們簡單認爲,只要把一條消息成功投遞到 MQ 組件中,它就一定被下游 consumer 端消費端,至少不會發生消息丟失的問題.

倘若我們需要執行一個分佈式事務,事務流程中包含需要在服務 A 中執行的動作 I 以及需要在服務 B 中執行的動作 II,此時我們可以基於如下思路串聯流程:

對上述流程進行總結,其具備如下優勢:

與之相對的,上述流程也具備如下幾項侷限性:

在本章談及的事務消息實現方案中:

2.3 本地事務 + 消息投遞

2.2 小節中,聊到的服務 A 所要執行的操作分爲兩步:本地事務 + 消息投遞. 這裏我們需要如何保證這兩個步驟的執行能夠步調統一呢,下面不妨一起來推演一下我們的流程設計思路:

首先,這兩個步驟在流程中一定會存在一個執行的先後順序,我們首先來思考看看不同的組織順序可能會分別衍生出怎樣的問題:

組合 I 的優勢:不會出現消息投遞成功而本地事務執行失敗的情況. 這是因爲在本地事務執行失敗時,可以主動熔斷消息投遞的動作.

組合 I 的劣勢:可能出現本地事務執行成功而消息投遞失敗的問題. 比如本地事務成功後,想要嘗試執行消息投遞操作時一直出現失敗,最終消息無法發出. 此時由於本地事務已經提交,要執行回滾操作會存在着很高的成本.

組合 II 的優勢:不會出現本地事務執行成功而消息投遞失敗的問題. 因爲在消息投遞失敗時,可以不開啓本地事務的執行操作.

組合 II 的劣勢:可能出現消息投遞成功而本地事務執行失敗問題. 比如消息投遞成功後,本地事務始終無法成功執行,而消息一經發出,就已經覆水難收了.

捋完上述兩種流程中存在的問題後,一種比較容易想到的實現思路是:基於本地事務包裹消息投遞操作的實現方式,對應執行步驟如下:

這個流程乍一看沒啥毛病,重複利用了本地事務回滾的能力,解決了本地修改操作成功、消息投遞失敗後本地數據修正成本高的問題.

然而,這僅僅是表現. 上述流程實際上是經不住推敲的,其中存在三個致命問題:

2.4 事務消息原理

2.3 小節聊完後,我猜測大家心頭多少產生一點點窒息感,同時也對我們今天所探討的分佈式事務這個話題產生了更充分的敬畏之心.

下面,我們就正式開始介紹解決這個問題的正解——事務消息 Transaction Message.

我們以 RocketMQ 中 TX Msg 的實現方案爲例展開介紹. 首先拋出結論,TX Msg 能保證我們做到在本地事務執行成功的情況下,後置的投遞消息操作能以接近百分之百的概率被髮出. 其實現的核心流程爲:

在 TX Msg 的實現流程中,能夠保證 2.3 小節中談及的各種 badcase 都能被很好地消化:

RocketMQ 中半事務消息輪詢流程示意如下:

最後,我們再回過頭把 RocketMQ TX Msg 的使用交互流程總結梳理如下:

2.5 事務消息侷限性

在本章一開始時,我就有提到,事務消息 TX Msg 只是一類偏狹義的分佈式事務解決方案. 因此我們在使用這項能力時,需要在心中需要有一個明確的認知和預期.

現在我們就來總結梳理一下,TX Msg 中存在的幾項侷限性:

關於上面第二點,我們再展開談幾句. 我們知道,並非所有動作都能通過簡單的重試機制加以解決.

打個比方,倘若下游是一個庫存管理系統,而對應商品的庫存在事實上已經被扣減爲 0,此時無論重試多少次請求都是徒然之舉,這就是一個客觀意義上的失敗動作.

而遵循正常的事務流程,後置操作失敗時,我們應該連帶前置操作一起執行回滾,然而這部分能力在 TX Msg 的主流程中並沒有予以體現.

要實現這種事務的逆向回滾能力,就必然需要構築打通一條由下游逆流而上回調上游的通道,這一點並不屬於 TX Msg 探討的範疇.

我相信,能跟着我的思路一起走到這裏的同學,一定都是對技術抱着主動求知、精益求精的積極態度,針對於 TX Msg 中存在的問題,心中一定是迫切地希望能尋求到一個合適的解決方案. 那麼請大家不用着急,有關於這部分內容,我們就在第 3 章內容中見分曉.

3 TCC 實現方案

3.1 TCC 概念簡述

TCC,全稱 Try-Confirm-Cancel,指的是將一筆狀態數據的修改操作拆分成兩個階段:

更多有關於 TCC 的概念介紹,可以參見這篇文檔:https://www.bytesoft.org/tcc-intro/ . 個人覺得內容還是比較優質.

3.2 TCC 宏觀架構

下面是 TCC 分佈式事務實現方案的整體架構,大家可以先整體瀏覽一下存個印象,下面我們會逐一展開介紹:

在 TCC 分佈式事務架構中,包含三類角色:

3.3 TCC 案例分析

前面我們大致捋了一遍 TCC 的整體架構,但幹聊概念還是過於抽象,理解認知上不夠形象和立體,下面我們引入一個具體的分佈式事務場景問題,並通過 TCC 架構加以實現,幫助大家進一步提高對 TCC 分佈式事務方案的感性認識.

現在假設我們需要維護一個電商後臺系統,需要處理來自用戶的支付請求. 每當有一筆支付請求到達,我們需要執行下述三步操作,並要求其前後狀態保持一致性:

上面這三步操作分別需要對接訂單、賬戶、庫存三個不同的子模塊,底層的狀態數據是基於不同的數據庫和存儲組件實現的,並且我們這套後臺系統是基於當前流行的微服務架構實現的,這三子個模塊本身對應的就是三個相互獨立的微服務,因此如何實現在一筆支付請求處理流程中,使得這三筆操作對應的狀態數據始終保持高度一致性,就成了一個非常具有技術挑戰性的問題.

下面我們就擼起袖子開整.

首先,我們基於 TCC 的設計理念,將訂單模塊、賬戶模塊、庫存模塊分別改造成三個 TCC Component,每個 Component 對應需要暴露出 Try、Confirm、Cancel 三個 API,對應於凍結資源、確認更新資源、回滾解凍資源三個行爲.

同時,爲了能夠簡化後續 TX Manager 和 Application 之間的交互協議,每個 TCC Component 會以插件的形式提前註冊到 TX Manager 維護的組件市場 Component Market 中,並提前聲明好一個全局唯一鍵與之進行映射關聯.

由於每個 TCC Component 需要支持 Try 接口的鎖定操作,因此其中維護的數據需要在明細記錄中拆出一個用於標識 “凍結” 狀態的標籤,或者在狀態機中拆出一個 “凍結” 狀態.

最終在第二階段的 Confirm 或者 Cancel 請求到達時,再把 ”凍結 “狀態調整爲” 成功 “ 或者 ” 失敗“ 的終態.

下面描述一下,基於 TCC 架構實現後,對應於一次支付請求的分佈式事務處理流程:

在上述流程中,有一個很重要的環節需要補充說明:

首先,TCC 本質上是一個兩階段提交(Two Phase Commitment Protocol,2PC)的實現方案,分爲 Try 和 Confirm/Cancel 的兩個階段:

針對於這個場景,TCC 架構中採用的解決方案是:在第二階段中,TX Manager 輪詢重試 + TCC Component 冪等去重. 通過這兩套動作形成的組合拳,保證 Confirm/ Cancel 操作至少會被 TCC Component 執行一次.

首先,針對於 TX Manager 而言:

需要注意,在 TX Manager 輪詢重試的流程中,針對下游 TCC Component 的 Confirm 和 Cancel 請求只能保證 at least once 的語義,換句話說,這部分請求是可能出現重複的.

因此,在下游 TCC Component 中,需要在接收到 Confirm/Cancel 請求時,執行冪等去重操作. 冪等去重操作需要有一個唯一鍵作爲去重的標識,這個標識鍵就是 TX Manager 在開啓事務時爲其分配的全局唯一的 Transaction ID,它既要作爲這項事務在事務日誌表中的唯一鍵,同時在 TX Manager 每次向 TCC Component 發起請求時,都要攜帶上這筆 Transaction ID.

3.4 TX Manager 職責

通過上面這個實際案例的流程剖析後,相信大家已經對這套 TCC 架構有了一定的理解. 接下來我們再從 TX Manager 和 TCC Component 職責領域劃分的視角出發,進行一輪梳理.

首先針對於事務協調器 TX Manager,其核心要點包括:

3.5 TCC Component 職責

對於 TCC Component 而言,其需要關心和處理的工作包括:

下面針對最後一點提到的空回滾操作,進一步加以說明:

這個空回滾機制本質上是爲了解決 TCC 流程中出現的懸掛問題,下面我們舉個具體例子加以說明:

從執行邏輯上,Try 應該先於 Cancel 到達和處理,然而在事實上,由於網絡環境的不穩定性,請求到達的先後次序可能顛倒. 在這個場景中,Component A 需要保證的是,針對於同一筆事務,只要接受過對應的 Cancel 請求,之後到來的 Try 請求需要被忽略. 這就是 TCC Component 需要支持空回滾操作的原因所在.

3.6 TCC 優劣勢分析

最後我們針對 TCC 分佈式事務實現方案的優劣勢進行分析:

優勢:

劣勢:

事實上,上面提到的第二點劣勢也並非是 TCC 方案的缺陷,而是所有分佈式事務都存在的問題,由於網絡請求以及第三方系統的不穩定性,分佈式事務永遠無法達到 100% 的原子性.

4 總結

本期和大家一起分享了事務消息和 TCC 事務兩種分佈式事務實現方案的技術原理:

我個人一直秉持的觀點是——理論需要得到實踐的檢驗. 今天咱們看似聊了很多,但都是偏抽象和空洞的原理性內容. 這裏咱們提前做個預告,接下來我會基於今天聊到的這套 TCC 分佈式事務的技術方案進行落地實踐,基於 Golang 從零到一搭建出一個 TCC 分佈式事務框架,敬請期待!

目前基於 Golang 實現的 TCC 框架已於 github 開源:https://github.com/xiaoxuxiansheng/gotcc  大家走過路過,留個 star,不勝感激!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/yXl-LpuwwIbGMcHJDgx0Gw