客服接到用戶的反饋:訂單支付成功之后,用戶收到了多張優惠券。按照正常業務來說,完成訂單之后只會給用戶發送一張優惠券,而現在發送了多張。
如上圖所示,訂單系統與優惠券系統通過 RocketMQ 進行解耦,當發生消息重復消費問題時,表現出來的就是重復發送優惠券。
消息重復消費的問題是如何產生的?
重復消息的問題有可能是生產者重復發送消息到 MQ,導致 MQ 中有多條重復的消息;也有可能是消費者重復消費同一條消息:
- 接口調用超時,導致重復調用訂單接口發送消息到 MQ
- 訂單系統采用同步 + 消息重試的方式向 MQ 發送消息
- 優惠券系統正在處理消息,還沒來得及返回
CONSUME_SUCCESS
狀態就宕機了
訂單系統創建完訂單之后,需要通過支付系統進行支付。完成支付操作之后,由支付系統回調訂單系統接口,此時才算是正式創建了訂單,並由訂單系統向 MQ 發送消息。
支付系統回調訂單系統創建訂單時,對於回調的接口我們不可能無限制等待其執行成功,會接口設置超時重試或者降級熔斷策略。超時重試的情況下,支付系統認為訂單系統創建訂單失敗,多次調用接口。實際上,訂單系統執行成功並向 MQ 發送了消息,但是響應速度太慢。這種情況下,訂單系統就向 MQ 發送了多條重復消息。
此外,訂單系統向 MQ 發送的消息的時候,為了確保消息到達 MQ,可能會采用同步發送消息 + 反復重試的策略。當消息發送到 MQ 之后,MQ 應當給訂單系統返回響應。我們知道網絡環境是復雜的,極有可能發生網絡抖動的問題,此時 MQ 存儲了消息,但是響應在網絡傳輸過程中丟失了。訂單系統就會認為發送消息失敗,繼續重試發送消息,MQ 中就會有多條重復的消息。
最后,即使訂單系統只發送了一條消息到 MQ 中,難道就萬無一失了么?
優惠券系統消費消息時,我們需要有一個響應告訴 MQ 我們已經成功處理了該條消息。這個響應可以是自動響應,也可以是編碼響應,在 RocketMQ 中就是提交 offset 給 MQ。以手動提交 offset 為例,也就是在發放優惠券的代碼后面,返回 CONSUME_SUCCESS
狀態。
在執行完發送優惠券的代碼,還沒來得及提交 CONSUME_SUCCESS
狀態時,該台機器宕機的話,即使消息已經被處理了,但是 MQ 會認為還沒有處理。當優惠券系統重啟之后任然會消費到這條消息。
接口冪等性 | 如何避免消費重復消費的問題?
前面描述了可能導致消息重復消費現象發生的一些的情景:接口調用超時、消息重試、消費者宕機。
這就要依靠接口的冪等性機制,比如你有一個接口,然后如果別人對一次請求重試了多次,來調用你的接口,你必須保證自己系統的數據是正常的,不能多出來一些重復的數據,這就是冪等性的意思。
接口的冪等性一般有兩種實現方案:基於業務判斷,基於狀態判斷。
基於業務判斷
基於業務判斷是指通過具有唯一性的字段進行判斷,比如說注冊用戶的時候通過手機號或者身份證號判斷用戶是否已經注冊過了,在訂單系統中則可以訂單號來進行判斷。
訂單在將消息發送到 MQ 之前,先查詢 MQ 看看有沒有一條 id = 1234
的消息,如果有的話就不發送消息到 MQ。這樣做的缺點就是使用 MQ 的性能會差很多。
基於狀態判斷
基於狀態判斷需要借助於 redis 緩存,每當訂單系統往 MQ 發送一條消息,同時往 redis 里面插入訂單的 id。以后發送消息之前都從 redis 中插敘是否已經發送過該消息。
這樣的方案並不能 100% 保證冪等性,假設成功發送消息到 MQ 之后,下一步應該是往 redis 里面插入數據,但此時機器宕機了,就不會寫入 redis 了。
最佳方案 —— 消費側的業務判斷法
在生產側使用業務判斷法需要查詢 MQ 影響性能,在生產側使用狀態判斷法無法 100% 保證冪等。更加推薦的做法是在消費側通過業務判斷法來保證冪等。
我們默認允許生產者會重復發送消息到 MQ,但是在消費側查詢 MySQL 數據庫,通過字段(比如訂單id)來判斷消息是不是有被消費過。