你好呀,我是歪歪。 前幾天遇到一個生產問題,同一個數據在數據庫里面被插入了兩次,導致后續(xù)處理出現了一些問題。 當時我們首先檢討了自己,沒有做好冪等校驗。甚至還發(fā)現了一個低級錯誤:對應的表,針對訂單號,這個業(yè)務上具有唯一屬性的字段,連唯一索引都沒有加。如果加了唯一索引,也不至于出現落庫兩次的情況。 然
你好呀,我是歪歪。
前幾天遇到一個生產問題,同一個數據在數據庫里面被插入了兩次,導致后續(xù)處理出現了一些問題。
當時我們首先檢討了自己,沒有做好冪等校驗。甚至還發(fā)現了一個低級錯誤:對應的表,針對訂單號,這個業(yè)務上具有唯一屬性的字段,連唯一索引都沒有加。如果加了唯一索引,也不至于出現落庫兩次的情況。
然后拿著數據去問上游系統(tǒng),為什么會出現同一個訂單發(fā)起了兩次的這種異常場景。
上游系統(tǒng)一聽到我們的描述,立馬就站出來解釋:不可能,在沒有人工介入的情況下,同一個單子,我們絕對不可能發(fā)送兩次。在開發(fā)的過程中,我們還特意注意了這個場景。
但是我是不相信他們的“鬼話”,我更覺得這就是他們的一個 BUG。
既然爭執(zhí)不下了,那就拿事實說話。
日志就是事實。
于是我們一起查詢了日志,最后的結果就更加奇怪了。
調用方確實只有一次調用日志。
但是我們接收方卻收到了兩次請求。
通過圖片也能看出來,我們之間是通過 MQ 異步交互的。
所以,自然而然的就把目光放到 MQ 上。
我們使用的 MQ 是一個叫做 SofaMQ 的玩意,比較冷門,但是有螞蟻金服背書。
在官方文檔的“常見問題”部分,有這樣的描述:
這句話你仔細品一下:可以保證消息不丟失,但是無法保證消息不重復。
言外之意是不是就是在說:為了保證消息不丟失,在我拿不準你到底有沒有消費成功的情況下,我有可能針對的同一個消息再次發(fā)送。
再次發(fā)送,那不就是一個消息會被消費多次嗎?
不就是我們遇到的這個問題嗎?
然后我突然想起了一個曾經學過的東西:at least once。
在 MQTT 協(xié)議中,給出了三種傳遞消息時能夠提供的服務質量標準,這三種服務質量從低到高依次是:
同時,在“消息冪等”部分,也特別進行了強調:
為了防止消息重復消費導致業(yè)務處理異常,有必要根據業(yè)務上的唯一 Key 對消息做冪等處理。
雖然我用的這個玩意是一個冷門的 MQ ,但是這個問題和具體使用的哪個 MQ 關系不大,常見的 RabbitM、RocketMQ、Kafka 都有類似的問題,都需要消費端做好冪等處理。
本文就基于這個問題,來討論一下,在“消息可能重復消費”這個場景下,有沒有啥好的解決方案。
前面說了,要處理消息重復消費的場景,最核心的邏輯是需要實現冪等機制。
冪等,這個概念大家應該是比較清晰了。
舉個具體的例子。
比如在支付場景下,消費者消費扣款消息,對一筆訂單執(zhí)行扣款操作,扣款金額為 100 元。
如果因各種原因導致扣款消息重復投遞,比如簡單的一個場景,消費者接受到“扣款金額為 100 元”這個信息,完成消費,還沒來得及告訴 MQ,“老哥,這個消息我已經收到了”,就重啟了。
站在 MQ 的角度,沒有收到回執(zhí),就代表這個消息并沒有消費成功,基于“必須保證消息不丟失的指導思想”,它就會繼續(xù)投遞。
所以消費者會重復消費這個扣款消息。
但是,最終的業(yè)務結果是只扣款一次,扣費 100 元,且用戶的扣款記錄中對應的訂單只有一條扣款流水,不會多次扣除費用。
那么這次扣款操作是符合要求的,整個消費過程實現了消費冪等。
在要求冪等的場景中,我們要找到一個抓手。
比如在這個案例里面,扣款一般來說都會對應一個業(yè)務上的唯一流水號,這個業(yè)務上的唯一流水號,就是抓手,我們可以基于這個流水號來做冪等。
最常規(guī)的方案就是在這個字段上加唯一索引,然后出現重復投遞時,落庫的時候會拋出主鍵沖突的異常。
不要覺得重復投遞是一個小概率事件,就不上心了。我們敲代碼的,不就是要多考慮這些正常流程之外的“小概率事件”嗎,只寫正常流程,誰都會寫。
根據官方的說法,消息重復會發(fā)生在這些場景中:
還是順著前面扣款的例子說。
收到消息之后,我們第一步一般來說是保存信息到數據庫。
save(扣款信息);
現在我們要做冪等,已經找到了扣款唯一流水號這個抓手,那我們的代碼應該怎么寫呢?
扣款信息?=?select(扣款唯一流水號);
if(扣款信息?==?null){
????save(扣款信息);
}
先查詢,再判斷,最后保存。
這個方案,在一般的情況下,能達到冪等的效果。
但是,由于是三步,在并發(fā)場景下,立馬就扛不住了。
而且,消息重復投遞的場景,本來就是在極短的時間內產生的兩條信息。
所以,上面這個方案會出現什么場景呢?
兩個請求,在 select(扣款唯一流水號) 的時候都沒有查詢到數據,擊穿了校驗邏輯,然后兩個請求就都會去落庫。
這個時候怎么辦呢?
很簡單,前面說了,在扣款唯一流水號上加唯一索引,即使兩個請求都去落庫,但是由于有唯一索引,一定只會落一筆數據到數據庫。
另外一個怎么辦?
拋出唯一索引沖突的異常,在程序里面通過捕獲這個異常來控制流程上的后續(xù)運轉。
這個方案,很常見,很常用,實話實說我們用的就是這個方案。
但是既然已經有唯一索引了,那是不是前面的 select 都顯得沒啥卵用了?
我們要從辯證的角度去看待這個問題。
所以,是,也不是。
是的原因是因為前面這一層 select 相當于過濾層,能在一些非并發(fā)的場景下讓程序不拋出唯一索引沖突的異常,顯得更加優(yōu)雅。
不是的原因是因為優(yōu)雅的程度還不夠高,畢竟是通過“異!眮砜刂屏顺绦虻淖呦。
有沒有不拋出異常的方案呢?
也有,也很簡單,上鎖就行了:
扣款信息 = select(扣款唯一流水號);//select *** for update
這樣確實能保證不拋出唯一索引沖突的異常,但是關鍵是一旦涉及到上鎖,性能就拉胯了,為了解決這個偶發(fā)的問題,犧牲了接口的性能,這個路線就走的有點遠了。
所以,上鎖也不夠優(yōu)雅。
什么是真正的優(yōu)雅?
我也不知道,但是我試圖去思考一個相對優(yōu)雅的方案。
首先,我覺得上面的方案,不管是唯一索引,還是上鎖,不夠優(yōu)雅的原因是因為,它們都是在基于業(yè)務表搞事情。
業(yè)務表干得事兒,應該就是業(yè)務上的事兒。
那我問你:消息重復投遞,需要保持冪等,這個屬于業(yè)務上的事兒嗎?
我認為是不屬于的,這是屬于技術上的事情,任何業(yè)務都是可能遇到的。只不過,在前面的方案里面,我們想借用業(yè)務表的能力,來幫我們做一個它可以做,但是本來不該它做的事情。
首先,我們必須要在這一點上達成一致,不然后面的論述就不能展開了。
如果你不這樣認為,那么你可以不用往下看。
我想到的方案是什么呢?
我相信你聽過這樣一句話:計算機領域中的所有問題都可以通過增加一個中間層來解決。
所以,我也想著抽一層。
我還是需要數據庫通過唯一索引來幫我保證只有一條數據被成功落庫,所以我想著抽一個專門的表出來,比如叫做消息消費記錄表。
只要數據插入了這個表,就代表消息被消費了。后續(xù)即使重發(fā),也不會插入成功。
那么怎么來保證這個機制呢?
前面提到的抓手又可以用上了:業(yè)務唯一流水號。
這個消息消費記錄表里面最重要的一個字段,可以叫做“消息唯一標識”,并且作為唯一索引。
這里的這個“消息唯一標識”就是對應業(yè)務唯一流水號。
如果你要基于這個表來實現消息冪等,那么你必須具備這樣的一個業(yè)務唯一流水號,當重復的時候,還是會拋出主鍵沖突異常。
我知道著聽起來就像是脫褲子放屁。
但是,你想想,這個表是完全脫離于業(yè)務的存在。
在前面的解決方案中,你要問別人,你有沒有一張業(yè)務表來做這個事情。
在現在的方案中,你會給別人說,我這里有一個解決方案,你只需要執(zhí)行我給你的 SQL,生成一張消息消費記錄表就行。
這張表是完全獨立于業(yè)務的存在,它只是為了解決消息重復投遞這個共性問題。
從你問別人要,到別人按照你說的做,就這么輕輕的抽一小層,攻守易形了啊,朋友。
它是一種通用的解決方案,一種策略,甚至可以叫做一個框架。
現在,我們可以給它取一個新的名字。
比如:一種基于數據庫唯一索引實現消息冪等的解決方案。
或者:一種分布式系統(tǒng)中數據唯一性的保障策略。
再或者:一個由數據庫約束驅動的消息冪等保護性框架。
好,現在我們有了這么一個“高大上”的通用解決方案了。
到底怎么用呢?
名字很厲害,但是用起來其實也就那么回事兒。
回到前面轉賬的例子,很簡單:
if(保存數據到消息消費記錄表){//出現主鍵沖突就返回false
????save(扣款信息);????
}
這樣,消息防重,由消息消費記錄表來保證。
業(yè)務表,不感知“消息是否重復”的場景。
看起來似乎是優(yōu)雅了那么一點點。
但是,同時帶來了另外一個問題。
又回到了之前“先校驗,再保持”的非原子性的邏輯。
我們想想一個極端場景,如果保存數據到消息消費記錄表成功,還沒來得及 save(扣款信息) ,服務重啟了,怎么辦?
其實換句話說,這兩個信息需要保持一致性。
所以可以加入事務嘛,把這兩步綁定到一起:
開啟事務;
if(保存數據到消息消費記錄表){//出現主鍵沖突就返回false
????save(扣款信息);????
}
提交事務;
這樣,如果保存數據到消息消費記錄表成功,還沒來得及 save(扣款信息) ,服務重啟,事務回滾,消息消費記錄表就不會真的插入成功。
而 MQ 沒有收到這個消息的回執(zhí),也會再次進行投遞。
由于消息消費記錄表里沒有這個數據,所以會再次進行消費。
在上面的這個過程中,MQ 再次投遞,是為了 at least once。
而我們引入了消息消費記錄表,通過唯一索引來保證不重復消費,這個玩意加上 at least once,在業(yè)界有另外一個叫法: exactly-only。
現在,我們通過引入事務來解決了“非原子性”的問題,但是又帶來另外一個問題:事務。
一般來說,大家都是能不使用事務的地方就盡量不使用事務,通過最終一致性來保證數據的完整性。
那現在有沒有不基于事務的解決方案呢?
我想到的是可以在消息消費記錄表里面再引入一個“狀態(tài)字段”,這個字段有三個取值:未消費、消費中、消費完成。
通過維護狀態(tài)的流轉,來代替事務的邏輯。
這個思路來源于我實習的時候,給老師做外包項目。當時我是真的不知道 Spring 的事務怎么用,但是我知道結合當時我開發(fā)的業(yè)務場景,一個數據的狀態(tài)很重要,處理之前把數據的狀態(tài)修改了,但是如果出了異常,應該把狀態(tài)給它恢復回去。
于是我手動寫了這樣的一坨代碼,四處散落在我寫的模塊里面。
后來一個師兄看了我的代碼,提出了應該用事務來保證這樣的邏輯,并給我做了演示,我才去了解了事務相關的東西。
但是有一說一,我后來也思考了,在我那個特定的業(yè)務場景下,通過狀態(tài)的流轉,確實是可以代替事務的存在的。
好,回到我們現在的這個場景中。
一個消息過來的時候,首先根據唯一消息標識獲取對應的數據。
如果沒有獲取到,就初始化為“未消費”狀態(tài)落庫,然后去執(zhí)行具體的業(yè)務邏輯。在業(yè)務邏輯執(zhí)行之前,把狀態(tài)修改為“消費中”,然后在執(zhí)行完成之后,把狀態(tài)修改為“消費完成”。
如果這個消息被重新投遞了,那么根據唯一消息標識就能獲取到對應的數據,接著檢查這個消息的狀態(tài)。如果是“消費完成”,直接就丟掉。
但是上面的描述只是描述了最簡單的場景,一些復雜場景下狀態(tài)的流轉和判斷應該怎么做,我確實還沒想好。
所以就當是個課后習題吧,你去推一推,看看用狀態(tài)流轉代替事務的方式是否能成功落地。
學會了記得回來教我。