訊息投遞:如何保證訊息僅僅被消費一次?
- 訊息為什么會丟失
- 如何保證訊息只被消費一次
- 總結:
我們在電商系統中增加了訊息佇列,用它來對峰值寫流量做削峰填谷,對次要的業務邏輯做異步處理,對不同的系統模塊做解耦合,因為業務邏輯從同步代碼中移除 了,所以,我們也要有相應的佇列處理程式來處理訊息、執行業務邏輯,這時,我們的系統架構變成了下面的樣子:

這是一個簡化版的架構圖,實際上,隨著業務邏輯越來越復雜,會引入更多的外部系統和服務來解決業務上的問題,比如說,我們會引入 Elasticsearch 來解決商品和店鋪搜索的問 題,也會引入審核系統,來對售賣的商品、用戶的評論做自動的和人工的審核,會越來越多地使用訊息佇列與外部系統解耦合,以及提升系統性能,
比如說,我們的電商系統需要上一個新的紅包功能:用戶在購買一定數量的商品之后,由我們的系統給用戶發一個現金的紅包,鼓勵用戶消費,由于發放紅包的程序不應該在購買商品的主流程之內,所以我們考慮使用訊息佇列來異步處理,這時,發現了一個問題:如果訊息在投遞的程序中發生丟失,那么用戶就會因為沒有得到紅包而投訴,相反,如果訊息在投遞的程序中出現了重復,那么我們的系統就會因為發送兩個紅包而損失,
那么我們如何保證,產生的訊息一定會被消費到,并且只被消費一次呢?這個問題雖然聽起來很淺顯,很好理解,但是實際上卻藏著很多玄機,
訊息為什么會丟失
如果要保證訊息只被消費一次,首先就要保證訊息不會丟失,那么訊息從被寫入到訊息佇列,到被消費者消費完成,這個鏈路上會有哪些地方存在丟失訊息的可能呢?其實,主要存在三個場景:
- 訊息從生產者寫入到訊息佇列的程序,
- 訊息在訊息佇列中的存盤場景,
- 訊息被消費者消費的程序,

接下來,我們就針對每一個場景,詳細地剖析一下,這樣就可以針對不同的場景選擇合適的,減少訊息丟失的解決方案,
-
在訊息生產的程序中丟失訊息
在這個環節中主要有兩種情況,
首先,訊息的生產者一般是我們的業務服務器,訊息佇列是獨立部署在單獨的服務器上的,兩者之間的網路雖然是內網,但是也會存在抖動的可能,而一旦發生抖動,訊息就有可能因為網路的錯誤而丟失,
針對這種情況,建議采用的方案是訊息重傳:也就是當發現發送超時后我們就將訊息重 新發一次,但是也不能無限制地重傳訊息,一般來說,如果不是訊息佇列發生故障,或者是到訊息佇列的網路斷開了,重試 2~3 次就可以了,
不過,這種方案可能會造成訊息的重復,從而導致在消費的時候會重復消費同樣的訊息,比方說,訊息生產時由于訊息佇列處理慢或者網路的抖動,導致雖然最終寫入訊息佇列成功,但在生產端卻超時了,生產者重傳這條訊息就會形成重復的訊息,那么針對上面的例子,直觀顯示在我們面前的就會是收到了兩個現金紅包,
那么訊息發送到了訊息佇列之后是否就萬無一失了呢?當然不是,在訊息佇列中訊息仍然有丟失的風險,
-
在訊息佇列中丟失訊息
用 Kafka 舉例,訊息在 Kafka 中是存盤在本地磁盤上的,而為了減少訊息存盤時對磁盤的 隨機 I/O,我們一般會將訊息先寫入到作業系統的 Page Cache 中,然后再找合適的時機刷 新到磁盤上,
比如,Kafka 可以配置當達到某一時間間隔,或者累積一定的訊息數量的時候再刷盤,也就是所說的異步刷盤,
來看一個形象的比喻:假如經營一個圖書館,讀者每還一本書都要去把圖書歸位,不僅 作業量大而且效率低下,但是如果每隔 3 小時,或者圖書達到一定數量的時候再把圖書歸位,這樣可以把同一型別的書一起歸位,節省了查找圖書位置的時間,這樣就可以提高效率了,
不過,如果發生機器掉電或者機器例外重啟,那么 Page Cache 中還沒有來得及刷盤的訊息就會丟失了,那么怎么解決呢?
我們可能會把刷盤的間隔設定很短,或者設定累積一條訊息就就刷盤,但這樣頻繁刷盤會對性能有比較大的影響,而且從經驗來看,出現機器宕機或者掉電的幾率也不高,所以不建議這樣做,

