哎,被這個叫做at least once的玩意坑麻了。

why技术發表於2024-06-17

你好呀,我是歪歪。

前幾天遇到一個生產問題,同一個資料在資料庫裡面被插入了兩次,導致後續處理出現了一些問題。

當時我們首先檢討了自己,沒有做好冪等校驗。甚至還發現了一個低階錯誤:對應的表,針對訂單號,這個業務上具有唯一屬性的欄位,連唯一索引都沒有加。如果加了唯一索引,也不至於出現落庫兩次的情況。

然後拿著資料去問上游系統,為什麼會出現同一個訂單發起了兩次的這種異常場景。

上游系統一聽到我們的描述,立馬就站出來解釋:不可能,在沒有人工介入的情況下,同一個單子,我們絕對不可能傳送兩次。在開發的過程中,我們還特意注意了這個場景。

但是我是不相信他們的“鬼話”,我更覺得這就是他們的一個 BUG。

既然爭執不下了,那就拿事實說話。

日誌就是事實。

於是我們一起查詢了日誌,最後的結果就更加奇怪了。

呼叫方確實只有一次呼叫日誌。

但是我們接收方卻收到了兩次請求。

透過圖片也能看出來,我們之間是透過 MQ 非同步互動的。

所以,自然而然的就把目光放到 MQ 上。

我們使用的 MQ 是一個叫做 SofaMQ 的玩意,比較冷門,但是有螞蟻金服背書。

在官方文件的“常見問題”部分,有這樣的描述:

這句話你仔細品一下:可以保證訊息不丟失,但是無法保證訊息不重複。

言外之意是不是就是在說:為了保證訊息不丟失,在我拿不準你到底有沒有消費成功的情況下,我有可能針對的同一個訊息再次傳送。

再次傳送,那不就是一個訊息會被消費多次嗎?

不就是我們遇到的這個問題嗎?

然後我突然想起了一個曾經學過的東西:at least once。

在 MQTT 協議中,給出了三種傳遞訊息時能夠提供的服務質量標準,這三種服務質量從低到高依次是:

  • At most once: 至多一次。訊息在傳遞時,最多會被送達一次。換一個說法就是,沒什麼訊息可靠性保證,允許丟訊息。一般都是一些對訊息可靠性要求不太高的監控場景使用,比如每分鐘上報一次機房溫度資料,可以接受資料少量丟失。
  • At least once:至少一次。訊息在傳遞時,至少會被送達一次。也就是說,不允許丟訊息,但是允許有少量重複訊息出現。
  • Exactly once:恰好一次。訊息在傳遞時,只會被送達一次,不允許丟失也不允許重複,這個是最高的等級

同時,在“訊息冪等”部分,也特別進行了強調:

https://help.aliyun.com/document_detail/146983.html

為了防止訊息重複消費導致業務處理異常,有必要根據業務上的唯一 Key 對訊息做冪等處理。

雖然我用的這個玩意是一個冷門的 MQ ,但是這個問題和具體使用的哪個 MQ 關係不大,常見的 RabbitM、RocketMQ、Kafka 都有類似的問題,都需要消費端做好冪等處理。

本文就基於這個問題,來討論一下,在“訊息可能重複消費”這個場景下,有沒有啥好的解決方案。

舉個例子

前面說了,要處理訊息重複消費的場景,最核心的邏輯是需要實現冪等機制。

冪等,這個概念大家應該是比較清晰了。

舉個具體的例子。

比如在支付場景下,消費者消費扣款訊息,對一筆訂單執行扣款操作,扣款金額為 100 元。

如果因各種原因導致扣款訊息重複投遞,比如簡單的一個場景,消費者接受到“扣款金額為 100 元”這個資訊,完成消費,還沒來得及告訴 MQ,“老哥,這個訊息我已經收到了”,就重啟了。

站在 MQ 的角度,沒有收到回執,就代表這個訊息並沒有消費成功,基於“必須保證訊息不丟失的指導思想”,它就會繼續投遞。

所以消費者會重複消費這個扣款訊息。

但是,最終的業務結果是隻扣款一次,扣費 100 元,且使用者的扣款記錄中對應的訂單隻有一條扣款流水,不會多次扣除費用。

那麼這次扣款操作是符合要求的,整個消費過程實現了消費冪等。

在要求冪等的場景中,我們要找到一個抓手。

比如在這個案例裡面,扣款一般來說都會對應一個業務上的唯一流水號,這個業務上的唯一流水號,就是抓手,我們可以基於這個流水號來做冪等。

最常規的方案就是在這個欄位上加唯一索引,然後出現重複投遞時,落庫的時候會丟擲主鍵衝突的異常。

