訊息最終一致性最易用的新架構

dongfuye發表於2022-01-17

概述

跨服務更新資料是應用開發常見的任務,如果一些關鍵資料對一致性的要求較高,而業務上也不需要支援回滾的話,那麼通常就會採用本地訊息表的方式來保證最終一致。許多公司在處理跨服務更新資料一致性問題時,都會先引入本地訊息表,後續隨著業務場景複雜化,再引入更多的事務模式

本文提出的二階訊息,是一種新模式,新架構,優雅的解決了訊息最終一致性的問題,帶來更加簡易快捷的開發新體驗。

下面我們以跨行轉賬作為例子,給大家詳解這種新架構。業務場景介紹如下:

我們需要跨行從A轉給B 30元,我們先進行可能失敗的轉出操作TransOut,即進行A扣減30元。如果A因餘額不足扣減失敗,那麼轉賬直接失敗,返回錯誤;如果扣減成功,那麼進行下一步轉入操作,因為轉入操作沒有餘額不足的問題,可以假定轉入操作一定會成功。

採用新架構開發

新架構基於分散式事務管理器dtm

完成上述任務的核心程式碼如下所示:

        msg := dtmcli.NewMsg(DtmServer, gid).            Add(busi.Busi+"/TransIn", &TransReq{Amount: 30})        err := msg.PrepareAndSubmit(busi.Busi+"/QueryPreparedB", db, func(tx *sql.Tx) error {            return busi.SagaAdjustBalance(tx, busi.TransOutUID, -req.Amount, "SUCCESS")        }

這部分程式碼中

  • 首先生成一個DTM的msg全域性事務,傳遞dtm的伺服器地址和全域性事務id

  • 給msg新增一個分支業務邏輯,這裡的業務邏輯為餘額轉入操作TransIn,然後帶上這個服務需要傳遞的資料,金額30元

  • 然後呼叫msg的PrepareAndSubmit,這個函式保證業務成功執行和msg全域性事務提交,要麼同時成功,要麼同時失敗

  1. 第一個引數為回查URL,詳細含義稍後說

  2. 第二個引數為sql.DB,是業務訪問的資料庫物件

  3. 第三個引數是業務函式,我們這個例子中的業務是給A扣減30元餘額

成功流程

PrepareAndSubmit是如何保證業務成功執行與msg提交的原子性的呢?請看如下的時序圖:

訊息最終一致性最易用的新架構

一般情況下,時序圖中的7個步驟會正常完成,整個業務按照預期進行,全域性事務完成。這裡面有個新的內容需要解釋一下,就是msg的提交是按照兩個階段發起的,第一階段呼叫Prepare,第二階段呼叫Commit,DTM收到Prepare呼叫後,不會呼叫分支事務,而是等待後續的Submit。只有收到了Submit,開始分支呼叫,最終完成全域性事務。

提交後當機流程

在分散式系統中,各類的當機和網路異常都是需要考慮的,下面我們來看看可能發生的問題:

首先我們要達到的最重要目標是業務成功執行和msg事務是原子操作,因此首先看如果在業務完成提交後,傳送Submit訊息前出現了當機故障會怎麼樣,新架構如何保證原子性?

我們來看看這種情況下的時序圖:

圖片

如果在本地事務提交之後,在傳送Submit前,出現了程式Crash或者機器當機會怎麼樣?這個時候DTM會在一定超時時間之後,取出只Prepare但未Submit的msg事務,呼叫msg事務指定的回查服務。

您的回查服務邏輯,不需要手動編寫,只需要按照如下程式碼進行呼叫即可:

    app.GET(BusiAPI+"/QueryPreparedB", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {        return MustBarrierFromGin(c).QueryPrepared(dbGet())    }))

這個回查函式,會到表裡面查詢,本地事務是否提交了:

  • 已提交: 返回成功,dtm進行下一步子事務呼叫

  • 已回滾: 返回失敗,dtm終止全域性事務,不再進行子事務呼叫

  • 進行中: 這個回查會等待最終結果,然後按照前面的已提交/已回滾的情況處理

