秒懂分佈式事務

我們首先從事務的概念開始聊起。

事務

事務的 ACID 想必大家都熟知,這其實是嚴格意義上的定義,指的是事務的實現必須具備原子性、一致性、隔離性和持久性。

不過嚴格意義上的事務很難達到,像我們熟知的數據庫就有各種隔離級別,隔離級別越高性能越低,所以往往我們都會從中找到屬於自己的平衡,不會遵循嚴格意義上的事務。

並且在我們平日的談論中,所謂的事務往往簡單的指代一系列的操作全部執行成功,或者全部失敗,不會出現一些成功一些失敗的情形。

清晰了平日我們對事務的定義之後,再來看看什麼是分佈式事務。

分佈式事務

由於互聯網的快速發展,以往的單體架構頂不住這麼多的需求,這麼複雜的業務,這麼大的流量。

單體架構的優勢在於前期快速搭建、快速上線,並且方法和模塊之間都是內部調用,沒有網絡的開銷更加的高效。

從某方面來說部署也方便,畢竟就一個包,扔上去。

不過隨着企業的發展,業務的複雜度越來越高,內部耦合極其嚴重,導致牽一髮而動全身,開發不易,測試不易。

並且無法根據熱點服務進行動態的伸縮,比如商品服務訪問量特別大,如果是單體架構的話我們只能把整個應用複製多份集羣部署,浪費資源。

因此拆分勢在必行,微服務架構就這麼來了。

拆分之後服務之間的邊界就清晰了,每個服務都能獨立地運行,獨立地部署,所以能以服務級別彈性伸縮了。

服務之間的本地調用變成了遠程調用,鏈路更長了,一次調用的耗時更長了,但是總體的吞吐量更大了。

不過拆分之後還會引入其他複雜度,比如服務鏈路的監控、整體的監控、容錯措施、彈性伸縮等等運維監控的問題,還有像分佈式事務、分佈式鎖跟業務息息相關的問題等。

往往解決了一個痛點又會引入別的痛點,所以架構的演進都是權衡的結果,就看你們的系統更能忍受哪種痛點了。

而今天我們談及的就是分佈式事務這個痛點。

分佈式事務是由多個本地事務組成的,分佈式事務跨越了多設備,之間又經歷的複雜的網絡,可想而知想要實現嚴格的事務道路阻且長。

單機版事務都不會嚴格遵守事務的嚴格實現,更別說分佈式事務了,所以在現實情況下我們只能實現殘缺版的事務。

在明確了事務和分佈式事務之後,我們就先來看看常見的分佈式事務方案:2PC、3PC、TCC、本地消息、事務消息。

2PC

2PC,Two-phase commit protocol,即兩階段提交協議。它引入了一個事務協調者角色,來管理各個參與者(就是各數據庫資源)。

整體分爲兩個階段,分別是準備階段和提交 / 回滾階段。

我們先來看看第一個階段,即準備階段。

由事務協調者給每個參與者發送準備命令,每個參與者收到命令之後會執行相關事務操作,你可以認爲除了事務的提交啥都做了。

然後每個參與者會返回響應告知協調者自己是否準備成功。

協調者收到每個參與者的響應之後就進入第二階段,根據收集的響應,如果有一個參與者響應準備失敗那麼就向所有參與者發送回滾命令,反之發送提交命令。

這個協議其實很符合正常的思維,就像我們大學上課點名的時候,其實老師就是協調者的角色,我們都是參與者。

老師一個一個的點名,我們一個一個的喊到,最後老師收到所有同學的到之後就開始了今天的講課。

而和點名有所不同的是,老師發現某幾個學生不在還是能繼續上課,而我們的事務可不允許這樣。

事務協調者在第一階段未收到個別參與者的響應,則等待一定時間就會認爲事務失敗,會發送回滾命令,所以在 2PC 中事務協調者有超時機制。

我們再來分析一下 2PC 的優缺點。

