MQ系列10:如何保證訊息冪等性消費

Brand發表於2023-01-05

MQ系列1:訊息中介軟體執行原理
MQ系列2:訊息中介軟體的技術選型
MQ系列3:RocketMQ 架構分析
MQ系列4:NameServer 原理解析
MQ系列5:RocketMQ訊息的傳送模式
MQ系列6:訊息的消費
MQ系列7:訊息通訊,追求極致效能
MQ系列8:資料儲存,訊息佇列的高可用保障
MQ系列9:高可用架構分析

1 介紹

我們實際系統中有很多操作,不管你執行多少次,都應該產生一樣的效果或返回一樣的結果。 例如:

  • 前端頁面重複提交選中的資料,服務端只產生對應這個資料的一個反應結果,只儲存一次資料。
  • 我們發起一筆付款請求,也只能扣使用者賬戶一次錢,即使遇到網路重發或系統bug重發,也應該只扣一次金額。
  • 訊息通知,也應該只能收到一次,如果收到多次的扣款通知簡訊,會讓使用者誤解的。
  • 建立商品訂單,一次業務請求只能建立一個,建立多個就會變成購買多次,就會出問題。

以上等等很多重要的場景,都需要冪等的特性來支援。

冪等(idempotent、idempotence)是一個數學與計算機學概念,常見於抽象代數中。 在程式設計中.一個冪等操作的特點是其任意多次執行所產生的影響均與一次執行的影響相同。冪等函式,或冪等方法,是指可以使用相同引數重複執行,並能獲得相同結果的函式。這些函式不會影響系統狀態,也不用擔心重複執行會對系統造成改變。
例如,“getUserSex()和setRight()”函式就是一個冪等函式,包括資料庫中的查詢和刪除也是一樣的道理,它是天然冪等的。總之,冪等就是一個操作,不論執行多少次,產生的效果和返回的結果都是一樣的 。

2 訊息佇列中如何保證冪等性

2.1 訊息佇列的基本構成

我們先來回顧下 Message Queue的構成,這邊以RocketMQ為例子:
RocketMQ主要有四大核心組成部分:NameServer、Broker、Producer以及Consumer四部分。

  • NameServer:Name Server是一個幾乎無狀態節點,可叢集部署,節點之間無任何資訊同步。NameServer 是整個 RocketMQ 的 "中央大腦 " ,它是 RocketMQ 的服務註冊中心,所以 RocketMQ 需要先啟動 NameServer 再啟動 Rocket 中的 Broker。
  • Broker: 訊息伺服器,作為Server提供訊息核心服務, 它接收並儲存Producer生產的訊息,也提供訊息給Consumer消費。Broker一般會分主從,Master 可讀可寫,Slave 只讀。
  • Producer: 訊息生產者,訊息的傳送方,負責生產訊息傳輸給broker。RocketMQ提供了傳送:同步、非同步和單向(one-way)的多種模式。
  • Consumer: 訊息消費者,訊息的處理方,負責從broker獲取訊息並進行業務邏輯處理。
    另外其他如 Topic、 Message,也是重要的組成部分:
  • Topic:主題,釋出/訂閱模式下的訊息統一彙集地,不同生產者向topic傳送訊息,由MQ伺服器分發到不同的訂閱者,實現訊息的廣播
  • Message:訊息體,根據不同通訊協議定義的固定格式進行編碼的資料包,來封裝業務資料,實現訊息的傳輸。

image

2.2 訊息佇列的冪等分析

可以看出,訊息傳送和訊息消費兩個步驟是有可能產生訊息不冪等的問題。
為保證訊息的正確性傳送,超時重試、異常重試、消費完成確認機制等能力都是可以使用,並對業務產生影響的。
我們舉個例子,如果你購買一件商品,使用者付款完成之後,透過MQ訊息的非同步通知,告知下游服務出庫和通知。如果訊息通知出現了問題或者下游訊息消費出現了問題,導致無法ACK,都有可能導致重複的出庫和通知。
image

2.2.1 訊息生產的冪保證

