概述
跨服務更新資料是應用開發常見的任務,如果一些關鍵資料對一致性的要求較高,而業務上也不需要支援回滾的話,那麼通常就會採用本地訊息表的方式來保證最終一致。許多公司在處理跨服務更新資料一致性問題時,都會先引入本地訊息表,後續隨著業務場景複雜化,再引入更多的事務模式
本文提出的二階訊息,是一種新模式,新架構,優雅的解決了訊息最終一致性的問題,帶來更加簡易快捷的開發新體驗。
下面我們以跨行轉賬作為例子,給大家詳解這種新架構。業務場景介紹如下:
我們需要跨行從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")
})
::: gRPC
gRPC 的接入和 HTTP 基本一樣,這裡不再贅述,有需要的讀者,可以參考dtm-labs/dtm-examples中的例子
:::
這部分程式碼中
- 首先生成一個DTM的msg全域性事務,傳遞dtm的伺服器地址和全域性事務id
- 給msg新增一個分支業務邏輯,這裡的業務邏輯為餘額轉入操作TransIn,然後帶上這個服務需要傳遞的資料,金額30元
然後呼叫msg的PrepareAndSubmit,這個函式保證業務成功執行和msg全域性事務提交,要麼同時成功,要麼同時失敗
- 第一個引數為回查URL,詳細含義稍後說
- 第二個引數為sql.DB,是業務訪問的資料庫物件
- 第三個引數是業務函式,我們這個例子中的業務是給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的二階訊息工作過程如下:
- 在處理本地事務時,會將gid插入到dtm_barrier.barrier表中,同時帶上插入原因為committed。該表有一個唯一索引,主要欄位為gid。
- 當進行回查時,二階訊息的操作不是直接查gid是否存在,而是再insert ignore一條帶有相同gid的資料,同時帶上插入原因為rollbacked。此時如果表中如果已有gid的記錄,那麼新的插入操作就會被ignore,否則資料會被插入。
- 然後再用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 事務訊息更簡單的架構,可以幫助大家更好的解決無需回滾的資料一致性問題。
專案地址
關於分散式事務更多的理論知識與實踐,可以訪問以下專案和公眾號:
https://github.com/dtm-labs/dtm ,歡迎訪問,並star支援我們。
關注【分散式事務】公眾號,獲取更多分散式事務相關知識,同時可以加入我們的社群