2PC 的優點是能利用數據庫自身的功能進行本地事務的提交和回滾,也就是說提交和回滾實際操作不需要我們實現,不侵入業務邏輯由數據庫完成,在之後講解 TCC 之後相信大家對這點會有所體會。

2PC 主要有三大缺點:同步阻塞、單點故障和數據不一致問題。

同步阻塞

可以看到在第一階段執行了準備命令後,我們每個本地資源都處於鎖定狀態,因爲除了事務的提交之外啥都做了。

所以這時候如果本地的其他請求要訪問同一個資源,比如要修改商品表 id 等於 100 的那條數據,那麼此時是被阻塞住的,必須等待前面事務的完結,收到提交 / 回滾命令執行完釋放資源後,這個請求才能得以繼續。

所以假設這個分佈式事務涉及到很多參與者,然後有些參與者處理又特別複雜,特別慢,那麼那些處理快的節點也得等着,所以說效率有點低。

單點故障

可以看到這個單點就是協調者,如果協調者掛了整個事務就執行不下去了。

如果協調者在發送準備命令前掛了還行,畢竟每個資源都還未執行命令,那麼資源是沒被鎖定的。

可怕的是在發送完準備命令之後掛了,這時候每個本地資源都執行完處於鎖定狀態了,都杵着了,這就很僵硬了,如果是某個熱點資源都阻塞了,這估計就要 GG 了。

數據不一致問題

因爲協調者和參與者之間的交流是經過網絡的,而網絡有時候就會抽風的或者發生局部網絡異常。

那麼就有可能導致某些參與者無法收到協調者的請求,而某些收到了。比如是提交請求,然後那些收到命令的參與者就提交事務了,此時就產生了數據不一致的問題。

小結一下 2PC

至此我們來先小結一些 2PC ,它是一個同步阻塞的強一致性兩階段提交協議,分別是準備階段和提交 / 回滾階段。

2PC 的優勢在於對業務沒有侵入,可以利用數據庫自身機制來進行事務的提交和回滾。

它的缺點:是一個同步阻塞協議,會導致高延遲和性能的下降,並且存在協調者單點故障問題,極端情況下會有數據不一致的問題。

當然這只是協議,具體的落地還是可以變通了,比如協調者單點問題,我就搞個主從來實現協調者,對吧。

分佈式數據庫的 2PC 改進模型

可能有些人對分佈式數據庫不熟悉,沒有關係,我們主要學的是思想,看看人家的思路。

我簡單的講下 Percolator 模型,它是基於分佈式存儲系統 BigTable 建立的模型,BigTable 是啥也不清楚的同學沒有關係影響不大。

還是拿轉賬的例子來說,我現在有 200 塊錢,你現在有 100 塊錢,爲了突出重點我也不按正常的結構來畫這個表。

然後我要轉 100 塊給你。

此時事務管理器發起了準備請求,然後我賬上的錢就少了,你賬上的錢就多了,而且事務管理器還記錄下這次操作的日誌。

此時的數據還是私有版本,別的事務是讀不到的,簡單的理解 Lock 上有值就還是私有的。

可以看到我的記錄 Lock 標記的是 PK,你的記錄標記的是指向我的記錄指針,這個 PK 是隨機選擇的。

然後事務管理器會向被選擇作爲 PK 的那條記錄發起提交指令。

此時就會把我的記錄的鎖給抹去了,這等於我的記錄不再是私有版本了,別的事務就都能訪問了。

那你的記錄上還有鎖啊?不用更新嗎?

嘿嘿不需要及時更新,因爲訪問你的這條記錄的時候會去根據指針找我的那個記錄,發現記錄已經提交了所以你的記錄就可以被訪問了。

有人說這效率不就差了,每次都要去找一次,別急。

後臺會有個線程來掃描,然後更新把鎖記錄給去了。

這不就穩了嘛。

相比於 2PC 的改進

首先 Percolator 在提交階段不需要和所有的參與者交互,主需要和一個參與者打交道,所以這個提交是原子的!解決了數據不一致問題。