如果我們的系統對訊息丟失的容忍度很低,那么可以考慮以集群方式部署 Kafka 服 務,通過部署多個副本備份資料,保證訊息盡量不丟失, -
在消費的程序中存在訊息丟失的可能
我還是以 Kafka 為例來說明,一個消費者消費訊息的進度是記錄在訊息佇列集群中的,而消費的程序分為三步:接收訊息、處理訊息、更新消費進度,
這里面接收訊息和處理訊息的程序都可能會發生例外或者失敗,比如說,訊息接收時網路發生抖動,導致訊息并沒有被正確的接收到;處理訊息時可能發生一些業務的例外導致處理流程未執行完成,這時如果更新消費進度,那么這條失敗的訊息就永遠不會被處理了,也可以認為是丟失了,
所以,在這里需要注意的是,一定要等到訊息接收和處理完成后才能更新消費進度,但是這也會造成訊息重復的問題,比方說某一條訊息在處理之后,消費者恰好宕機了,那么因為沒有更新消費進度,所以當這個消費者重啟之后,還會重復地消費這條訊息,
如何保證訊息只被消費一次
從上面的分析中,我們能發現,為了避免訊息丟失,我們需要付出兩方面的代價:一方面是性能的損耗;一方面可能造成訊息重復消費,
性能的損耗我們還可以接受,因為一般業務系統只有在寫請求時才會有發送訊息佇列的操作,而一般系統的寫請求的量級并不高,但是訊息一旦被重復消費,就會造成業務邏輯處理的錯誤,那么我們要如何避免訊息的重復呢?
想要完全的避免訊息重復的發生是很難做到的,因為網路的抖動、機器的宕機和處理的例外都是比較難以避免的,在工業上并沒有成熟的方法,因此我們會把要求放寬,只要保證即使消費到了重復的訊息,從消費的最終結果來看和只消費一次是等同的就好了,也就是保證在訊息的生產和消費的程序是“冪等”的,
-
什么是冪等
冪等是一個數學上的概念,它的含義是多次執行同一個操作和執行一次操作,最終得到的結果是相同的,
說白了,可以這么理解“冪等”:一件事兒無論做多少次都和做一次產生的結果是一樣的,那么這件事兒就具有冪等性,
-
在生產、消費程序中增加訊息冪等性的保證
訊息在生產和消費的程序中都可能會產生重復,所以我們要做的是,在生產程序和消費程序中增加訊息冪等性的保證,這樣就可以認為從“最終結果上來看”,訊息實際上是只被消費了一次的,
在訊息生產程序中,在 Kafka0.11 版本和 Pulsar 中都支持“producer idempotency”的 特性,翻譯過來就是生產程序的冪等性,這種特性保證訊息雖然可能在生產端產生重復,但 是最終在訊息佇列存盤時只會存盤一份,
它的做法是給每一個生產者一個唯一的 ID,并且為生產的每一條訊息賦予一個唯一 ID,訊息佇列的服務端會存盤 < 生產者 ID,最后一條訊息 ID > 的映射,當某一個生產者產生新的訊息時,訊息佇列服務端會比對訊息 ID 是否與存盤的最后一條 ID 一致,如果一致,就 認為是重復的訊息,服務端會自動丟棄,

而在消費端,冪等性的保證會稍微復雜一些,你可以從通用層和業務層兩個層面來考慮,在通用層面,我們可以在訊息被生產的時候,使用發號器給它生成一個全域唯一的訊息 ID, 訊息被處理之后,把這個 ID 存盤在資料庫中,在處理下一條訊息之前,先從資料庫里面查詢這個全域 ID 是否被消費過,如果被消費過就放棄消費,
我們可以看到,無論是生產端的冪等性保證方式,還是消費端通用的冪等性保證方式,它們的共同特點都是為每一個訊息生成一個唯一的 ID,然后在使用這個訊息的時候,先比對這個 ID 是否已經存在,如果存在,則認為訊息已經被使用過,所以這種方式是一種標準的實作冪等的方式,
不過這樣會有一個問題:如果訊息在處理之后,還沒有來得及寫入資料庫,消費者宕機了重啟之后發現資料庫中并沒有這條訊息,還是會重復執行兩次消費邏輯,這時我們就需要引入事務機制,保證訊息處理和寫入資料庫必須同時成功或者同時失敗,但是這樣訊息處理的成 就更高了,所以,如果對于訊息重復沒有特別嚴格的要求,可以直接使用這種通用的方案, 而不考慮引入事務,
在業務層面怎么處理呢?這里有很多種處理方式,其中有一種是增加樂觀鎖的方式,比如, 我們的訊息處理程式需要給一個人的賬號加錢,那么就可以通過樂觀鎖的方式來解決,
總結:
我們了解了在訊息佇列中,訊息可能會發生丟失的場景,和應對方法,以及在訊息重復的場景下,要如何保證,盡量不影響訊息最終的處理結果,重點是:
- 訊息的丟失可以通過生產端的重試、訊息佇列配置集群模式,以及消費端合理處理消費進度三個方式來解決,
- 為了解決訊息的丟失通常會造成性能上的問題以及訊息的重復問題,
- 通過保證訊息處理的冪等性可以解決訊息的重復問題,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/302459.html
標籤:其他
