點(diǎn)關(guān)注,不迷路;持續(xù)更新Java架構(gòu)相關(guān)技術(shù)及資訊熱文?。?!
前提
最近,工作中要為現(xiàn)在的老系統(tǒng)做拆分和升級(jí),剛好遇到了分布式事務(wù)、冪等控制、異步消息亂序和補(bǔ)償方案等問題,剛好基于實(shí)踐結(jié)合個(gè)人的看法記錄一下一些方案和思路。
分布式事務(wù)
首先,做系統(tǒng)拆分的時(shí)候幾乎都會(huì)遇到分布式事務(wù)的問題,一個(gè)仿真的案例如下:

項(xiàng)目初期,由于用戶體量不大,訂單模塊和錢包模塊共庫共應(yīng)用(大war包時(shí)代),模塊調(diào)用可以簡化為本地事務(wù)操作,這樣做只要不是程序本身的BUG,基本可以避免數(shù)據(jù)不一致。后面因?yàn)橛脩趔w量越發(fā)增大,基于容錯(cuò)、性能、功能共享等考慮,把原來的應(yīng)用拆分為訂單微服務(wù)和錢包微服務(wù),兩個(gè)服務(wù)之間通過非本地事務(wù)操(這里可以是HTTP或者消息隊(duì)列等)進(jìn)行數(shù)據(jù)同步,這個(gè)時(shí)候就很有可能由于異常場景出現(xiàn)數(shù)據(jù)不一致的情況。
事務(wù)中直接RPC調(diào)用達(dá)到強(qiáng)一致性
以上面的訂單微服務(wù)請(qǐng)求錢包微服務(wù)進(jìn)行扣款并更新訂單狀態(tài)為扣款這個(gè)調(diào)用過程為例,假設(shè)采用HTTP同步調(diào)用,項(xiàng)目如果由經(jīng)驗(yàn)不足的開發(fā)者開發(fā)這個(gè)邏輯,可能會(huì)出現(xiàn)下面的偽代碼:
[訂單微服務(wù)請(qǐng)求錢包微服務(wù)進(jìn)行扣款并更新訂單狀態(tài)]
處理訂單微服務(wù)請(qǐng)求錢包微服務(wù)進(jìn)行扣款并更新訂單狀態(tài)方法(){
[開啟事務(wù)]
1、查詢訂單
2、HTTP調(diào)用錢包微服務(wù)扣款
3、更新訂單狀態(tài)為扣款成功
[提交事務(wù)]
}
這是一個(gè)從肉眼上看起來沒有什么問題的解決方法,HTTP調(diào)用直接嵌入到事務(wù)代碼塊內(nèi)部,猜想最初開發(fā)者的想法是:HTTP調(diào)用失敗拋出異常會(huì)導(dǎo)致事務(wù)回滾,用戶重試即可;HTTP調(diào)用成功,事務(wù)正常提交,業(yè)務(wù)正常完成。這種做法看似可取,但是帶來了極大的隱患,根本原因是:事務(wù)中嵌入了RPC調(diào)用。假設(shè)兩種比較常見的情況:
上面方法中第2步由于錢包微服務(wù)本身各種原因?qū)е驴劭罱涌陧憫?yīng)極慢,會(huì)導(dǎo)致上面的處理方法事務(wù)(準(zhǔn)確來說是數(shù)據(jù)庫連接)長時(shí)間掛起,持有的數(shù)據(jù)庫連接無法釋放,會(huì)導(dǎo)致數(shù)據(jù)庫連接池的連接耗盡,很容易導(dǎo)致訂單微服務(wù)的其他依賴數(shù)據(jù)庫的接口無法響應(yīng)。
錢包微服務(wù)是單節(jié)點(diǎn)部署(并不是所有的公司微服務(wù)都做得很完善),升級(jí)期間應(yīng)用停機(jī),上面方法中第2步接口調(diào)用直接失敗,這樣會(huì)導(dǎo)致短時(shí)間內(nèi)所有的事務(wù)都回滾,相當(dāng)于訂單微服務(wù)的扣款入口是不可用的。
網(wǎng)絡(luò)是不可靠的,HTTP調(diào)用或者接受響應(yīng)的時(shí)候如果出現(xiàn)網(wǎng)絡(luò)閃斷有可能出現(xiàn)了服務(wù)間狀態(tài)不能互相明確的情況,例如訂單微服務(wù)調(diào)用錢包微服務(wù)成功,接受響應(yīng)的時(shí)候出現(xiàn)網(wǎng)絡(luò)問題,會(huì)出現(xiàn)扣款成功但是訂單狀態(tài)沒有更新的可能(訂單微服務(wù)事務(wù)回滾)。