然後事務管理器會記錄操作日誌,這樣當事務管理器掛了之後選舉的新事務管理器就可以通過日誌來得知當前的情況從而繼續工作,解決了單點故障問題。

並且 Percolator 還會有後臺線程,會掃描事務狀況,在事務管理器宕機之後會回滾各個參與者上的事務。

可以看到相對於 2PC 還是做了很多改進的,也是巧妙的。

其實分佈式數據庫還有別的事務模型,不過我也不太熟悉,就不多嗶嗶了,有興趣的同學可以自行了解。

還是挺能拓寬思想的。

XA 規範

讓我們再回來 2PC,既然說到 2PC 了那麼也簡單的提一下 XA 規範,XA 規範是基於兩階段提交的,它實現了兩階段提交協議。

在說 XA 規範之前又得先提一下 DTP 模型,即 Distributed Transaction Processing,這模型規範了分佈式事務的模型設計。

而 XA 規範又約束了 DTP 模型中的事務管理器(TM) 和資源管理器(RM)之間的交互,簡單的說就是你們兩之間要按照一定的格式規範來交流!

我們先來看下 XA 約束下的 DTP 模型。

簡單的說就是 AP 通過 TM 來定義事務操作,TM 和 RM 之間會通過 XA 規範進行通信,執行兩階段提交,而 AP 的資源是從 RM 拿的。

從模型上看有三個角色,而實際實現可以由一個角色實現兩個功能,比如 AP 來實現 TM 的功能,TM 沒必要抽出來單獨部署。

MySQL XA

知曉了 DTP 之後,我們就來看看 XA 在 MySQL 中是如何操作的,不過只有 InnoDB 支持。

簡單的說就是要先定義一個全局唯一的 XID,然後告知每個事務分支要進行的操作。

可以看到圖中執行了兩個操作,分別是改名字和插入日誌,等於先註冊下要做的事情,通過 XA START XID 和 XA END XID 來包裹要執行的 SQL。

然後需要發送準備命令,來執行第一階段,也就是除了事務的提交啥都幹了的階段。

然後根據準備的情況來選擇執行提交事務命令還是回滾事務命令。

基本上就是這麼個流程,不過 MySQL XA 的性能不高這點是需要注意的。

可以看到雖說 2PC 有缺點,但是還是有基於 2PC 的落地實現的,而 3PC 的引出是爲了解決 2PC 的一些缺點,但是它整體下來開銷更大,也解決不了網絡分區的問題,我也沒有找到 3PC 的落地實現。

不過我還是稍微提一下,知曉一下就行,純理論。

3PC

3PC 的引入是爲了解決 2PC 同步阻塞和減少數據不一致的情況。

3PC 也就是多了一個階段,一個詢問的階段,分別是準備、預提交和提交這三個階段。

準備階段單純就是協調者去訪問參與者,類似於你還好嗎?能接請求不。

預提交其實就是 2PC 的準備階段,除了事務的提交啥都幹了。

提交階段和 2PC 的提交一致。

3PC 多了一個階段其實就是在執行事務之前來確認參與者是否正常,防止個別參與者不正常的情況下,其他參與者都執行了事務,鎖定資源。

出發點是好的,但是絕大部分情況下肯定是正常的,所以每次都多了一個交互階段就很不划算。

然後 3PC 在參與者處也引入了超時機制,這樣在協調者掛了的情況下,如果已經到了提交階段了,參與者等半天沒收到協調者的情況的話就會自動提交事務。

不過萬一協調者發的是回滾命令呢?你看這就出錯了,數據不一致了。

還有維基百科上說 2PC 參與者準備階段之後,如果協調者掛了,參與者是無法得知整體的情況的,因爲大局是協調者掌控的,所以參與者相互之間的狀況它們不清楚。

而 3PC 經過了第一階段的確認,即使協調者掛了參與者也知道自己所處預提交階段是因爲已經得到準備階段所有參與者的認可了。

簡單的說就像加了個圍欄,使得各參與者的狀態得以統一。

小結 2PC 和 3PC

