前面我們聊了微服務的9個痛點,有些痛點沒有好的解決方案,而有些痛點剛好有一些對策,后面的幾篇文章我們就來聊聊某些痛點對應的解決方案。
本篇文章我們先解決數(shù)據(jù)一致性問題。
一、業(yè)務場景
使用微服務時,很多時候我們往往需要跨多個服務去更新多個數(shù)據(jù)庫的數(shù)據(jù),類似下圖所示的架構。

如果業(yè)務正常運轉,3 個服務的數(shù)據(jù)應該變?yōu)?a2、b2、c2,此時數(shù)據(jù)才一致。但是如果出現(xiàn)網(wǎng)絡抖動、服務超負荷或者數(shù)據(jù)庫超負荷等情況,整個處理鏈條有可能在步驟二失敗,這時數(shù)據(jù)就會變成 a2、b1、c1,當然也有可能在步驟三失敗,最終數(shù)據(jù)就會變成 a2、b2、c1,這樣數(shù)據(jù)就對不上了,即數(shù)據(jù)不一致。
在以往的架構經(jīng)歷中,因為項目非常趕,所以我們完全沒有精力處理數(shù)據(jù)一致性的問題,最終業(yè)務系統(tǒng)會出現(xiàn)很多錯誤數(shù)據(jù)。然后業(yè)務部門會發(fā)工單告知數(shù)據(jù)有問題,經(jīng)過一番檢查后,我們發(fā)現(xiàn)是分布式更新的原因?qū)е铝藬?shù)據(jù)不一致。
此時,我們不得不抽出時間針對數(shù)據(jù)一致性問題給出一個完美解決方案。于是,整個部門人員坐一起商量,并把數(shù)據(jù)一致性的問題歸類為以下 2 種場景。
二、第一種場景:實時數(shù)據(jù)不一致不要緊,保證數(shù)據(jù)最終一致性就行
因為一些服務出現(xiàn)錯誤,導致圖 1 的步驟三失敗,此時處理完請求后,數(shù)據(jù)就變成了 a2、 b2、c1,不過不要緊,我們只需保證最終數(shù)據(jù)是 a2、b2、 c2 就行。
比如:零售下單時,一般需要實現(xiàn)在商品服務中扣除商品的庫存、在訂單服務中生成一個訂單、在交易服務中生成一個交易單這三個步驟。 假設交易單生成失敗,就會出現(xiàn)庫存扣除了、訂單生成了、交易單沒生成的情況,此時我們只需保證最終交易單成功生成就行,這就是最終一致性。
三、第二種場景:必須保證實時一致性
如果上圖中的步驟二和步驟三成功了,數(shù)據(jù)就會變成 b2、c2,但是如果步驟三失敗,那么步驟一和步驟二會立即回滾,保證數(shù)據(jù)變回 a1、b1。
在以往的一個項目中,業(yè)務場景類似這樣:使用積分換折扣券時,需要實現(xiàn)扣除用戶積分、生成一張折扣券給用戶這 2 個步驟。如果我們還是使用最終一致性方案的話,有可能出現(xiàn)用戶積分扣除了而折扣券還未生成的情況,此時用戶進入賬戶一看,積分沒了也沒有折扣券,立馬就會投訴。
此時怎么辦呢?我們直接將前面的步驟回滾,并告知用戶處理失敗請繼續(xù)重試就行,這就是實時一致性。
針對以上兩種具體的場景,其具體解決方案是什么呢?下面我們一起來看看。
四、最終一致性方案
對于數(shù)據(jù)要求最終一致性的場景,實現(xiàn)思路是這樣的:
- 每個步驟完成后,生產(chǎn)一條消息給 MQ,告知下一步處理接下來的數(shù)據(jù);
- 消費者收到這條消息后,將數(shù)據(jù)處理完成后,與步驟一一樣觸發(fā)下一步;
- 消費者收到這條消息后,如果數(shù)據(jù)處理失敗,這條消息應該保留,直到消費者下次重試。
為了方便你理解這部分內(nèi)容,我梳理了一個大概的流程圖,如下圖所示:

關于上圖,詳細實現(xiàn)邏輯如下:
- 調(diào)用端調(diào)用 Service A;
- Service A 將數(shù)據(jù)庫中的 a1 改為 a2;
- Service A 生成一條步驟 2(姑且命名為 Step2)的消息給到 MQ;
- Service A 返回成功給調(diào)用端;
- Service B 監(jiān)聽 Step2 的消息,拿到一條消息。
- Service B 將數(shù)據(jù)庫中的 b1 改為 b2;
- Service B 生成一條步驟 3(姑且命名為 Step3)的消息給到 MQ;
- Service B 將 Step2 的消息設置為已消費;
- Service C 監(jiān)聽 Step3 的消息,拿到一條消息;
- Service C 將數(shù)據(jù)庫中的 c1 改為 c2;
- Service C 將 Step3 的消息設置為已消費。
接下來我們考慮下,如果每個步驟失敗了該怎么辦?
- 調(diào)用端調(diào)用 Service A。
解決方案:如果這步失敗,直接返回失敗給用戶,用戶數(shù)據(jù)不受影響。
- Service A 將數(shù)據(jù)庫中的 a1 改為 a2。
解決方案:如果這步失敗,利用本地事務數(shù)據(jù)直接回滾就行,用戶數(shù)據(jù)不受影響。
- Service A 生成一條步驟 2(姑且命名為 Step2)的消息給到 MQ。
解決方案:如果這步失敗,利用本地事務數(shù)據(jù)將步驟 2 直接回滾就行,用戶數(shù)據(jù)不受影響。
- Service A 返回成功給調(diào)用端。
解決方案:如果這步失敗,不做處理。
- Service B 監(jiān)聽 Step2 的消息,拿到一條消息。
解決方案:如果這步失敗,MQ 有對應機制,我們無須擔心。
- Service B 將數(shù)據(jù)庫中的 b1 改為 b2。
解決方案:如果這步失敗,利用本地事務直接將數(shù)據(jù)回滾,再利用消息重試的特性重新回到步驟 5 。
- Service B 生成一條步驟 3(姑且命名為 Step3)的消息給到 MQ。
解決方案:如果這步失敗,MQ 有生產(chǎn)消息失敗重試機制。要是出現(xiàn)極端情況,服務器會直接掛掉,因為 Step2 的消息還沒消費,MQ 會有重試機制,然后找另一個消費者重新從步驟 5 執(zhí)行。
- Service B 將 Step2 的消息設置為已消費。
解決方案:如果這步失敗,MQ 會有重試機制,找另一個消費者重新從步驟 5 執(zhí)行。
- Service C 監(jiān)聽 Step3 的消息,拿到一條消息。
解決方案:如果這步失敗,參考步驟 5 的解決方案。
- Service C 將數(shù)據(jù)庫中的 c1 改為 c2。
解決方案:如果這步失敗,參考步驟 6 的解決方案。
- Service C 將 Step3 的消息設置為已消費。
解決方案:如果這步失敗,參考步驟 8 的解決方案。
以上就是最終一致性的解決方案,如果你仔細思考了該方案,就會與當初的我一樣存在以下 2 點疑問。
- 因為我們利用了 MQ 的重試機制,就有可能出現(xiàn)步驟 6 跟步驟 10 重復執(zhí)行的情況,此時該怎么辦?比如上面流程中的步驟 8 失敗了,需要從步驟 5 重新執(zhí)行,這時就會出現(xiàn)步驟 6 執(zhí)行 2 遍的情況。為此,在下游(步驟 6 和 步驟 10)更新數(shù)據(jù)時,我們需要保證業(yè)務代碼的冪等性(關于冪等性,我們在 01 講提過)。
- 如果每個業(yè)務流程都需要這樣處理,豈不是需要額外寫很多代碼?那我們是否可以將類似處理流程的重復代碼抽取出來?答案是可以的,這里使用的 MQ 相關邏輯在其他業(yè)務流程中也通用,最終我們就是將這些代碼進行了抽取并封裝。關于重復代碼抽取的方法比較簡單,這里就不贅述了。
五、實時一致性方案
實時一致性,其實就是我們常說的分布式事務。
MySQL 其實有一個兩階段提交的分布式事務方案(MySQL XA),但是該方案存在嚴重的性能問題。比如,一個數(shù)據(jù)庫的事務與多個數(shù)據(jù)庫間的 XA 事務性能可能相差 10 倍。另外,在 XA 的事務處理過程中它會長期占用鎖資源,所以一開始我們并不考慮這個方案。
那時,市面上比較流行的方案是使用 TCC 模式,下面我們簡單介紹一下。
在 TCC 模式中,我們會把原來的一個接口分為 Try 接口、Confirm 接口、Cancel 接口。
- Try 接口用來檢查數(shù)據(jù)、預留業(yè)務資源。
- Confirm 接口用來確認實際業(yè)務操作、更新業(yè)務資源。
- Cancel 接口是指釋放 Try 接口中預留的資源。
比如積分兌換折扣券的例子中需要調(diào)用賬戶服務減積分、營銷服務加折扣券這兩個服務,那么針對賬戶服務減積分這個接口,我們需要寫 3 個方法,如下代碼所示:
public boolean prepareMinus(BusinessActionContext businessActionContext, final String accountNo, final double amount) {
//校驗賬戶積分余額
//凍結積分金額
}
public boolean Confirm(BusinessActionContext businessActionContext) {
//扣除賬戶積分余額
//釋放賬戶 凍結積分金額
}
public boolean Cancel(BusinessActionContext businessActionContext) {
//回滾所有數(shù)據(jù)變更
}
同樣,針對營銷服務加折扣券這個接口,我們也需要寫3個方法,而后調(diào)用的大體步驟如下:

上圖中綠色代表成功的調(diào)用路徑,如果中間出錯,就會先調(diào)用相關服務的回退方法,再進行手工回退。原本我們只需要在每個服務中寫一段業(yè)務代碼就行,現(xiàn)在需要拆成 3 段來寫,而且還涉及以下 5 點注意事項:
- 我們需要保證每個服務的 Try 方法執(zhí)行成功后,Confirm 方法在業(yè)務邏輯上能夠執(zhí)行成功;
- 可能會出現(xiàn) Try 方法執(zhí)行失敗而 Cancel 被觸發(fā)的情況,此時我們需要保證正確回滾;
- 可能因為網(wǎng)絡擁堵出現(xiàn) Try 方法的調(diào)用被堵塞的情況,此時事務控制器判斷 Try 失敗并觸發(fā)了 Cancel 方法,后來 Try 方法的調(diào)用請求到了服務這里,此時我們應該拒絕 Try 請求邏輯;
- 所有的 Try、Confirm、Cancel 都需要確保冪等性;
- 整個事務期間的數(shù)據(jù)庫數(shù)據(jù)處于一個臨時的狀態(tài),其他請求需要訪問這些數(shù)據(jù)時,我們需要考慮如何正確被其他請求使用,而這種使用包括讀取和并發(fā)的修改。
所以 TCC 模式是一個很麻煩的方案,除了每個業(yè)務代碼的工作量 X3 之外,出錯的概率也高,因為我們需要通過相應邏輯保證上面的注意事項都被處理。
后來,我們剛好看到了一篇介紹 Seata 的文章,了解到 AT 模式也能解決這個問題。
六、Seata 中 AT 模式的自動回滾
對于使用 Seata 的人來說操作比較簡單,只需要在觸發(fā)整個事務的業(yè)務發(fā)起方的方法中加入@GlobalTransactional 標注,且使用普通的 @Transactional 包裝好分布式事務中相關服務的相關方法即可。
在 Seata 內(nèi)在機制中,AT 模式的自動回滾往往需要執(zhí)行以下步驟:
(一)一階段
- 解析每個服務方法執(zhí)行的 SQL,記錄 SQL 的類型(Update、Insert 或 Delete),修改表并更新 SQL 條件等信息;
- 根據(jù)前面的條件信息生成查詢語句,并記錄修改前的數(shù)據(jù)鏡像;
- 執(zhí)行業(yè)務的 SQL;
- 記錄修改后的數(shù)據(jù)鏡像;
- 插入回滾日志:把前后鏡像數(shù)據(jù)及業(yè)務 SQL 相關的信息組成一條回滾日志記錄,插入 UNDO_LOG 表中;
- 提交前,向 TC 注冊分支,并申請相關修改數(shù)據(jù)行的全局鎖 ;
- 本地事務提交:業(yè)務數(shù)據(jù)的更新與前面步驟生成的 UNDO LOG 一并提交;
- 將本地事務提交的結果上報給事務控制器。
(二)二階段-回滾
收到事務控制器的分支回滾請求后,我們會開啟一個本地事務,并執(zhí)行如下操作:
- 查找相應的 UNDO LOG 記錄;
- 數(shù)據(jù)校驗:拿 UNDO LOG 中的后鏡像數(shù)據(jù)與當前數(shù)據(jù)進行對比,如果存在不同,說明數(shù)據(jù)被當前全局事務之外的動作做了修改,此時我們需要根據(jù)配置策略進行處理;
- 根據(jù) UNDO LOG 中的前鏡像和業(yè)務 SQL 的相關信息生成回滾語句并執(zhí)行;
- 提交本地事務,并把本地事務的執(zhí)行結果(即分支事務回滾的結果)上報事務控制器。
(三)二階段-提交
- 收到事務控制器的分支提交請求后,我們會將請求放入一個異步任務隊列中,并馬上返回提交成功的結果給事務控制器。
- 異步任務階段的分支提交請求將異步地、批量地刪除相應 UNDO LOG 記錄。
以上就是 Seata 的 AT 模式的簡單介紹。
七、嘗試 Seata
當時, Seata 雖然還沒有更新到 1.0,且官方也不推薦線上使用,但是最終我們還是使用了它,原因如下:
- 因為實時一致性的場景很少,而且發(fā)生頻率低,因此并不會大規(guī)模使用,對我們來說影響面在可控范圍內(nèi)。如果實時一致性的場景發(fā)生頻率高,并發(fā)量就高,業(yè)務人員對性能要求也高,此時我們就會與業(yè)務商量,采用最終一致性的方案。
- Seata AT 模式與 TCC 模式相比,它只是增加了一個 @GlobalTransactional 的工作量,因此兩者的工作量實在差太多了,所以我們愿意冒這個險,這也是 Seata 發(fā)展很快的原因。
后面,我們就在線上環(huán)境使用了 Seata。雖然它有點小毛病,但是瑕不掩瑜。
八、總結
最終一致性與實時一致性的解決方案設計完后,不僅沒有給業(yè)務開發(fā)人員帶來額外工作量,也沒有影響日常推進業(yè)務項目的進度,還大大減少了數(shù)據(jù)不一致的出現(xiàn)概率,因此數(shù)據(jù)不一致的痛點算是大大緩解了。
不過該方案存在一點不足,因為某個服務需要依賴其他服務的數(shù)據(jù),使得我們需要額外寫很多業(yè)務邏輯,關于此問題的解決方案我們已在 前面的文章中詳細說明,感興趣的小伙伴可以去看看。
感興趣的朋友歡迎關注微信公眾號:服務端技術精選
個人博客:http://jiangyi.cool