不要覺得重複投遞是一個小機率事件,就不上心了。我們敲程式碼的,不就是要多考慮這些正常流程之外的“小機率事件”嗎,只寫正常流程,誰都會寫。

根據官方的說法,訊息重複會發生在這些場景中:

  • 傳送時訊息重複。當一條訊息已被成功傳送到服務端並完成持久化,此時出現了網路閃斷或者客戶端當機,導致服務端對客戶端應答失敗。 如果此時生產者意識到訊息傳送失敗並嘗試再次傳送訊息,消費者後續會收到兩條內容相同並且 Message ID 也相同的訊息。
  • 投遞時訊息重複訊息消費的場景下,訊息已投遞到消費者並完成業務處理,當客戶端給服務端反饋應答的時候網路閃斷。為了保證訊息至少被消費一次,訊息佇列的服務端將在網路恢復後再次嘗試投遞之前已被處理過的訊息,消費者後續會收到兩條內容相同並且 Message ID 也會收到相同的訊息。
  • 負載均衡時訊息重複(包括但不限於網路抖動、Broker 重啟以及消費者應用重啟)。當訊息佇列的 Broker 或客戶端重啟、擴容或縮容時,會觸發 Rebalance,此時消費者可能會收到重複訊息。

搞搞具體方案

還是順著前面扣款的例子說。

收到訊息之後,我們第一步一般來說是儲存資訊到資料庫。

save(扣款資訊);

現在我們要做冪等,已經找到了扣款唯一流水號這個抓手,那我們的程式碼應該怎麼寫呢?

扣款資訊 = select(扣款唯一流水號);
if(扣款資訊 == null){
save(扣款資訊);
}

先查詢,再判斷,最後儲存。

這個方案,在一般的情況下,能達到冪等的效果。

但是,由於是三步,在併發場景下,立馬就扛不住了。

而且,訊息重複投遞的場景,本來就是在極短的時間內產生的兩條資訊。

所以,上面這個方案會出現什麼場景呢?

兩個請求,在 select(扣款唯一流水號) 的時候都沒有查詢到資料,擊穿了校驗邏輯,然後兩個請求就都會去落庫。

這個時候怎麼辦呢?

很簡單,前面說了,在扣款唯一流水號上加唯一索引,即使兩個請求都去落庫,但是由於有唯一索引,一定只會落一筆資料到資料庫。

另外一個怎麼辦?

丟擲唯一索引衝突的異常,在程式裡面透過捕獲這個異常來控制流程上的後續運轉。

這個方案,很常見,很常用,實話實說我們用的就是這個方案。

但是既然已經有唯一索引了,那是不是前面的 select 都顯得沒啥卵用了?

我們要從辯證的角度去看待這個問題。

所以,是,也不是。

是的原因是因為前面這一層 select 相當於過濾層,能在一些非併發的場景下讓程式不丟擲唯一索引衝突的異常,顯得更加優雅。

不是的原因是因為優雅的程度還不夠高,畢竟是透過“異常”來控制了程式的走向。

有沒有不丟擲異常的方案呢?

也有,也很簡單,上鎖就行了:

扣款資訊 = select(扣款唯一流水號);//select *** for update

這樣確實能保證不丟擲唯一索引衝突的異常,但是關鍵是一旦涉及到上鎖,效能就拉胯了,為了解決這個偶發的問題,犧牲了介面的效能,這個路線就走的有點遠了。

所以,上鎖也不夠優雅。

什麼是真正的優雅?

我也不知道,但是我試圖去思考一個相對優雅的方案。

思考一波

首先,我覺得上面的方案,不管是唯一索引,還是上鎖,不夠優雅的原因是因為,它們都是在基於業務表搞事情。

業務表幹得事兒,應該就是業務上的事兒。

那我問你:訊息重複投遞,需要保持冪等,這個屬於業務上的事兒嗎?

我認為是不屬於的,這是屬於技術上的事情,任何業務都是可能遇到的。只不過,在前面的方案裡面,我們想借用業務表的能力,來幫我們做一個它可以做,但是本來不該它做的事情。

首先,我們必須要在這一點上達成一致,不然後面的論述就不能展開了。

如果你不這樣認為,那麼你可以不用往下看。

我想到的方案是什麼呢?

我相信你聽過這樣一句話:計算機領域中的所有問題都可以透過增加一箇中間層來解決。

所以,我也想著抽一層。

我還是需要資料庫透過唯一索引來幫我保證只有一條資料被成功落庫,所以我想著抽一個專門的表出來,比如叫做訊息消費記錄表。

只要資料插入了這個表,就代表訊息被消費了。後續即使重發,也不會插入成功。

那麼怎麼來保證這個機制呢?

前面提到的抓手又可以用上了:業務唯一流水號。

這個訊息消費記錄表裡面最重要的一個欄位,可以叫做“訊息唯一標識”,並且作為唯一索引。