盡管現(xiàn)在有Hystrix等框架可以基于線程池隔離調(diào)用或者基于熔斷器快速失敗,但是這是收效甚微的。因此,個(gè)人認(rèn)為事務(wù)中直接RPC調(diào)用達(dá)到強(qiáng)一致性是完全不可取的,如果使用了這種方式實(shí)現(xiàn)”分布式事務(wù)”建議整改,否則只能每天祈求下游服務(wù)或者網(wǎng)絡(luò)不出現(xiàn)任何問題。
事務(wù)中進(jìn)行異步消息推送
使用消息隊(duì)列進(jìn)行服務(wù)之間的調(diào)用也是常見的方式之一,但是使用消息隊(duì)列交互本質(zhì)是異步的,無法感知下游消息消費(fèi)方是否正常處理消息。用前一節(jié)的例子,假設(shè)采用消息隊(duì)列異步調(diào)用,項(xiàng)目如果由經(jīng)驗(yàn)不足的開發(fā)者開發(fā)這個(gè)邏輯,可能會(huì)出現(xiàn)下面的偽代碼:
[訂單微服務(wù)請(qǐng)求錢包微服務(wù)進(jìn)行扣款并更新訂單狀態(tài)]
處理訂單微服務(wù)請(qǐng)求錢包微服務(wù)進(jìn)行扣款并更新訂單狀態(tài)方法(){
[開啟事務(wù)]
1、查詢訂單
2、推送錢包微服務(wù)扣款消息(推送消息)
3、更新訂單狀態(tài)為扣款成功
[提交事務(wù)]
}
上面的處理方法如果抽象一點(diǎn)表示如下:
方法(){
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);
}
}
這樣做,在正常情況下,也就是能夠正常調(diào)用消息隊(duì)列中間件推送消息成功的情況下,事務(wù)是能夠正確提交的。但是存在兩個(gè)明顯的問題:
消息隊(duì)列中間件出現(xiàn)了異常,無法正常調(diào)用,常見的情況是網(wǎng)絡(luò)原因或者消息隊(duì)列中間件不可用,會(huì)導(dǎo)致異常從而使得事務(wù)回滾。這種情況看起來似乎合情合理,但是仔細(xì)想:為什么消息隊(duì)列中間件調(diào)用異常會(huì)導(dǎo)致業(yè)務(wù)事務(wù)回滾,如果中間件不恢復(fù),這個(gè)接口調(diào)用豈不是相當(dāng)于不可用?
如果消息隊(duì)列中間件正常,消息正常推送,但是第3步由于SQL存在語法錯(cuò)誤導(dǎo)致事務(wù)回滾,這樣就會(huì)出現(xiàn)了下游微服務(wù)被調(diào)用成功,本地事務(wù)卻回滾的問題,導(dǎo)致了上下游系統(tǒng)數(shù)據(jù)不一致。