從上面已經知曉了 2PC 是一個強一致性的同步阻塞協議,性能已經是比較差的了。

而 3PC 的出發點是爲了解決 2PC 的缺點,但是多了一個階段就多了一次通訊的開銷,而且是絕大部分情況下無用的通訊。

雖說引入參與者超時來解決協調者掛了的阻塞問題,但是數據還是會不一致。

可以看到 3PC 的引入並沒什麼實際突破,而且性能更差了,所以實際只有 2PC 的落地實現。

再提一下,2PC 還是 3PC 都是協議,可以認爲是一種指導思想,和真正的落地還是有差別的。

TCC

不知道大家注意到沒,不管是 2PC 還是 3PC 都是依賴於數據庫的事務提交和回滾。

而有時候一些業務它不僅僅涉及到數據庫,可能是發送一條短信,也可能是上傳一張圖片。

所以說事務的提交和回滾就得提升到業務層面而不是數據庫層面了,而 TCC 就是一種業務層面或者是應用層的兩階段提交。

TCC 分爲指代 Try、Confirm、Cancel ,也就是業務層面需要寫對應的三個方法,主要用於跨數據庫、跨服務的業務操作的數據一致性問題。

TCC 分爲兩個階段,第一階段是資源檢查預留階段即 Try,第二階段是提交或回滾,如果是提交的話就是執行真正的業務操作,如果是回滾則是執行預留資源的取消,恢復初始狀態。

比如有一個扣款服務,我需要寫 Try 方法,用來凍結釦款資金,還需要一個 Confirm 方法來執行真正的扣款,最後還需要提供 Cancel 來進行凍結操作的回滾,對應的一個事務的所有服務都需要提供這三個方法。

可以看到本來就一個方法,現在需要膨脹成三個方法,所以說 TCC 對業務有很大的侵入,像如果沒有凍結的那個字段,還需要改表結構。

我們來看下流程。

雖說對業務有侵入,但是 TCC 沒有資源的阻塞,每一個方法都是直接提交事務的,如果出錯是通過業務層面的 Cancel 來進行補償,所以也稱補償性事務方法。

這裏有人說那要是所有人 Try 都成功了,都執行 Comfirm 了,但是個別 Confirm 失敗了怎麼辦?

這時候只能是不停地重試調失敗了的 Confirm 直到成功爲止,如果真的不行只能記錄下來,到時候人工介入了。

TCC 的注意點

這幾個點很關鍵,在實現的時候一定得注意了。

冪等問題,因爲網絡調用無法保證請求一定能到達,所以都會有重調機制,因此對於 Try、Confirm、Cancel 三個方法都需要冪等實現,避免重複執行產生錯誤。

空回滾問題,指的是 Try 方法由於網絡問題沒收到超時了,此時事務管理器就會發出 Cancel 命令,那麼需要支持 Cancel  在未執行 Try 的情況下能正常的 Cancel。

懸掛問題,這個問題也是指 Try 方法由於網絡阻塞超時觸發了事務管理器發出了 Cancel 命令,但是執行了 Cancel 命令之後 Try 請求到了,你說氣不氣。

這都 Cancel 了你來個 Try,對於事務管理器來說這時候事務已經是結束了的,這凍結操作就被 “懸掛” 了,所以空回滾之後還得記錄一下,防止 Try 的再調用。

TCC 變體

上面我們說的是通用型的 TCC,它需要改造以前的實現,但是有一種情況是無法改造的,就是你調用的是別的公司的接口。

沒有 Try 的 TCC

比如坐飛機需要換乘,換乘的又是不同的航空公司,比如從 A 飛到 B,再從 B 飛到 C,只有 A - B 和 B - C 都買到票了纔有意義。

這時候的選擇就沒得 Try 了,直接調用航空公司的買票操作,當兩個航空公司都買成功了那就直接成功了,如果某個公司買失敗了,那就需要調用取消訂票接口。