提交前當機流程

我們來看看本地事務被回滾的時序圖:圖片

如果在dtm收到Prepare呼叫後,AP在事務提交前,遇見故障當機,那麼資料庫會檢測到AP的連線斷開,自動回滾本地事務。

後續dtm輪詢取出已經超時的,只Prepare但沒有Submit的全域性事務,進行回查。回查服務發現本地事務已回滾,返回結果給dtm。dtm收到已回滾的結果後,將全域性事務標記為失敗,並結束該全域性事務。

易用性

採用新架構處理一致性問題,僅需要:

  • 定義好本地業務邏輯,指定下一步處理的服務即可

  • 定義QueryPrepared處理服務,複製貼上例子程式碼即可。

然後我們看看其他方案情況

二階訊息 vs 本地訊息表

上述的問題也可以採用本地訊息表方案(方案詳情參考分散式事務最經典的七種解決方案),來保證資料的最終一致性。如果採用本地訊息表,需要的工作包括:

  • 在本地事務中執行本地業務邏輯,將訊息插入訊息表並最後提交

  • 編寫輪詢任務,將本地訊息表的訊息,發給訊息佇列

  • 消費訊息,並將訊息發給相應的處理服務

兩者對比,二階訊息有以下優點:

  • 無需學習或維護任何訊息佇列

  • 不需要處理輪詢任務

  • 不需要消費訊息

二階訊息 vs 事務訊息

上述的問題也可以採用RocketMQ的事務訊息方案(方案詳情參考分散式事務最經典的七種解決方案),來保證資料的最終一致性。如果採用本地訊息表,需要的工作包括:

如果採用事務訊息,需要的工作包括:

  • 開啟本地事務,傳送半訊息,提交事務,傳送commit訊息

  • 消費超時的半訊息,對於收到的超時半訊息,查詢本地資料庫,然後進行Commit/Rollback

  • 消費已提交的訊息,並將訊息傳送給處理服務

兩者對比,二階訊息有以下優點:

  • 無需學習或維護任何訊息佇列

  • 本地事務與傳送訊息之間的複雜操作需要手動處理,一不小心,可能出現bug。而二階訊息則是全自動處理

  • 不需要消費訊息

二階訊息在二階段提交方面,與RocketMQ的事務訊息相似,是受到RocketMQ的事務訊息啟發後提出的新架構。二階訊息的命名,不再複用RocketMQ的事務訊息,主要是因為二階訊息在架構上有很大的改變,而另一方面,在分散式事務的上下文中,使用”事務訊息“這個名字,容易帶來理解上的混淆。

更多的優點

對比於前面講述的佇列方案,二階訊息還有很多額外的優點:

  • 二階訊息整個暴露的介面,完全與佇列無關,只跟實際的業務和服務呼叫相關,對開發人員更加友好

  • 二階訊息不用考慮訊息佇列訊息堆積及其他故障等問題,因為二階訊息只依賴dtm,開發人員可以認為dtm與系統中其他一個普通無狀態服務一樣,只依賴背後的儲存 Mysql/Redis。

  • 訊息佇列是非同步的,而二階訊息同時支援非同步和同步,預設非同步,只需要開啟msg.WaitResult=true,那麼可以同步等待下游服務完成

  • 二階訊息還支援同時指定多個下游服務

二階訊息未來展望

二階訊息能夠大幅降低訊息最終一致性解決方案的難度,獲得廣泛的應用。未來dtm會考慮新增後臺,允許動態指定下游服務,提供更高的靈活性。如果您原先採用訊息佇列來做服務解耦,那麼這個dtm的後臺,允許你直接指定某個訊息的多個接收函式,無需編寫訊息消費者,帶來更加簡單、直觀、易用的開發體驗。

回查原理剖析

前面的時序圖中,以及介面中都出現了回查服務,在二階訊息中,是複製貼上程式碼自動處理的,而RocketMQ的事務訊息,則是手動處理的。那麼自動處理的原理是什麼?