這裡的這個“訊息唯一標識”就是對應業務唯一流水號。

如果你要基於這個表來實現訊息冪等,那麼你必須具備這樣的一個業務唯一流水號,當重複的時候,還是會丟擲主鍵衝突異常。

我知道著聽起來就像是脫褲子放屁。

但是,你想想,這個表是完全脫離於業務的存在。

在前面的解決方案中,你要問別人,你有沒有一張業務表來做這個事情。

在現在的方案中,你會給別人說,我這裡有一個解決方案,你只需要執行我給你的 SQL,生成一張訊息消費記錄表就行。

這張表是完全獨立於業務的存在,它只是為了解決訊息重複投遞這個共性問題。

從你問別人要,到別人按照你說的做,就這麼輕輕的抽一小層,攻守易形了啊,朋友。

它是一種通用的解決方案,一種策略,甚至可以叫做一個框架。

現在,我們可以給它取一個新的名字。

比如:一種基於資料庫唯一索引實現訊息冪等的解決方案。

或者:一種分散式系統中資料唯一性的保障策略。

再或者:一個由資料庫約束驅動的訊息冪等保護性框架。

好,現在我們有了這麼一個“高大上”的通用解決方案了。

到底怎麼用呢?

名字很厲害,但是用起來其實也就那麼回事兒。

回到前面轉賬的例子,很簡單:

if(儲存資料到訊息消費記錄表){//出現主鍵衝突就返回false
save(扣款資訊);
}

這樣,訊息防重,由訊息消費記錄表來保證。

業務表,不感知“訊息是否重複”的場景。

看起來似乎是優雅了那麼一點點。

但是,同時帶來了另外一個問題。

又回到了之前“先校驗,再保持”的非原子性的邏輯。

我們想想一個極端場景,如果儲存資料到訊息消費記錄表成功,還沒來得及 save(扣款資訊) ,服務重啟了,怎麼辦?

其實換句話說,這兩個資訊需要保持一致性。

所以可以加入事務嘛,把這兩步繫結到一起:

開啟事務;
if(儲存資料到訊息消費記錄表){//出現主鍵衝突就返回false
save(扣款資訊);
}
提交事務;

這樣,如果儲存資料到訊息消費記錄表成功,還沒來得及 save(扣款資訊) ,服務重啟,事務回滾,訊息消費記錄表就不會真的插入成功。

而 MQ 沒有收到這個訊息的回執,也會再次進行投遞。

由於訊息消費記錄表裡沒有這個資料,所以會再次進行消費。

在上面的這個過程中,MQ 再次投遞,是為了 at least once。

而我們引入了訊息消費記錄表,透過唯一索引來保證不重複消費,這個玩意加上 at least once,在業界有另外一個叫法: exactly-only。

現在,我們透過引入事務來解決了“非原子性”的問題,但是又帶來另外一個問題:事務。

一般來說,大家都是能不使用事務的地方就儘量不使用事務,透過最終一致性來保證資料的完整性。

那現在有沒有不基於事務的解決方案呢?

我想到的是可以在訊息消費記錄表裡面再引入一個“狀態欄位”,這個欄位有三個取值:未消費、消費中、消費完成。

透過維護狀態的流轉,來代替事務的邏輯。

這個思路來源於我實習的時候,給老師做外包專案。當時我是真的不知道 Spring 的事務怎麼用,但是我知道結合當時我開發的業務場景,一個資料的狀態很重要,處理之前把資料的狀態修改了,但是如果出了異常,應該把狀態給它恢復回去。

於是我手動寫了這樣的一坨程式碼,四處散落在我寫的模組裡面。

後來一個師兄看了我的程式碼,提出了應該用事務來保證這樣的邏輯,並給我做了演示,我才去瞭解了事務相關的東西。

但是有一說一,我後來也思考了,在我那個特定的業務場景下,透過狀態的流轉,確實是可以代替事務的存在的。

好,回到我們現在的這個場景中。

一個訊息過來的時候,首先根據唯一訊息標識獲取對應的資料。

如果沒有獲取到,就初始化為“未消費”狀態落庫,然後去執行具體的業務邏輯。在業務邏輯執行之前,把狀態修改為“消費中”,然後在執行完成之後,把狀態修改為“消費完成”。

如果這個訊息被重新投遞了,那麼根據唯一訊息標識就能獲取到對應的資料,接著檢查這個訊息的狀態。如果是“消費完成”,直接就丟掉。

但是上面的描述只是描述了最簡單的場景,一些複雜場景下狀態的流轉和判斷應該怎麼做,我確實還沒想好。

所以就當是個課後習題吧,你去推一推,看看用狀態流轉代替事務的方式是否能成功落地。

學會了記得回來教我。

相關文章