也就是在第一階段直接就執行完整個業務操作了,所以要重點關注回滾操作,如果回滾失敗得有提醒,要人工介入等。

這其實就是 TCC 的思想。

異步 TCC

這 TCC 還能異步?其實也是一種折中,比如某些服務很難改造,並且它又不會影響主業務決策,也就是它不那麼重要,不需要及時的執行。

這時候可以引入可靠消息服務,通過消息服務來替代個別服務來進行 Try、Confirm、Cancel 。

Try 的時候只是寫入消息,消息還不能被消費,Confirm 就是真正發消息的操作,Cancel 就是取消消息的發送。

這可靠消息服務其實就類似於等下要提到的事務消息,這個方案等於糅合了事務消息和 TCC。

TCC 小結

可以看到 TCC 是通過業務代碼來實現事務的提交和回滾,對業務的侵入較大,它是業務層面的兩階段提交,。

它的性能比 2PC 要高,因爲不會有資源的阻塞,並且適用範圍也大於 2PC,在實現上要注意上面提到的幾個注意點。

它是業界比較常用的分佈式事務實現方式,而且從變體也可以得知,還是得看業務變通的,不是說你要用 TCC 一定就得死板的讓所有的服務都改造成那三個方法。

Seata 的實現

首先什麼是 Seata ,摘抄官網的一段話。

Seata 是一款開源的分佈式事務解決方案,致力於提供高性能和簡單易用的分佈式事務服務。Seata 將爲用戶提供了 AT、TCC、SAGA 和 XA 事務模式,爲用戶打造一站式的分佈式解決方案。

可以看到提供了很多模式,我們先來看看 AT 模式。

AT 模式

AT 模式就是兩階段提交,前面我們提到了兩階段提交有同步阻塞的問題,效率太低了,那 Seata 是怎麼解決的呢?

AT 的一階段直接就把事務提交了,直接釋放了本地鎖,這麼草率直接提交的嘛?當然不是,這裏和本地消息表有點類似,就是利用本地事務,執行真正的事務操作中還會插入回滾日誌,然後在一個事務中提交。

這回滾日誌怎麼來的?

通過框架代理 JDBC 的一些類,在執行 SQL 的時候解析 SQL 得到執行前的數據鏡像,然後執行 SQL ,再得到執行後的數據鏡像,然後把這些數據組裝成回滾日誌。

再伴隨的這個本地事務的提交把回滾日誌也插入到數據庫的 UNDO_LOG 表中 (所以數據庫需要有一張 UNDO_LOG 表)。

這波操作下來在一階段就可以沒有後顧之憂的提交事務了。

然後一階段如果成功,那麼二階段可以異步的刪除那些回滾日誌,如果一階段失敗那麼可以通過回滾日誌來反向補償恢復。

這時候有細心的同學想到了,萬一中間有人改了這條數據怎麼辦?你這鏡像就不對了啊?

所以說還有個全局鎖的概念,在事務提交前需要拿到全局鎖(可以理解爲對這條數據的鎖),然後才能順利提交本地事務。

如果一直拿不到那就需要回滾本地事務了。

官網的示例很好,我就不自己編了,以下部分內容摘抄自 Seata 官網的示例:

此時有兩個事務,分別是 tx1、和 tx2,分別對 a 表的 m 字段進行更新操作,m 的初始值 1000。

tx1 先開始,開啓本地事務,拿到本地鎖,更新操作 m = 1000 - 100 = 900。本地事務提交前,先拿到該記錄的 全局鎖 ,本地提交釋放本地鎖。

tx2 後開始,開啓本地事務,拿到本地鎖,更新操作 m = 900 - 100 = 800。本地事務提交前,嘗試拿該記錄的 全局鎖 ,tx1 全局提交前,該記錄的全局鎖被 tx1 持有,tx2 需要重試等待全局鎖 。

可以看到 tx2 的修改被阻塞了,之後重試拿到全局鎖之後就能提交然後釋放本地鎖。

如果 tx1 的二階段全局回滾,則 tx1 需要重新獲取該數據的本地鎖,進行反向補償的更新操作,實現分支的回滾。

