前提#
最近,工作中要為現在的老系統做拆分和更新,剛好遇到了分布式事務、幂等控制、異步消息亂序和補償方案等問題,剛好基于實踐結合個人的看法記錄一下一些方案和思路。
分布式事務#
首先,做系統拆分的時候幾乎都會遇到分布式事務的問題,一個仿真的案例如下:
項目初期,由于使用者體量不大,訂單子產品和錢包子產品共庫共應用(大war包時代),子產品調用可以簡化為本地事務操作,這樣做隻要不是程式本身的BUG,基本可以避免資料不一緻。後面因為使用者體量越發增大,基于容錯、性能、功能共享等考慮,把原來的應用拆分為訂單微服務和錢包微服務,兩個服務之間通過非本地事務操(這裡可以是HTTP或者消息隊列等)作進行資料同步,這個時候就很有可能由于異常場景出現資料不一緻的情況。
事務中直接RPC調用達到強一緻性#
以上面的訂單微服務請求錢包微服務進行扣款并更新訂單狀态為扣款這個調用過程為例,假設采用HTTP同步調用,項目如果由經驗不足的開發者開發這個邏輯,可能會出現下面的僞代碼:
Copy
[訂單微服務請求錢包微服務進行扣款并更新訂單狀态]
處理訂單微服務請求錢包微服務進行扣款并更新訂單狀态方法(){
[開啟事務]
1、查詢訂單
2、HTTP調用錢包微服務扣款
3、更新訂單狀态為扣款成功
[送出事務]
}
這是一個從肉眼上看起來沒有什麼問題的解決方法,
HTTP
調用直接嵌入到事務代碼塊内部,猜想最初開發者的想法是:
HTTP
調用失敗抛出異常會導緻事務復原,使用者重試即可;
HTTP
調用成功,事務正常送出,業務正常完成。這種做法看似可取,但是帶來了極大的隐患,根本原因是:事務中嵌入了
RPC
調用。假設兩種比較常見的情況:
- 1、上面方法中第2步由于錢包微服務本身各種原因導緻扣款接口響應極慢,會導緻上面的處理方法事務(準确來說是資料庫連接配接)長時間挂起,持有的資料庫連接配接無法釋放,會導緻資料庫連接配接池的連接配接耗盡,很容易導緻訂單微服務的其他依賴資料庫的接口無法響應。
- 2、錢包微服務是單節點部署(并不是所有的公司微服務都做得很完善),更新期間應用停機,上面方法中第2步接口調用直接失敗,這樣會導緻短時間内所有的事務都復原,相當于訂單微服務的扣款入口是不可用的。
- 3、網絡是不可靠的,HTTP調用或者接受響應的時候如果出現網絡閃斷有可能出現了服務間狀态不能互相明确的情況,例如訂單微服務調用錢包微服務成功,接受響應的時候出現網絡問題,會出現扣款成功但是訂單狀态沒有更新的可能(訂單微服務事務復原)。
盡管現在有
Hystrix
等架構可以基于線程池隔離調用或者基于熔斷器快速失敗,但是這是收效甚微的。是以,個人認為事務中直接RPC調用達到強一緻性是完全不可取的,如果使用了這種方式實作"分布式事務"建議整改,否則隻能每天祈求下遊服務或者網絡不出現任何問題。
事務中進行異步消息推送#
使用消息隊列進行服務之間的調用也是常見的方式之一,但是使用消息隊列互動本質是異步的,無法感覺下遊消息消費方是否正常處理消息。用前一節的例子,假設采用消息隊列異步調用,項目如果由經驗不足的開發者開發這個邏輯,可能會出現下面的僞代碼:
[訂單微服務請求錢包微服務進行扣款并更新訂單狀态]
處理訂單微服務請求錢包微服務進行扣款并更新訂單狀态方法(){
[開啟事務]
1、查詢訂單
2、推送錢包微服務扣款消息(推送消息)
3、更新訂單狀态為扣款成功
[送出事務]
}
上面的處理方法如果抽象一點表示如下:
方法(){
DataSource dataSource = xx;
Connection con = dataSource.getConnection();
con.setAutoCommit(false);
try{
1、SQL操作;
2、推送消息;
3、SQL操作;
con.commit();
}catch(Exception e){
con.rollback();
}finally{
釋放其他資源;
release(con);
}
}
這樣做,在正常情況下,也就是能夠正常調用消息隊列中間件推送消息成功的情況下,事務是能夠正确送出的。但是存在兩個明顯的問題:
- 1、消息隊列中間件出現了異常,無法正常調用,常見的情況是網絡原因或者消息隊列中間件不可用,會導緻異常進而使得事務復原。這種情況看起來似乎合情合理,但是仔細想:為什麼消息隊列中間件調用異常會導緻業務事務復原,如果中間件不恢複,這個接口調用豈不是相當于不可用?
- 2、如果消息隊列中間件正常,消息正常推送,但是第3步由于SQL存在文法錯誤導緻事務復原,這樣就會出現了下遊微服務被調用成功,本地事務卻復原的問題,導緻了上下遊系統資料不一緻。
總的來說:事務中進行異步消息推送是一種并不可靠的實作。
目前業界提供的解決方案#
業界目前主流的分布式事務解決方案主要有:多階段送出方案(2PC、3PC)、補償事務(TCC)和消息事務(主要是RocketMQ,基本思想也是多階段送出方案,并且基于中間提供件輪詢和重試,其他消息隊列中間件并沒有實作分布式事務)。這些方案的原理在此處不展開,目前網絡中相應資料比較多,小結一下它們的特點:
- 多階段送出方案:常見的有二階段和三階段送出事務,需要額外的資料總管來協調事務,資料一緻性強,但是實作方案比較複雜,對性能的犧牲比較大(主要是需要對資源鎖定,等待所有事務送出才能解鎖),不适用于高并發的場景,目前比較知名的有阿裡開源的fescar。
- 補償事務:一般也叫
,因為每個事務操作都需要提供三個操作嘗試(TCC
)、确認(Try
)和補償/撤銷(Confirm
),資料一緻性的強度比多階段送出方案低,但是實作的複雜度會有所降低,比較明顯的缺陷是每個業務事務需要實作三組操作,有可能出現過多的補償方案的代碼;另外有很多輸完液場景TCC是不合适的。Cancel
- 消息事務:這裡隻談
的實作,一個事務的執行流程包括:發送預消息、執行本地事務、确認消息發送成功。它的消息中間件存儲了下遊無法消費成功的消息,并且不斷重試推送下遊消費消息,而生産者(上遊)需要提供一個RocketMQ
接口,用于檢查成功發送預消息但是未确認最終消息發送狀态的事務的狀态。check
項目實踐中最終使用的方案#
個人所在的公司的技術棧中沒有使用RocketMQ,主要使用RabbitMQ,是以需要針對RabbitMQ做消息事務的适配。目前業務系統中消息異步互動存在三種場景:
- 1、消息推送實時性高,可以接受丢失。
- 2、消息推送實時性低,不能丢失。
- 3、消息推送實時性高,不能丢失。
最終敲定使用了本地消息表的解決方案,這個方案十分簡單:
主要思路是:
- 1、需要發送到消費方的消息的儲存和業務處理綁定在同一個本地事務中,需要額外建立一張本地消息表。
- 2、本地事務送出之後,可以在事務外對本地消息表進行查詢并且進行消息推送,或者采用定時排程輪詢本地消息表進行消息推送。
- 3、下遊服務消費消息成功可以回調一個确認到上遊服務,這樣就可以從上遊服務的本地消息表删除對應的消息記錄。
僞代碼如下:
[消息推送實時性高,可以接受丢失-這種情況下可以不需要寫入本地消息表 - start]
處理方法(){
[本地事務開始]
1、處理業務操作
[本地事務送出]
2、組裝推送消息并且進行推送
}
[消息推送實時性高,可以接受丢失-這種情況下可以不需要寫入本地消息表 - end]
[消息推送實時性低,不能丢失 - start]
處理方法(){
[本地事務開始]
1、處理業務操作
2、組裝推送消息并且寫入到本地消息表
[本地事務送出]
}
消息推送排程子產品(){
3、查詢本地消息表待推送資料進行推送
}
[消息推送實時性低,不能丢失 - end]
[消息推送實時性高,不能丢失 - start]
處理方法(){
[本地事務開始]
1、處理業務操作
2、組裝推送消息并且寫入到本地消息表
[本地事務送出]
3、消息推送
}
消息推送排程子產品(){
4、查詢本地消息表待推送資料進行推送
}
[消息推送實時性高,不能丢失 - end]
- 對于"消息推送實時性高,可以接受丢失"這種情況,實際上不用依賴本地消息表,隻要在業務操作事務送出之後組裝和推送消息即可,這種情況會存在因為消息隊列中間件不可用或者本地應用當機導緻消息丢失的問題(本質是因為資料是記憶體态,非持久化),可靠性不高,但是絕大多數情況下是沒有問題的。如果使用
的聲明式事務spring-tx
或者程式設計式事務@Transactional
,可以使用事務同步器實作嵌入于業務操作事務代碼塊中的RPC操作延後到事務送出後執行,這樣子RPC調用的代碼實體位置就可以放置在事務代碼塊内,例如:TransactionTemplate
@Transactional(rollbackFor = RuntimeException.class)
public void process(){
1.處理業務邏輯
TransactionSynchronizationManager.getSynchronizations().add(new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
2.進行消息推送
}
});
}
對于使用到本地消息表的場景,需要警惕下面幾個問題:
- 1、注意本地消息表盡量不要長時間積壓資料,推送成功的資料需要及時删除。
- 2、本地消息表的資料在查詢并且推送的時候,需要設計最大重試次數上限,達到上限仍然推送失敗的記錄需要進行預警和人為幹預。
- 3、如果入庫的消息體比較大,查詢可能消耗的IO比較大,需要考慮拆分單獨的一張消息内容表用于存放消息體内容,而經常更變的列應該單獨拆分到另外一張表。
例如本地消息表的設計如下:
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 '本地消息内容表';
分布式事務小結#
個人認為,解決分布式事務的最佳實踐就是:
- 規避使用強一緻性的分布式事務實作,基本觀念就是放棄ACID投奔BASE。
- 推薦使用消息隊列進行系統間的解耦,消息推送方為了確定消息推送成功可以獨立附加消息表把需要推送的消息和業務操作綁定在同一個事務内,使用異步或者排程的方式進行推送。
- 消息推送方(上遊)需要確定消息正确投遞到消息隊列中間件,消息消費或者補償方案由消息消費方(下遊)自行解決,關于這一點後文一個章節專門解釋。
其實,對于一緻性和實時性要求相對較高的分布式事務的實作,使用消息隊列解耦也有對應的解決方案。
幂等控制#
幂等(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.
簡單來說就是:除了錯誤或者過期的請求(換言之就是成功的請求),無論多次調用還是單次調用最終得到的效果是一緻的。通俗來說,有一次調用成功,采用相同的請求參數無論調用多少次(重複送出)都應該傳回成功。
下遊服務對外提供服務接口,必須承諾實作接口的幂等性,這一點在分布式系統中極其重要。
- 對于HTTP調用,承諾幂等性可以避免表單或者請求操作重複送出造成業務資料重複。
- 對于異步消息調用,承諾幂等性通過對消息去重處理也是用于避免重複消費造成業務資料重複。
目前實踐中對于幂等的處理使用了下面三個方面的控制:
- 1、實作幂等的接口調用時入口使用分布式鎖,使用了主流的Redisson,控制鎖的粒度和鎖的等待、持有時間在合理範圍(筆者所在行業要求資料必須準确無誤,是以幾乎用悲觀鎖設計所有核心接口,甯願慢也不能錯,實際上如果沖突比較低的時候為了性能優化可以考慮使用樂觀鎖)。
- 2、業務邏輯上的防重,例如建立訂單的接口先做一步通過訂單号查詢庫表中是否已經存在對應的訂單,如果存在則不做處理直接傳回成功。
- 3、資料庫表設計對邏輯上唯一的業務鍵做唯一索引,這個是通過資料庫層面做最後的保障。
舉一個基于消息消費幂等控制的僞代碼例子:
[處理消息消費]
listen(request){
1、通過業務鍵建構分布式鎖的KEY
2、通過Redisson建構分布式鎖并且加鎖
3、加鎖代碼中執行業務邏輯(包括去重判斷、事務操作和非事務操作等)
4、finally代碼塊中釋放分布式鎖
}
補償方案#
補償方案主要是HTTP同步調用的補償和異步消息消費失敗的補償。
HTTP同步調用補償#
一般情況下,
HTTP
同步調用會得到下遊系統的同步結果,對結果的處理存在下面幾種常見的情況:
- 1、同步結果傳回正常,得到了和下遊約定的最終狀态,互動結束,一般認為成功就是最終狀态,不需要補償。
- 2、同步結果傳回正常,得到了和下遊約定的非最終狀态,需要定時補償到最終狀态或到達重試上限自行标記為最終狀态。
- 3、同步結果傳回異常,最常見的是下遊服務不可用傳回HTTP狀态碼為5XX。
首先要有一個簡單的認知:短時間内的HTTP重試通常情況下都是無效的。如果是瞬時的網絡抖動,短時間内
HTTP
同步重試是可行的,大部分情況下是下遊服務無法響應、下遊服務重新開機中或者複雜的網絡情況導緻短時間内無法恢複,這個時候做HTTP同步重試調用往往是無效的。
如果面對的場景是内部低并發量的系統之間的進行
HTTP
互動,可以考慮使用基于指數退避的算法進行重試,舉個例子:
1、第一次調用失敗,馬上進行第二次重試
2、第二次重試失敗,線程休眠2秒
3、第三次重試失敗,線程休眠4秒(2^2)
4、第四次重試失敗,線程休眠8秒(2^8)
5、第五次重試失敗,抛出異常
如果上面的例子中使用了
Hystrix
控制逾時為1秒包裹着要執行的HTTP指令進行調用,上面的重試過程最大耗時小于20秒,在低并發的内部系統之間的互動是可以接受的。
但是,如果面對的是并發比較高、使用者體驗優先級比較高的場景,這樣做顯然是不合理的。為了穩妥起見,可以采取相對傳統而有效的方案:HTTP調用的調用瞬時内容儲存到一張本地重試表中,這個儲存操作綁定在業務處理的事務中,通過定時排程對未調用成功的記錄進行重試。這個方案和上文提到保證消息推送成功的方案類似,舉一個仿真的例子:
[下單接口請求下遊錢包服務扣錢的過程]
process(){
[事務代碼塊-start]
1、處理業務邏輯,儲存訂單資訊,訂單狀态為扣錢進行中
2、組裝将要向下遊錢包服務發起的HTTP調用資訊,儲存在本地表中
[事務代碼塊-end]
3、事務外進行HTTP調用(OkHttp用戶端或者Apache的Http用戶端),調用成功更新訂單狀态為扣錢成功
}
定時排程(){
4、定時查詢訂單狀态為扣錢進行中的訂單進行HTTP調用,調用成功更新訂單狀态為扣錢成功
}
異步消息消費失敗補償#
異步消息消費失敗的場景發生隻能在消息消費方,也就是下遊服務。從降低成本的目的上看,消息消費失敗的補償應該由消息處理的一方(消費者)自行承擔,畫一個系統互動圖了解一下:
如果由上遊服務進行補償,存在兩個明顯的問題:
- 1、消息補償子產品需要在所有的上遊服務中編寫,這是不合理的。
- 2、一旦下遊消費出現生産問題需要上遊補償,需要先定位出對應的消息是哪個上遊服務推送,然後通過該上遊服務進行補償,處理生産問題的複雜度提高。
在最近的一些項目實踐中,确定在使用異步消息互動的時候,補償統一由消息消費方實作。最簡單的方式也是使用類似本地消息表的方式,把消費失敗的消息入庫,并且進行重試,到達重試上限依然失敗則進行預警和人工介入即可。簡單的流程圖如下:
異步消息亂序解決#
異步消息亂序是使用消息隊列進行異步互動場景中需要考慮和解決的問題。下面舉一些可能不合乎實際但是能夠說明問題的例子。
場景一:上遊某個服務向使用者服務通過消息隊列異步修改使用者的性别資訊,假設消息簡化如下:
隊列: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
實作了,其他消息隊列中間件不确定),把下遊服務的消費線程設定為1即可,那麼上遊推送的消息和下遊消費消息的時序是一緻的。RabbitMQ
- 方案二:使用HTTP調用,這個要前端或者APP用戶端配合,請求設計成串行的即可。
場景二:沒有時序要求的異步消息處理,但是要求最終展示的時候是有時序的。這樣說可能有點抽象,舉個例子:在借呗上借了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]
1、A系統t_account表X使用者餘額減去1000
2、A系統流水表寫入一條使用者X的預扣1000的記錄,标記狀态為進行中,生成全局唯一的流水号記為SEQ_NO
[A系統本地事務-end]
3、A系統通過消息隊列推送一條使用者X扣減1000的消息(一定要附帶流水号SEQ_NO)到消息隊列中間件(這裡可以用上文提到的技巧確定消息推送成功)
[B系統本地事務-start]
4、B系統t_account表X使用者餘額加上1000
5、B系統流水表寫入一條使用者X的餘額變更(增加)1000的記錄 <= 注意這裡B系統的流水隻能insert不能update
[B系統本地事務-end]
6、B系統推送處理X使用者餘額處理成功的消息到消息隊列中間件,一定要附帶流水号SEQ_NO(這裡可以用上文提到的技巧確定消息推送成功)
[A系統本地事務-start]
7、A系統更新流水表中X使用者流水号為SEQ_NO的預扣記錄的狀态為處理成功(這一步一定要做好幂等控制,可以考慮用SEQ_NO作為分布式鎖的KEY)
[A系統本地事務-end]
其他:
[A系統流水表進行中的記錄需要定時輪詢和重試]
1、定時排程重試A系統流水表中狀态為進行中的記錄
[A-B系統日切對賬子產品]
1、日切,用A系統中處理成功的T-1日流水記錄和B系統中的流水表所有T-1日的記錄進行對賬
上面的步驟看起來比較多,而且還需要編寫對賬和重試子產品。其實,在上下遊系統、消息隊列中間件都正常運作的情況下,上面的這套互動方案可承受的并發量遠比同步方案高,出現了服務或者消息隊列中間件不可用的情況下,由于流水表有未處理的本地記錄,在這些問題恢複之後可以重試,可靠性也是比較高的。另外,重試和對賬的子產品,對于所有涉及金額交易的處理都是必須的,這一點其實選用同步或者異步互動方式并沒有關系。
小結#
你會發覺,通篇文章有很多方案都是使用了待處理内容寫入本地表 + 事務外實時觸發 + 定時排程補償這個模式,其實我想表達的就是這個模式是目前分布式解決方案中一個相對通用的模式,可以基本滿足分布式事務、同步異步補償、實時非實時觸發等多種複雜場景的處理。這個模式也存在一些明顯的問題(如果實踐過的話一般會遇到):
- 1、庫表(本地消息表)設計不合理或者處理不合理容易成為資料庫的瓶頸。
- 2、補償或者本地表入庫處理的邏輯代碼容易備援和腐化。
- 3、極端情況下,異常恢複的場景存在拖垮服務的隐患。
其實,更多的時候需要結合現有的系統或者場景進行分析,通過資料監控和分析進行後續優化。畢竟,架構是疊代出來,而不是設計出來的。
(本文完 e-a-20190323 c-14-d 996 這是一篇2019年3月底寫的文章,現在發出來希望還沒有過時)
技術公衆号《Throwable文摘》(id:throwable-doge),不定期推送筆者原創技術文章(絕不抄襲或者轉載):