總的來說:事務(wù)中進(jìn)行異步消息推送是一種并不可靠的實(shí)現(xiàn)。
目前業(yè)界提供的解決方案
業(yè)界目前主流的分布式事務(wù)解決方案主要有:多階段提交方案(2PC、3PC)、補(bǔ)償事務(wù)(TCC)和消息事務(wù)(主要是RocketMQ,基本思想也是多階段提交方案,其他消息隊(duì)列中間件并沒有實(shí)現(xiàn)分布式事務(wù))。這些方案的原理在此處不展開,目前網(wǎng)絡(luò)中相應(yīng)資料比較多,小結(jié)一下它們的特點(diǎn):
多階段提交方案:常見的有二階段和三階段提交事務(wù),需要額外的資源管理器來協(xié)調(diào)事務(wù),數(shù)據(jù)一致性強(qiáng),但是實(shí)現(xiàn)方案比較復(fù)雜,對(duì)性能的犧牲比較大(主要是需要對(duì)資源鎖定,等待所有事務(wù)提交才能解鎖),不適用于高并發(fā)的場景,目前比較知名的有阿里開源的fescar。
補(bǔ)償事務(wù):一般也叫TCC,因?yàn)槊總€(gè)事務(wù)操作都需要提供三個(gè)操作嘗試(Try)、確認(rèn)(Confirm)和補(bǔ)償/撤銷(Cancel),數(shù)據(jù)一致性的強(qiáng)度比多階段提交方案低,但是實(shí)現(xiàn)的復(fù)雜度會(huì)有所降低,比較明顯的缺陷是每個(gè)業(yè)務(wù)事務(wù)需要實(shí)現(xiàn)三組操作,有可能出現(xiàn)過多的補(bǔ)償方案的代碼;另外有很多場景TCC是不合適的。
消息事務(wù):這里只談RocketMQ的實(shí)現(xiàn),一個(gè)事務(wù)的執(zhí)行流程包括:發(fā)送預(yù)消息、執(zhí)行本地事務(wù)、確認(rèn)消息發(fā)送成功。它的消息中間件存儲(chǔ)了下游無法消費(fèi)成功的消息,并且不斷重試推送下游消費(fèi)消息,而生產(chǎn)者(上游)需要提供一個(gè)check接口,用于檢查成功發(fā)送預(yù)消息但是未確認(rèn)最終消息發(fā)送狀態(tài)的事務(wù)的狀態(tài)。
項(xiàng)目實(shí)踐中最終使用的方案
個(gè)人所在的公司的技術(shù)棧中沒有使用RocketMQ,主要使用RabbitMQ,所以需要針對(duì)RabbitMQ做消息事務(wù)的適配。目前業(yè)務(wù)系統(tǒng)中消息異步交互存在三種場景:
消息推送實(shí)時(shí)性高,可以接受丟失。
消息推送實(shí)時(shí)性低,不能丟失。
消息推送實(shí)時(shí)性高,不能丟失。
最終敲定使用了本地消息表的解決方案,這個(gè)方案十分簡單:

主要思路是:
需要發(fā)送到消費(fèi)方的消息的保存和業(yè)務(wù)處理綁定在同一個(gè)本地事務(wù)中,需要額外建立一張本地消息表。
本地事務(wù)提交之后,可以在事務(wù)外對(duì)本地消息表進(jìn)行查詢并且進(jìn)行消息推送,或者采用定時(shí)調(diào)度輪詢本地消息表進(jìn)行消息推送。
下游服務(wù)消費(fèi)消息成功可以回調(diào)一個(gè)確認(rèn)到上游服務(wù),這樣就可以從上游服務(wù)的本地消息表刪除對(duì)應(yīng)的消息記錄。
偽代碼如下:
[消息推送實(shí)時(shí)性高,可以接受丟失-這種情況下可以不需要寫入本地消息表 - start]
處理方法(){
[本地事務(wù)開始]
1、處理業(yè)務(wù)操作
[本地事務(wù)提交]
2、組裝推送消息并且進(jìn)行推送
}
[消息推送實(shí)時(shí)性高,可以接受丟失-這種情況下可以不需要寫入本地消息表 - end]
[消息推送實(shí)時(shí)性低,不能丟失 - start]
處理方法(){
[本地事務(wù)開始]
1、處理業(yè)務(wù)操作
2、組裝推送消息并且寫入到本地消息表
[本地事務(wù)提交]
}
消息推送調(diào)度模塊(){
3、查詢本地消息表待推送數(shù)據(jù)進(jìn)行推送
}
[消息推送實(shí)時(shí)性低,不能丟失 - end]
[消息推送實(shí)時(shí)性高,不能丟失 - start]
處理方法(){
[本地事務(wù)開始]
1、處理業(yè)務(wù)操作
2、組裝推送消息并且寫入到本地消息表
[本地事務(wù)提交]
3、消息推送
}
消息推送調(diào)度模塊(){
4、查詢本地消息表待推送數(shù)據(jù)進(jìn)行推送
}
[消息推送實(shí)時(shí)性高,不能丟失 - end]
- 對(duì)于”消息推送實(shí)時(shí)性高,可以接受丟失”這種情況,實(shí)際上不用依賴本地消息表,只要在業(yè)務(wù)操作事務(wù)提交之后組裝和推送消息即可,這種情況會(huì)存在因?yàn)橄㈥?duì)列中間件不可用或者本地應(yīng)用宕機(jī)導(dǎo)致消息丟失的問題(本質(zhì)是因?yàn)閿?shù)據(jù)是內(nèi)存態(tài),非持久化),可靠性不高,但是絕大多數(shù)情況下是沒有問題的。如果使用spring-tx的聲明式事務(wù)@Transactional或者編程式事務(wù)TransactionTemplate,可以使用事務(wù)同步器實(shí)現(xiàn)嵌入于業(yè)務(wù)操作事務(wù)代碼塊中的RPC操作延后到事務(wù)提交后執(zhí)行,這樣子RPC調(diào)用的代碼物理位置就可以放置在事務(wù)代碼塊內(nèi),例如:
@Transactional(rollbackFor = RuntimeException.class)
public void process(){
1.處理業(yè)務(wù)邏輯
TransactionSynchronizationManager.getSynchronizations().add(new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
2.進(jìn)行消息推送
}
});
}
對(duì)于使用到本地消息表的場景,需要警惕下面幾個(gè)問題:
注意本地消息表盡量不要長時(shí)間積壓數(shù)據(jù),推送成功的數(shù)據(jù)需要及時(shí)刪除。
本地消息表的數(shù)據(jù)在查詢并且推送的時(shí)候,需要設(shè)計(jì)最大重試次數(shù)上限,達(dá)到上限仍然推送失敗的記錄需要進(jìn)行預(yù)警和人為干預(yù)。
如果入庫的消息體比較大,查詢可能消耗的IO比較大,需要考慮拆分單獨(dú)的一張消息內(nèi)容表用于存放消息體內(nèi)容,而經(jīng)常更變的列應(yīng)該單獨(dú)拆分到另外一張表。
例如本地消息表的設(shè)計(jì)如下:
CREATE TABLE `t_local_message`(
id BIGINT PRIMARY KEY COMMENT '主鍵',
module INT NOT NULL COMMENT '消息模塊',
tag VARCHAR(20) NOT NULL COMMENT '消息標(biāo)簽',
business_key VARCHAR(60) NOT NULL COMMENT '業(yè)務(wù)鍵',
queue VARCHAR(60) NOT NULL COMMENT '隊(duì)列',
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 '重試次數(shù)',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創(chuàng)建日期時(shí)間',
edit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改日期時(shí)間',
seq_no VARCHAR(60) NOT NULL COMMENT '流水號(hào)',
message_status TINYINT NOT NULL DEFAULT 0 COMMENT '消息狀態(tài)',
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 '消息內(nèi)容',
UNIQUE uniq_message_id(message_id)
)COMMENT '本地消息內(nèi)容表';
分布式事務(wù)小結(jié)
個(gè)人認(rèn)為,解決分布式事務(wù)的最佳實(shí)踐就是:
規(guī)避使用強(qiáng)一致性的分布式事務(wù)實(shí)現(xiàn),基本觀念就是放棄ACID投奔BASE。
推薦使用消息隊(duì)列進(jìn)行系統(tǒng)間的解耦,消息推送方為了確保消息推送成功可以獨(dú)立附加消息表把需要推送的消息和業(yè)務(wù)操作綁定在同一個(gè)事務(wù)內(nèi),使用異步或者調(diào)度的方式進(jìn)行推送。
消息推送方(上游)需要確保消息正確投遞到消息隊(duì)列中間件,消息消費(fèi)或者補(bǔ)償方案由消息消費(fèi)方(下游)自行解決,關(guān)于這一點(diǎn)后文一個(gè)章節(jié)專門解釋。
其實(shí),對(duì)于一致性和實(shí)時(shí)性要求相對(duì)較高的分布式事務(wù)的實(shí)現(xiàn),使用消息隊(duì)列解耦也有對(duì)應(yīng)的解決方案。
冪等控制
冪等(idempotence)這個(gè)術(shù)語原文來自于HTTP/1.1協(xié)議中的定義:
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.
簡單來說就是:除了錯(cuò)誤或者過期的請(qǐng)求(換言之就是成功的請(qǐng)求),無論多次調(diào)用還是單次調(diào)用最終得到的效果是一致的。通俗來說,有一次調(diào)用成功,采用相同的請(qǐng)求參數(shù)無論調(diào)用多少次(重復(fù)提交)都應(yīng)該返回成功。
下游服務(wù)對(duì)外提供服務(wù)接口,必須承諾實(shí)現(xiàn)接口的冪等性,這一點(diǎn)在分布式系統(tǒng)中極其重要。
對(duì)于HTTP調(diào)用,承諾冪等性可以避免表單或者請(qǐng)求操作重復(fù)提交造成業(yè)務(wù)數(shù)據(jù)重復(fù)。
對(duì)于異步消息調(diào)用,承諾冪等性通過對(duì)消息去重處理也是用于避免重復(fù)消費(fèi)造成業(yè)務(wù)數(shù)據(jù)重復(fù)。
目前實(shí)踐中對(duì)于冪等的處理使用了下面三個(gè)方面的控制:
實(shí)現(xiàn)冪等的接口調(diào)用時(shí)入口使用分布式鎖,使用了主流的Redisson,控制鎖的粒度和鎖的等待、持有時(shí)間在合理范圍(筆者所在行業(yè)要求數(shù)據(jù)必須準(zhǔn)確無誤,所以幾乎用悲觀鎖設(shè)計(jì)所有核心接口,寧愿慢也不能錯(cuò),實(shí)際上如果沖突比較低的時(shí)候?yàn)榱诵阅軆?yōu)化可以考慮使用樂觀鎖)。
業(yè)務(wù)邏輯上的防重,例如創(chuàng)建訂單的接口先做一步通過訂單號(hào)查詢庫表中是否已經(jīng)存在對(duì)應(yīng)的訂單,如果存在則不做處理直接返回成功。
數(shù)據(jù)庫表設(shè)計(jì)對(duì)邏輯上唯一的業(yè)務(wù)鍵做唯一索引,這個(gè)是通過數(shù)據(jù)庫層面做最后的保障。
舉一個(gè)基于消息消費(fèi)冪等控制的偽代碼例子:
[處理消息消費(fèi)]
listen(request){
1、通過業(yè)務(wù)鍵構(gòu)建分布式鎖的KEY
2、通過Redisson構(gòu)建分布式鎖并且加鎖
3、加鎖代碼中執(zhí)行業(yè)務(wù)邏輯(包括去重判斷、事務(wù)操作和非事務(wù)操作等)
4、finally代碼塊中釋放分布式鎖
}
補(bǔ)償方案
補(bǔ)償方案主要是HTTP同步調(diào)用的補(bǔ)償和異步消息消費(fèi)失敗的補(bǔ)償。
HTTP同步調(diào)用補(bǔ)償
一般情況下,HTTP同步調(diào)用會(huì)得到下游系統(tǒng)的同步結(jié)果,對(duì)結(jié)果的處理存在下面幾種常見的情況:
同步結(jié)果返回正常,得到了和下游約定的最終狀態(tài),交互結(jié)束,一般認(rèn)為成功就是最終狀態(tài),不需要補(bǔ)償。
同步結(jié)果返回正常,得到了和下游約定的非最終狀態(tài),需要定時(shí)補(bǔ)償?shù)阶罱K狀態(tài)或到達(dá)重試上限自行標(biāo)記為最終狀態(tài)。
同步結(jié)果返回異常,最常見的是下游服務(wù)不可用返回HTTP狀態(tài)碼為5XX。
首先要有一個(gè)簡單的認(rèn)知:短時(shí)間內(nèi)的HTTP重試通常情況下都是無效的。如果是瞬時(shí)的網(wǎng)絡(luò)抖動(dòng),短時(shí)間內(nèi)HTTP同步重試是可行的,大部分情況下是下游服務(wù)無法響應(yīng)、下游服務(wù)重啟中或者復(fù)雜的網(wǎng)絡(luò)情況導(dǎo)致短時(shí)間內(nèi)無法恢復(fù),這個(gè)時(shí)候做HTTP同步重試調(diào)用往往是無效的。
如果面對(duì)的場景是內(nèi)部低并發(fā)量的系統(tǒng)之間的進(jìn)行HTTP交互,可以考慮使用基于指數(shù)退避的算法進(jìn)行重試,舉個(gè)例子:
1、第一次調(diào)用失敗,馬上進(jìn)行第二次重試
2、第二次重試失敗,線程休眠2秒
3、第三次重試失敗,線程休眠4秒(2^2)
4、第四次重試失敗,線程休眠8秒(2^3)
5、第五次重試失敗,拋出異常
如果上面的例子中使用了Hystrix控制超時(shí)為1秒包裹著要執(zhí)行的HTTP命令進(jìn)行調(diào)用,上面的重試過程最大耗時(shí)小于20秒,在低并發(fā)的內(nèi)部系統(tǒng)之間的交互是可以接受的。
但是,如果面對(duì)的是并發(fā)比較高、用戶體驗(yàn)優(yōu)先級(jí)比較高的場景,這樣做顯然是不合理的。為了穩(wěn)妥起見,可以采取相對(duì)傳統(tǒng)而有效的方案:HTTP調(diào)用的調(diào)用信息快照內(nèi)容保存到一張本地重試表中,這個(gè)保存操作綁定在業(yè)務(wù)處理的事務(wù)中,通過定時(shí)調(diào)度對(duì)未調(diào)用成功的記錄進(jìn)行重試。這個(gè)方案和上文提到保證消息推送成功的方案類似,舉一個(gè)仿真的例子:
[下單接口請(qǐng)求下游錢包服務(wù)扣錢的過程]
process(){
[事務(wù)代碼塊-start]
1、處理業(yè)務(wù)邏輯,保存訂單信息,訂單狀態(tài)為扣錢處理中
2、組裝將要向下游錢包服務(wù)發(fā)起的HTTP調(diào)用信息,保存在本地表中
[事務(wù)代碼塊-end]
3、事務(wù)外進(jìn)行HTTP調(diào)用(OkHttp客戶端或者Apache的Http客戶端),調(diào)用成功更新訂單狀態(tài)為扣錢成功
}
定時(shí)調(diào)度(){
4、定時(shí)查詢訂單狀態(tài)為扣錢處理中的訂單進(jìn)行HTTP調(diào)用,調(diào)用成功更新訂單狀態(tài)為扣錢成功
}
異步消息消費(fèi)失敗補(bǔ)償
異步消息消費(fèi)失敗的場景發(fā)生只能在消息消費(fèi)方,也就是下游服務(wù)。從降低成本的目的上看,消息消費(fèi)失敗的補(bǔ)償應(yīng)該由消息處理的一方(消費(fèi)者)自行承擔(dān),畫一個(gè)系統(tǒng)交互圖理解一下:

如果由上游服務(wù)進(jìn)行補(bǔ)償,存在兩個(gè)明顯的問題:
消息補(bǔ)償模塊需要在所有的上游服務(wù)中編寫,這是不合理的。
一旦下游消費(fèi)出現(xiàn)生產(chǎn)問題需要上游補(bǔ)償,需要先定位出對(duì)應(yīng)的消息是哪個(gè)上游服務(wù)推送,然后通過該上游服務(wù)進(jìn)行補(bǔ)償,處理生產(chǎn)問題的復(fù)雜度提高。
在最近的一些項(xiàng)目實(shí)踐中,確定在使用異步消息交互的時(shí)候,補(bǔ)償統(tǒng)一由消息消費(fèi)方實(shí)現(xiàn)。最簡單的方式也是使用類似本地消息表的方式,把消費(fèi)失敗的消息入庫,并且進(jìn)行重試,到達(dá)重試上限依然失敗則進(jìn)行預(yù)警和人工介入即可。簡單的流程圖如下:

異步消息亂序解決
異步消息亂序是使用消息隊(duì)列進(jìn)行異步交互場景中需要考慮和解決的問題。下面舉一些可能不合乎實(shí)際但是能夠說明問題的例子。
場景一:上游某個(gè)服務(wù)向用戶服務(wù)通過消息隊(duì)列異步修改用戶的性別信息,假設(shè)消息簡化如下:
隊(duì)列:user-service.modify.sex.qeue
消息:
{
"userId": 長整型,
"sex": 字符串,可選值是MAN、WOMAN和UNKNOW
}
用戶服務(wù)一共使用了10個(gè)消費(fèi)者線程監(jiān)聽user-service.modify.sex.qeue隊(duì)列。假設(shè)上游服務(wù)先后向user-service.modify.sex.qeue隊(duì)列推送下面兩條消息:
第一條消息:
{
"userId": 1,
"sex": "MAN"
}
第二條消息:
{
"userId": 1,
"sex": "WOMAN"
}
上面的消息推送和下游處理有比較高幾率出現(xiàn)下面的情況:

原本用戶ID為1的用戶先把性別改為MAN(第一次請(qǐng)求),后來改為WOMAN(第二次請(qǐng)求),最終看到更新后的性別有可能是MAN,這顯然是不合理的。這個(gè)不是很合理的例子想說明的問題是:通過異步消息交互,下游服務(wù)處理消息的時(shí)序有可能和上游發(fā)送消息的時(shí)序并不一致,這樣有可能導(dǎo)致業(yè)務(wù)狀態(tài)錯(cuò)亂。對(duì)于解決這個(gè)問題,提供幾個(gè)可行的思路:
方案一:并發(fā)要求不高的情況下,可以充分利用消息隊(duì)列FIFO的特性(這一點(diǎn)RabbitMQ實(shí)現(xiàn)了,其他消息隊(duì)列中間件不確定),把下游服務(wù)的消費(fèi)線程設(shè)置為1即可,那么上游推送的消息和下游消費(fèi)消息的時(shí)序是一致的(這個(gè)方案有缺陷,因?yàn)榉植际蕉喙?jié)點(diǎn)部署,下游服務(wù)節(jié)點(diǎn)會(huì)超過兩個(gè),因此消費(fèi)者線程一定會(huì)大于1)。
方案二:使用HTTP調(diào)用,這個(gè)要前端或者APP客戶端配合,請(qǐng)求設(shè)計(jì)成串行的即可。
方案三:這是一個(gè)實(shí)驗(yàn)性方案,個(gè)人只在Demo中嘗試過,采用取模或者Hash對(duì)隊(duì)列進(jìn)行分片,上面的例子基于用戶ID % 10取模建立queue_0到queue_9一共10個(gè)隊(duì)列,下游啟動(dòng)10個(gè)消費(fèi)者線程,queue_0到queue_9每個(gè)隊(duì)列只啟動(dòng)一個(gè)消費(fèi)線程(例如下游服務(wù)有雙節(jié)點(diǎn),節(jié)點(diǎn)1消費(fèi)queue_0到queue_4,節(jié)點(diǎn)2消費(fèi)queue_5到queue_9),這樣子能夠保證單個(gè)用戶ID的性別更新操作是串行的。
場景二:沒有時(shí)序要求的異步消息處理,但是要求最終展示的時(shí)候是有時(shí)序的。這樣說可能有點(diǎn)抽象,舉個(gè)例子:在借唄上借了10000元,還款的時(shí)候,用戶是分多次還清(例如還款方案一:2000,3000,5000;還款方案二:1000,1000,1000,7000等等),每次還的錢都不一樣,最終要求賬單展示的時(shí)候是按照用戶的還款操作順序。
假設(shè)借唄的上游服務(wù)和它通過異步消息交互。詳細(xì)分析一下:這個(gè)場景其實(shí)對(duì)于借唄(主要是考慮收回用戶的還款這個(gè)目的)來說,對(duì)用戶還款的順序并不需要感知,只需要考慮用戶是否還清,但是使用異步交互,有可能導(dǎo)致下游無法正確得知用戶還款的操作順序。
解決方案很簡單:推送消息的時(shí)候附加一個(gè)帶有增長或者減少趨勢(shì)的標(biāo)記位即可,例如使用帶有時(shí)間戳的標(biāo)記位或者使用Snowflake算法生成自增趨勢(shì)的長整型數(shù)作為流水號(hào),之后按照流水號(hào)排序即可得到消息操作的順序(這個(gè)流水號(hào)下游需要保存),但是實(shí)際消息處理的時(shí)候并不需要感知消息的時(shí)序。
異步消息結(jié)合狀態(tài)驅(qū)動(dòng)
個(gè)人認(rèn)為:異步消息結(jié)合狀態(tài)驅(qū)動(dòng)是可以相對(duì)完善地解決分布式事務(wù),結(jié)合預(yù)處理(例如預(yù)扣除或者預(yù)增長)可以滿足比較高一致性和實(shí)時(shí)性。先引出一個(gè)經(jīng)常用來討論分布式事務(wù)強(qiáng)一致性的轉(zhuǎn)賬場景。