此時,如果 tx2 仍在等待該數據的全局鎖,同時持有本地鎖,則 tx1 的分支回滾會失敗。分支的回滾會一直重試,直到 tx2 的全局鎖等鎖超時,放棄全局鎖並回滾本地事務釋放本地鎖,tx1 的分支回滾最終成功。

因爲整個過程全局鎖在 tx1 結束前一直是被 tx1 持有的,所以不會發生髒寫的問題。

然後 AT 模式默認全局是讀未提交的隔離級別,如果應用在特定場景下,必需要求全局的讀已提交 ,可以通過 SELECT FOR UPDATE 語句的代理。

當然前提是你本地事務隔離級別是讀已提交及以上。

AT 模式小結

可以看到通過代理來無侵入的得到數據的前後鏡像,組裝成回滾日誌伴隨本地事務一起提交,解決了兩階段的同步阻塞問題。

並且利用全局鎖來實現寫隔離。

爲了總體性能的考慮,默認是讀未提交隔離級別,只代理了 SELECT FOR UPDATE 來進行讀已提交的隔離。

這其實就是兩階段提交的變體實現。

TCC 模式

沒什麼花頭,就是咱們上面分析的需要搞三個方法, 然後把自定義的分支事務納入到全局事務的管理中

我貼一張官網的圖應該挺清晰了。

Saga 模式

這個 Saga 是 Seata 提供的長事務解決方案,適用於業務流程多且長的情況下,這種情況如果要實現一般的 TCC 啥的可能得嵌套多個事務了。

並且有些系統無法提供 TCC 這三種接口,比如老項目或者別人公司的,所以就搞了個 Saga 模式,這個 Saga 是在 1987 年 Hector & Kenneth 發表的論⽂中提出的。

那 Saga 如何做呢?來看下這個圖。

假設有 N 個操作,直接從 T1 開始就是直接執行提交事務,然後再執行 T2,可以看到就是無鎖的直接提交,到 T3 發現執行失敗了,然後就進入 Compenstaing 階段,開始一個一個倒回補償了。

思想就是一開始蒙着頭幹,別慫,出了問題咱們再一個一個改回去唄。

可以看到這種情況是不保證事務的隔離性的,並且 Saga 也有 TCC 的一樣的注意點,需要空補償,防懸掛和冪等。

而且極端情況下會因爲數據被改變了導致無法回滾的情況。比如第一步給我打了 2 萬塊錢,我給取出來花了,這時候你回滾,我賬上餘額已經 0 了,你說怎麼辦嘛?難道給我還搞負的不成?

這種情況只能在業務流程上入手,我寫代碼其實一直是這樣寫的,就拿買皮膚的場景來說,我都是先扣錢再給皮膚。

假設先給皮膚扣錢失敗了不就白給了嘛?這錢你來補啊?你覺得用戶會來反饋說皮膚給了錢沒扣嘛?

可能有小機靈鬼說我到時候把皮膚給改回去,嘿嘿這種事情確實發生過,嘖嘖,被罵的真慘。

所以正確的流程應該是先扣錢再給皮膚,錢到自己袋裏先,皮膚沒給成功用戶自然而然會找過來,這時候再給他唄,雖說可能你寫出了個 BUG ,但是還好不是個白給的 BUG。

所以說這點在編碼的時候還是得注意下的。

最後

可以看到分佈式事務還是會有各種問題,一般分佈式事務的實現還是隻能達到最終一致性。

極端情況下還是得人工介入,所以做好日誌記錄很關鍵。

還有編碼的業務流程,要往利於公司的方向寫,就例如先拿到用戶的錢,再給用戶東西這個方向,切記。

在上分佈式事務之前想想,有沒有必要,能不能改造一下避免分佈式事務?

再極端一點,考慮下你的業務到底有沒有必要上事務。

巨人的肩膀

分佈式協議與算法實戰,韓健

分佈式數據庫 30 講,王磊

seata.io

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