最近,工做中要爲如今的老系統作拆分和升級,恰好遇到了分佈式事務、冪等控制、異步消息亂序和補償方案等問題,恰好基於實踐結合我的的見解記錄一下一些方案和思路。前端
首先,作系統拆分的時候幾乎都會遇到分佈式事務的問題,一個仿真的案例以下:java
項目初期,因爲用戶體量不大,訂單模塊和錢包模塊共庫共應用(大war包時代),模塊調用能夠簡化爲本地事務操做,這樣作只要不是程序自己的BUG,基本能夠避免數據不一致。後面由於用戶體量愈加增大,基於容錯、性能、功能共享等考慮,把原來的應用拆分爲訂單微服務和錢包微服務,兩個服務之間經過非本地事務操(這裏能夠是HTTP或者消息隊列等)做進行數據同步,這個時候就頗有可能因爲異常場景出現數據不一致的狀況。git
以上面的訂單微服務請求錢包微服務進行扣款並更新訂單狀態爲扣款這個調用過程爲例,假設採用HTTP同步調用,項目若是由經驗不足的開發者開發這個邏輯,可能會出現下面的僞代碼:github
[訂單微服務請求錢包微服務進行扣款並更新訂單狀態] 處理訂單微服務請求錢包微服務進行扣款並更新訂單狀態方法(){ [開啓事務] 一、查詢訂單 二、HTTP調用錢包微服務扣款 三、更新訂單狀態爲扣款成功 [提交事務] }
這是一個從肉眼上看起來沒有什麼問題的解決方法,HTTP
調用直接嵌入到事務代碼塊內部,猜測最初開發者的想法是:HTTP
調用失敗拋出異常會致使事務回滾,用戶重試便可;HTTP
調用成功,事務正常提交,業務正常完成。這種作法看似可取,可是帶來了極大的隱患,根本緣由是:事務中嵌入了RPC
調用。假設兩種比較常見的狀況:redis
儘管如今有Hystrix
等框架能夠基於線程池隔離調用或者基於熔斷器快速失敗,可是這是收效甚微的。所以,我的認爲事務中直接RPC調用達到強一致性是徹底不可取的,若是使用了這種方式實現"分佈式事務"建議整改,不然只能天天祈求下游服務或者網絡不出現任何問題。算法
使用消息隊列進行服務之間的調用也是常見的方式之一,可是使用消息隊列交互本質是異步的,沒法感知下游消息消費方是否正常處理消息。用前一節的例子,假設採用消息隊列異步調用,項目若是由經驗不足的開發者開發這個邏輯,可能會出現下面的僞代碼:spring
[訂單微服務請求錢包微服務進行扣款並更新訂單狀態] 處理訂單微服務請求錢包微服務進行扣款並更新訂單狀態方法(){ [開啓事務] 一、查詢訂單 二、推送錢包微服務扣款消息(推送消息) 三、更新訂單狀態爲扣款成功 [提交事務] }
上面的處理方法若是抽象一點表示以下:sql
方法(){ DataSource dataSource = xx; Connection con = dataSource.getConnection(); con.setAutoCommit(false); try{ 一、SQL操做; 二、推送消息; 三、SQL操做; con.commit(); }catch(Exception e){ con.rollback(); }finally{ 釋放其餘資源; release(con); } }
這樣作,在正常狀況下,也就是可以正常調用消息隊列中間件推送消息成功的狀況下,事務是可以正確提交的。可是存在兩個明顯的問題:數據庫
總的來講:事務中進行異步消息推送是一種並不可靠的實現。編程
業界目前主流的分佈式事務解決方案主要有:多階段提交方案(2PC、3PC)、補償事務(TCC)和消息事務(主要是RocketMQ,基本思想也是多階段提交方案,而且基於中間提供件輪詢和重試,其餘消息隊列中間件並無實現分佈式事務)。這些方案的原理在此處不展開,目前網絡中相應資料比較多,小結一下它們的特色:
TCC
,由於每一個事務操做都須要提供三個操做嘗試(Try
)、確認(Confirm
)和補償/撤銷(Cancel
),數據一致性的強度比多階段提交方案低,可是實現的複雜度會有所下降,比較明顯的缺陷是每一個業務事務須要實現三組操做,有可能出現過多的補償方案的代碼;另外有不少輸完液場景TCC是不合適的。RocketMQ
的實現,一個事務的執行流程包括:發送預消息、執行本地事務、確認消息發送成功。它的消息中間件存儲了下游沒法消費成功的消息,而且不斷重試推送下游消費消息,而生產者(上游)須要提供一個check
接口,用於檢查成功發送預消息可是未確認最終消息發送狀態的事務的狀態。我的所在的公司的技術棧中沒有使用RocketMQ,主要使用RabbitMQ,因此須要針對RabbitMQ作消息事務的適配。目前業務系統中消息異步交互存在三種場景:
最終敲定使用了本地消息表的解決方案,這個方案十分簡單:
主要思路是:
僞代碼以下:
[消息推送實時性高,能夠接受丟失-這種狀況下能夠不須要寫入本地消息表 - start] 處理方法(){ [本地事務開始] 一、處理業務操做 [本地事務提交] 二、組裝推送消息而且進行推送 } [消息推送實時性高,能夠接受丟失-這種狀況下能夠不須要寫入本地消息表 - end] [消息推送實時性低,不能丟失 - start] 處理方法(){ [本地事務開始] 一、處理業務操做 二、組裝推送消息而且寫入到本地消息表 [本地事務提交] } 消息推送調度模塊(){ 三、查詢本地消息表待推送數據進行推送 } [消息推送實時性低,不能丟失 - end] [消息推送實時性高,不能丟失 - start] 處理方法(){ [本地事務開始] 一、處理業務操做 二、組裝推送消息而且寫入到本地消息表 [本地事務提交] 三、消息推送 } 消息推送調度模塊(){ 四、查詢本地消息表待推送數據進行推送 } [消息推送實時性高,不能丟失 - end]
spring-tx
的聲明式事務@Transactional
或者編程式事務TransactionTemplate
,能夠使用事務同步器實現嵌入於業務操做事務代碼塊中的RPC操做延後到事務提交後執行,這樣子RPC調用的代碼物理位置就能夠放置在事務代碼塊內,例如:@Transactional(rollbackFor = RuntimeException.class) public void process(){ 1.處理業務邏輯 TransactionSynchronizationManager.getSynchronizations().add(new TransactionSynchronizationAdapter() { @Override public void afterCommit() { 2.進行消息推送 } }); }
對於使用到本地消息表的場景,須要警戒下面幾個問題:
例如本地消息表的設計以下:
CREATE TABLE `t_local_message`( id BIGINT PRIMARY KEY COMMENT '主鍵', module INT NOT NULL COMMENT '消息模塊', tag VARCHAR(20) NOT NULL COMMENT '消息標籤', business_key VARCHAR(60) NOT NULL COMMENT '業務鍵', queue VARCHAR(60) NOT NULL COMMENT '隊列', exchange VARCHAR(60) NOT NULL COMMENT '交換器', exchange_type VARCHAR(10) NOT NULL COMMENT '交換器類型', routing_key VARCHAR(60) NOT NULL COMMENT '路由鍵', retry_times TINYINT NOT NULL DEFAULT 0 COMMENT '重試次數', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立日期時間', edit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改日期時間', seq_no VARCHAR(60) NOT NULL COMMENT '流水號', message_status TINYINT NOT NULL DEFAULT 0 COMMENT '消息狀態', INDEX idx_business_key(business_key), INDEX idx_create_time(create_time), UNIQUE uniq_seq_no(seq_no) )COMMENT '本地消息表'; CREATE TABLE `t_local_message_content`( id BIGINT PRIMARY KEY COMMENT '主鍵', message_id BIGINT NOT NULL COMMENT '本地消息表主鍵', message_content TEXT COMMENT '消息內容', UNIQUE uniq_message_id(message_id) )COMMENT '本地消息內容表';
我的認爲,解決分佈式事務的最佳實踐就是:
其實,對於一致性和實時性要求相對較高的分佈式事務的實現,使用消息隊列解耦也有對應的解決方案。
冪等(idempotence)這個術語原文來自於HTTP/1.1
協議中的定義:
Methods can also have the property of 「idempotence」 in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request.
簡單來講就是:除了錯誤或者過時的請求(換言之就是成功的請求),不管屢次調用仍是單次調用最終獲得的效果是一致的。通俗來講,有一次調用成功,採用相同的請求參數不管調用多少次(重複提交)都應該返回成功。
下游服務對外提供服務接口,必須承諾實現接口的冪等性,這一點在分佈式系統中極其重要。
目前實踐中對於冪等的處理使用了下面三個方面的控制:
舉一個基於消息消費冪等控制的僞代碼例子:
[處理消息消費] listen(request){ 一、經過業務鍵構建分佈式鎖的KEY 二、經過Redisson構建分佈式鎖而且加鎖 三、加鎖代碼中執行業務邏輯(包括去重判斷、事務操做和非事務操做等) 四、finally代碼塊中釋放分佈式鎖 }
補償方案主要是HTTP同步調用的補償和異步消息消費失敗的補償。
通常狀況下,HTTP
同步調用會獲得下游系統的同步結果,對結果的處理存在下面幾種常見的狀況:
首先要有一個簡單的認知:短期內的HTTP重試一般狀況下都是無效的。若是是瞬時的網絡抖動,短期內HTTP
同步重試是可行的,大部分狀況下是下游服務沒法響應、下游服務重啓中或者複雜的網絡狀況致使短期內沒法恢復,這個時候作HTTP同步重試調用每每是無效的。
若是面對的場景是內部低併發量的系統之間的進行HTTP
交互,能夠考慮使用基於指數退避的算法進行重試,舉個例子:
一、第一次調用失敗,立刻進行第二次重試 二、第二次重試失敗,線程休眠2秒 三、第三次重試失敗,線程休眠4秒(2^2) 四、第四次重試失敗,線程休眠8秒(2^8) 五、第五次重試失敗,拋出異常
若是上面的例子中使用了Hystrix
控制超時爲1秒包裹着要執行的HTTP命令進行調用,上面的重試過程最大耗時小於20秒,在低併發的內部系統之間的交互是能夠接受的。
可是,若是面對的是併發比較高、用戶體驗優先級比較高的場景,這樣作顯然是不合理的。爲了穩妥起見,能夠採起相對傳統而有效的方案:HTTP調用的調用瞬時內容保存到一張本地重試表中,這個保存操做綁定在業務處理的事務中,經過定時調度對未調用成功的記錄進行重試。這個方案和上文提到保證消息推送成功的方案相似,舉一個仿真的例子:
[下單接口請求下游錢包服務扣錢的過程] process(){ [事務代碼塊-start] 一、處理業務邏輯,保存訂單信息,訂單狀態爲扣錢處理中 二、組裝將要向下遊錢包服務發起的HTTP調用信息,保存在本地表中 [事務代碼塊-end] 三、事務外進行HTTP調用(OkHttp客戶端或者Apache的Http客戶端),調用成功更新訂單狀態爲扣錢成功 } 定時調度(){ 四、定時查詢訂單狀態爲扣錢處理中的訂單進行HTTP調用,調用成功更新訂單狀態爲扣錢成功 }
異步消息消費失敗的場景發生只能在消息消費方,也就是下游服務。從下降成本的目的上看,消息消費失敗的補償應該由消息處理的一方(消費者)自行承擔,畫一個系統交互圖理解一下:
若是由上游服務進行補償,存在兩個明顯的問題:
在最近的一些項目實踐中,肯定在使用異步消息交互的時候,補償統一由消息消費方實現。最簡單的方式也是使用相似本地消息表的方式,把消費失敗的消息入庫,而且進行重試,到達重試上限依然失敗則進行預警和人工介入便可。簡單的流程圖以下:
異步消息亂序是使用消息隊列進行異步交互場景中須要考慮和解決的問題。下面舉一些可能不合乎實際可是可以說明問題的例子。
場景一:上游某個服務向用戶服務經過消息隊列異步修改用戶的性別信息,假設消息簡化以下:
隊列:user-service.modify.sex.qeue 消息: { "userId": 長整型, "sex": 字符串,可選值是MAN、WOMAN和UNKNOW }
用戶服務一共使用了10個消費者線程監聽user-service.modify.sex.qeue
隊列。假設上游服務前後向user-service.modify.sex.qeue
隊列推送下面兩條消息:
第一條消息: { "userId": 1, "sex": "MAN" } 第二條消息: { "userId": 1, "sex": "WOMAN" }
上面的消息推送和下游處理有比較高概率出現下面的狀況:
本來用戶ID爲1的用戶先把性別改成MAN(第一次請求),後來改成WOMAN(第二次請求),最終看到更新後的性別有多是MAN,這顯然是不合理的。這個不是很合理的例子想說明的問題是:經過異步消息交互,下游服務處理消息的時序有可能和上游發送消息的時序並不一致,這樣有可能致使業務狀態錯亂。對於解決這個問題,提供幾個可行的思路:
FIFO
的特性(這一點RabbitMQ
實現了,其餘消息隊列中間件不肯定),把下游服務的消費線程設置爲1便可,那麼上游推送的消息和下游消費消息的時序是一致的。場景二:沒有時序要求的異步消息處理,可是要求最終展現的時候是有時序的。這樣說可能有點抽象,舉個例子:在借唄上借了10000元,還款的時候,用戶是分屢次還清(例如還款方案一:2000,3000,5000;還款方案二:1000,1000,1000,7000等等),每次還的錢都不同,最終要求帳單展現的時候是按照用戶的還款操做順序。
假設借唄的上游服務和它經過異步消息交互。詳細分析一下:這個場景其實對於借唄(主要是考慮收回用戶的還款這個目的)來講,對用戶還款的順序並不須要感知,只須要考慮用戶是否還清,可是使用異步交互,有可能致使下游沒法正確得知用戶還款的操做順序。
解決方案很簡單:推送消息的時候附加一個帶有增加或者減小趨勢的標記位便可,例如使用帶有時間戳的標記位或者使用Snowflake
算法生成自增趨勢的長整型數做爲流水號,以後按照流水號排序便可獲得消息操做的順序(這個流水號下游須要保存),可是實際消息處理的時候並不須要感知消息的時序。
我的認爲:異步消息結合狀態驅動是能夠相對完善地解決分佈式事務,結合預處理(例如預扣除或者預增加)能夠知足比較高一致性和實時性。先引出一個常常用來討論分佈式事務強一致性的轉帳場景。
解決這個問題若是使用同步調用(其實像TCC
、2PC
或者3PC
等本質都是同步調用),在容許性能損失的狀況下是可以達到強一致性。這一節並不討論同步調用的狀況下怎麼作,重點研究一下在使用消息隊列的狀況下,如何從BASE
的角度"達到比較高的一致性"。先把這個例子抽象化,假設兩個系統的帳戶表都設計成這樣:
CREATE TABLE `t_account`( id BIGINT PRIMARY KEY COMMENT '主鍵', user_id BIGINT NOT NULL COMMENT '用戶ID', balance DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '帳戶餘額', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間', edit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改時間', version BIGINT NOT NULL DEFAULT 0 COMMENT '版本' // 省略索引 )COMMENT '帳戶表';
兩個系統均可以創建一張表結構類似的金額變動流水錶,上游系統用於作預扣操做和流水記錄,下游系統用於作流水記錄,接着咱們能夠梳理出新的交互時序邏輯以下:
[A系統本地事務-start] 一、A系統t_account表X用戶餘額減去1000 二、A系統流水錶寫入一條用戶X的預扣1000的記錄,標記狀態爲處理中,生成全局惟一的流水號記爲SEQ_NO [A系統本地事務-end] 三、A系統經過消息隊列推送一條用戶X扣減1000的消息(必定要附帶流水號SEQ_NO)到消息隊列中間件(這裏能夠用上文提到的技巧確保消息推送成功) [B系統本地事務-start] 四、B系統t_account表X用戶餘額加上1000 五、B系統流水錶寫入一條用戶X的餘額變動(增長)1000的記錄 <= 注意這裏B系統的流水只能insert不能update [B系統本地事務-end] 六、B系統推送處理X用戶餘額處理成功的消息到消息隊列中間件,必定要附帶流水號SEQ_NO(這裏能夠用上文提到的技巧確保消息推送成功) [A系統本地事務-start] 七、A系統更新流水錶中X用戶流水號爲SEQ_NO的預扣記錄的狀態爲處理成功(這一步必定要作好冪等控制,能夠考慮用SEQ_NO做爲分佈式鎖的KEY) [A系統本地事務-end] 其餘: [A系統流水錶處理中的記錄須要定時輪詢和重試] 一、定時調度重試A系統流水錶中狀態爲處理中的記錄 [A-B系統日切對帳模塊] 一、日切,用A系統中處理成功的T-1日流水記錄和B系統中的流水錶全部T-1日的記錄進行對帳
上面的步驟看起來比較多,並且還須要編寫對帳和重試模塊。其實,在上下游系統、消息隊列中間件都正常運做的狀況下,上面的這套交互方案可承受的併發量遠比同步方案高,出現了服務或者消息隊列中間件不可用的狀況下,因爲流水錶有未處理的本地記錄,在這些問題恢復以後能夠重試,可靠性也是比較高的。另外,重試和對帳的模塊,對於全部涉及金額交易的處理都是必須的,這一點其實選用同步或者異步交互方式並無關係。
你會發覺,通篇文章有不少方案都是使用了待處理內容寫入本地表 + 事務外實時觸發 + 定時調度補償這個模式,其實我想表達的就是這個模式是目前分佈式解決方案中一個相對通用的模式,能夠基本知足分佈式事務、同步異步補償、實時非實時觸發等多種複雜場景的處理。這個模式也存在一些明顯的問題(若是實踐過的話通常會遇到):
其實,更多的時候須要結合現有的系統或者場景進行分析,經過數據監控和分析進行後續優化。畢竟,架構是迭代出來,而不是設計出來的。
(本文完 e-a-20190323 c-14-d 996 這是一篇2019年3月底寫的文章,如今發出來但願尚未過期)
技術公衆號《Throwable文摘》(id:throwable-doge),不按期推送筆者原創技術文章(毫不抄襲或者轉載):