解決這個(gè)問題如果使用同步調(diào)用(其實(shí)像TCC、2PC或者3PC等本質(zhì)都是同步調(diào)用),在允許性能損失的情況下是能夠達(dá)到比較高的一致性。這一節(jié)并不討論同步調(diào)用的情況下怎么做,重點(diǎn)研究一下在使用消息隊(duì)列的情況下,如何從BASE的角度”達(dá)到比較高的一致性”。先把這個(gè)例子抽象化,假設(shè)兩個(gè)系統(tǒng)的賬戶表都設(shè)計(jì)成這樣:
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 '創(chuàng)建時(shí)間',
edit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改時(shí)間',
version BIGINT NOT NULL DEFAULT 0 COMMENT '版本'
// 省略索引
)COMMENT '賬戶表';
兩個(gè)系統(tǒng)都可以建立一張表結(jié)構(gòu)相似的金額變更流水表,上游系統(tǒng)用于做預(yù)扣操作和流水記錄,下游系統(tǒng)用于做流水記錄,接著我們可以梳理出新的交互時(shí)序邏輯如下:
[A系統(tǒng)本地事務(wù)-start]
1、A系統(tǒng)t_account表X用戶余額減去1000
2、A系統(tǒng)流水表寫入一條用戶X的預(yù)扣1000的記錄,標(biāo)記狀態(tài)為處理中,生成全局唯一的流水號(hào)記為SEQ_NO
[A系統(tǒng)本地事務(wù)-end]
3、A系統(tǒng)通過消息隊(duì)列推送一條用戶X扣減1000的消息(一定要附帶流水號(hào)SEQ_NO)到消息隊(duì)列中間件(這里可以用上文提到的技巧確保消息推送成功)
[B系統(tǒng)本地事務(wù)-start]
4、B系統(tǒng)t_account表X用戶余額加上1000
5、B系統(tǒng)流水表寫入一條用戶X的余額變更(增加)1000的記錄 <= 注意這里B系統(tǒng)的流水只能insert不能update
[B系統(tǒng)本地事務(wù)-end]
6、B系統(tǒng)推送處理X用戶余額處理成功的消息到消息隊(duì)列中間件,一定要附帶流水號(hào)SEQ_NO(這里可以用上文提到的技巧確保消息推送成功)
[A系統(tǒng)本地事務(wù)-start]
7、A系統(tǒng)更新流水表中X用戶流水號(hào)為SEQ_NO的預(yù)扣記錄的狀態(tài)為處理成功(這一步一定要做好冪等控制,可以考慮用SEQ_NO作為分布式鎖的KEY)
[A系統(tǒng)本地事務(wù)-end]
其他:
[A系統(tǒng)流水表處理中的記錄需要定時(shí)輪詢和重試]
1、定時(shí)調(diào)度重試A系統(tǒng)流水表中狀態(tài)為處理中的記錄
[A-B系統(tǒng)日切對(duì)賬模塊]
1、日切,用A系統(tǒng)中處理成功的T-1日流水記錄和B系統(tǒng)中的流水表所有T-1日的記錄進(jìn)行對(duì)賬