MQ訊息生產部分,就是下圖中的步驟1、步驟2、步驟3:

  • 步驟1:訊息生產端 MQ-Client Producer 將訊息發給服務端MQ-server
  • 步驟2:訊息佇列服務 MQ-Server 將訊息持久化儲存
  • 步驟3:息佇列服務 MQ-Server 返回確認資訊(ACK \ CONSUME_SUCCESS \ offset)給訊息生產端 MQ-Client Producer

如果3 訊息確認故障導致訊息丟失,則訊息生產端 MQ-Client Producer 超時後會重發訊息,這時候可能就有重複訊息,如何保證冪等呢?
因為訊息重發也是MQ-Client Producer發起的,訊息的處理是訊息佇列的服務MQ-Server處理的,MQ-Server將資料進行了持久化麼,這時候我們可以設計一個唯一的 msgId,作為去重的依據,無論重發多少次,msgId都是一樣的,然後在DB資料庫中將這個msgId設定為unique key,不允許重複,他有如下特性:

  • 全域性唯一,不允許重複
  • MQ生成與業務無耦,對訊息的生產和消費也是無強相關。

使用這個 msgId,可以保證只有1條訊息落地到資料庫中,就保證了訊息生產端的冪等。
image

2.2.2 訊息消費的冪保證

MQ訊息消費部分,就是下圖中的步驟4、步驟5、步驟6:

  • 步驟4:訊息佇列服務 MQ-Server 將訊息發給給消費端 MQ-Client Consumer
  • 步驟5:消費端 MQ-Client Consumer 返回確認資訊 (ACK \ CONSUME_SUCCESS \ offset) 給 訊息佇列服務
  • 步驟6:訊息佇列服務 MQ-Server 將持久化的訊息資料刪除,根據msgId精確刪除

★ 說明:以上步驟須做一致性保障

這邊重災區就是步驟5,如果因為故障導致訊息丟失,訊息佇列服務 MQ-Server 在超時後會重發訊息,這樣 MQ-Client Producer/Consumer 就會重複收到訊息。
因為訊息重發是 訊息佇列服務 MQ-Server 發起的,MQ-Client Consumer 負責訊息消費,訊息重發必然會導致業務重複消費(比如重複發訊息、重複出庫)。所以一樣的道理,必然使用msgId來做判斷,如果存在庫中就進行消費,然後精確刪除庫中的資料。如果資料庫中不存在,就忽略,避免重複消費。
同樣的,這個msgID的特性如下:

  • 全域性唯一,不允許重複
  • MQ生成與業務無耦,對訊息的生產和消費也是無強相關。
  • 業務訊息消費方 MQ-Client Consumer 負責判重,保證冪等性

這種方式最常見應用在:商品下單、消費支付、帖子點贊和留言等。

image

2.3 總結說明

無論是何種訊息佇列,造成重複消費原因其實都是類似的。正常情況下,消費者在消費訊息時候,消費完畢後,會傳送一個確認資訊給訊息佇列,訊息佇列就知道該訊息被消費了,就會將該訊息從訊息佇列中刪除。
只是不同的訊息佇列傳送的確認資訊形式不同,例如RabbitMQ是傳送一個ACK確認訊息,RocketMQ是返回一個CONSUME_SUCCESS成功標誌,kafka實際上有個offset的概念,每一個訊息都有一個offset,kafka消費過訊息後,需要提交offset,讓訊息佇列知道自己已經消費過了。
那造成重複消費的原因? 就是因為網路傳輸等等故障,確認資訊沒有傳送到訊息佇列,導致訊息佇列不知道自己已經消費過該訊息了,再次將該訊息分發給其他的消費者。
如何解決?這個問題針對業務場景來答分以下幾點
(1)給這個訊息做一個唯一主鍵,做資料庫insert,如果出現重複消費情況,會導致主鍵衝突,避免資料庫出現髒資料。
(2)update 和 delete 支援天然冪等性,拿到這個訊息做redis的set的操作,那就容易了,不用解決,set操作天然冪等操作。
(3)第三方介質,來做消費記錄。以redis為例,給訊息分配一個全域性id,只要消費過該訊息,將<id,message>以K-V形式寫入redis。那消費者開始消費前,先去redis中查詢有沒消費記錄即可。

相關文章