要進行回查,首先要在業務資料庫例項中,建立一張獨立的表,裡面儲存全域性事務id。在處理業務事務時,會把gid寫入到這張表。

當我們用gid回查時,如果能夠在表中查到gid,那麼說明本地事務已提交,這樣就可以返回dtm,告知本地事務已提交。

當我們用gid回查時,沒有在表中查到gid,那麼說明本地事務未提交,此時可能的結果是兩個,一是事務還在進行中,二是事務已回滾。我查了許多關於RocketMQ的資料,未找到有效的解決方案。搜到所有解決方案是,如果未查到結果,那麼什麼都不做,等待下一次回查,如果2分鐘或者更久的回查,一直都是查不到的,那麼認為本地事務已回滾。

上述這種方案有很大的問題:

  • 兩分鐘還查不到gid,並不能認為本地事務已回滾,極端情況下,可能發生資料庫故障(例如程式或磁碟卡住了),持續時間超過2分鐘,最後資料又提交了,那麼這個時候,資料就不是最終一致了,就需要人工介入處理了

  • 如果一個本地事務,已經回滾了,但是回查操作,還會在兩分鐘之內,按照10s左右的時間間隔,不斷的進行輪詢,會給伺服器造成不必要的壓力

而dtm的二階訊息方案,則徹底解決了這部分的問題。dtm的二階訊息工作過程如下:

  1. 在處理本地事務時,會將gid插入到dtm_barrier.barrier表中,同時帶上插入原因為committed。該表有一個唯一索引,主要欄位為gid。

  2. 當進行回查時,二階訊息的操作不是直接查gid是否存在,而是再insert ignore一條帶有相同gid的資料,同時帶上插入原因為rollbacked。此時如果表中如果已有gid的記錄,那麼新的插入操作就會被ignore,否則資料會被插入。

  3. 然後再用gid查詢表中的記錄,如果查到記錄的reason為committed,那麼說明本地事務已提交;如果查到記錄的reason為rollbacked,那麼說明本地事務已回滾。

那麼對比RocketMQ回查時的常見方案,二階訊息是如何區分出進行中和已回滾呢?其中的技巧在於回查時插入的資料,如果回查時,資料庫的事務還在進行中,那麼插入操作就會被進行中的事務阻塞,因為插入操作會等待事務中持有的鎖。如果插入操作正常返回,那麼資料庫中的本地事務,必定已結束,必然是已提交或已回滾。

下面給大家留一個問題:二階訊息的操作3能否省略,能否只根據步驟2的插入是否成功,來判斷是否已回滾?歡迎大家留言討論

普通訊息

二階訊息不僅可以替換本地訊息表方案,也能夠替換普通訊息方案。如果直接呼叫Submit,那麼就與普通訊息方案近似,但是提供了更靈活簡單的介面。

假設一個這樣的應用場景,介面上有一個參加活動的按鈕,如果參加活動,會贈與兩本電子書的永久許可權。這種情況下,可以再這個按鈕的服務端中,類似這樣處理:

msg := dtmcli.NewMsg(DtmServer, gid).    Add(busi.Busi+"/AuthBook", &Req{UID: 1, BookID: 5}).    Add(busi.Busi+"/AuthBook", &Req{UID: 1, BookID: 6})err := msg.Submit()

這種方式也提供了非同步介面,而不用依賴訊息訊息佇列。在微服務的許多場景中,可以替換原有的非同步訊息架構。

小結

本文提出的二階訊息,介面簡潔優雅,帶來了比本地訊息表和 Rocket 事務訊息更簡單的架構,可以幫助大家更好的解決無需回滾的資料一致性問題。

專案地址

關於分散式事務更多的理論知識與實踐,可以訪問以下專案和公眾號:

github.com/dtm-labs/dtm ,歡迎訪問,並star支援我們。

關注【分散式事務】公眾號,獲取更多分散式事務相關知識,同時可以加入我們的社群

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章