上面的步驟看起來比較多,而且還需要編寫對(duì)賬和重試模塊。其實(shí),在上下游系統(tǒng)、消息隊(duì)列中間件都正常運(yùn)作的情況下,上面的這套交互方案可承受的并發(fā)量遠(yuǎn)比同步方案高,出現(xiàn)了服務(wù)或者消息隊(duì)列中間件不可用的情況下,由于流水表有未處理的本地記錄,在這些問題恢復(fù)之后可以重試,可靠性也是比較高的。另外,重試和對(duì)賬的模塊,對(duì)于所有涉及金額交易的處理都是必須的,這一點(diǎn)其實(shí)選用同步或者異步交互方式并沒有關(guān)系(時(shí)序圖只展示了一般和正常交互情況下的調(diào)用時(shí)序,異常情況例如超額轉(zhuǎn)賬、調(diào)用鏈中某個(gè)環(huán)節(jié)失敗等細(xì)節(jié)等暫不分析)。
小結(jié)
你會(huì)發(fā)覺,通篇文章有很多方案都是使用了待處理內(nèi)容寫入本地表 + 事務(wù)外實(shí)時(shí)觸發(fā) + 定時(shí)調(diào)度補(bǔ)償這個(gè)模式,其實(shí)我想表達(dá)的就是這個(gè)模式是目前分布式解決方案中一個(gè)相對(duì)通用的模式,可以基本滿足分布式事務(wù)、同步異步補(bǔ)償、實(shí)時(shí)非實(shí)時(shí)觸發(fā)等多種復(fù)雜場景的處理。這個(gè)模式也存在一些明顯的問題(如果實(shí)踐過的話一般會(huì)遇到):
庫表(本地消息表)設(shè)計(jì)不合理或者處理不合理容易成為數(shù)據(jù)庫的瓶頸。
補(bǔ)償或者本地表入庫處理的邏輯代碼容易冗余和腐化。
極端情況下,異?;謴?fù)的場景存在拖垮服務(wù)的隱患。
其實(shí),更多的時(shí)候需要結(jié)合現(xiàn)有的系統(tǒng)或者場景進(jìn)行分析。畢竟,架構(gòu)是迭代出來,而不是設(shè)計(jì)出來的。
寫在最后
點(diǎn)關(guān)注,不迷路;持續(xù)更新Java架構(gòu)相關(guān)技術(shù)及資訊熱文?。?!