RabbitMQ 進階使用之延遲佇列 → 訂單在30分鐘之內未支付則自動取消

青石路發表於2024-06-03

開心一刻

晚上,媳婦和兒子躺在沙發上

兒子疑惑的問道:媽媽,你為什麼不去上班

媳婦:媽媽的人生目標是前20年靠父母養,後40年靠你爸爸養,再往後20年就靠你和妹妹養

兒子:我可養不起

媳婦:為什麼

兒子:因為,呃...,我和你的想法一樣

於家村長_不可置信

講在前面

如果你們對 RabbitMQ 感到陌生,那可以停止往下閱讀了

請先去查閱相關資料,對它有一個基本的瞭解之後再接著閱讀本文

本文會以循序漸進的方式來講解標題:

使用 RabbitMQ 的延遲佇列來實現:訂單在30分鐘之內未支付則自動取消

所以請你們耐心逐步往下看

另外,實現標題的方式有很多,但本文只講其中之一的 延遲佇列,至於其他方式,不在本文講解範圍之內,如果想了解,煩請你們自行去查閱

訊息何去何從

RabbitMQ 的模型架構,相信你們都知道

架構

訊息Producer 生成,經 Exchange 路由到 Queue ,然後推給 Consumer 進行消費

消費訊息有兩種方式

  1. 推模式(Basic.Consume)
  2. 拉模式(Basic.Get)

如果 訊息Exchange 無法路由到符合條件的佇列時,該 訊息 該如何處理,是返還給 Producer 還是直接丟棄?

如果 訊息 被路由到 Queue 時發現沒有任何消費者,該 訊息 該如何處理,是存在 Queue 中還是返還給 Producer ?

作為一個牛皮的中介軟體,一旦涉及到可選項了,應該怎麼做?

我相信你們已經想到了,那肯定是增加配置引數來支援可選項嘛!

mandatory

mandatory 引數用於設定訊息是否必須被路由到佇列中,預設值是 false

mandatory 引數設定為 true 時,Exchange 無法根據自身的型別和路由鍵找到一個符合條件的 Queue,那麼 RabbitMQ 會呼叫 Basic.Return 命令將訊息返回給生產者。當 mandatory 引數設定為 false 時,出現上述情形,則訊息直接被丟棄

mandatory 值為 false

mandaroty_false

程式碼執行正常,但沒有輸出結果,所以我們不確定訊息是否投遞了

但我們可以透過 RabbitMQ 管理介面,看 Exchange 概況

mandaroty_exchange

來確定訊息確實投遞了

mandatory 值為 true 時,需要新增一個監聽器 ReturnListener

mandatory_true

程式碼執行正常,同時也有輸出結果

2024-06-01 14:54:52|AMQP Connection 10.5.108.226:5672|com.qsl.rabbit.PriorityMessageTest|INFO|59|Basic.Return 返回結果:mandatory test

也可以透過 RabbitMQ 管理介面,看 Exchange 概況來確定訊息是否投遞過

作為擴充,給你們留兩個問題

  1. mandatory 設定為 true 的同時,不新增監聽器 ReturnListener,會是什麼結果
  2. mandatory 設定為 false 的同時,新增監聽器 ReturnListener,又會是什麼結果

immediate

immediate 引數用於設定訊息是否立即傳送給消費者,預設值是 false

immediate 引數設定為 true 時,如果訊息路由到佇列時發現佇列上並沒有任何消費者,那麼該訊息不會存入佇列中,當與路由鍵匹配的所有佇列都沒有消費者時,該訊息會透過 Basic.Return 返回至生產者

immediate 為 true ,訊息路由到匹配的佇列時

  1. 部分佇列有消費者,有消費者的佇列會立即將訊息投遞給消費者,沒有消費者的佇列會丟棄該訊息
  2. 全部佇列都沒有消費者,則將該訊息返回給生產者

執行如下程式碼

immediate_true

你會發現報錯

2024-06-01 16:16:06|AMQP Connection 10.5.108.226:5672|org.springframework.amqp.rabbit.connection.CachingConnectionFactory|ERROR|1575|Channel shutdown: connection error; protocol method: #method<connection.close>(reply-code=540, reply-text=NOT_IMPLEMENTED - immediate=true, class-id=60, method-id=40)

這是因為從 RabbitMQ 3.0 版本開始去掉了對 immediate 引數的支援,對此官方解釋如下

immediate 引數會影響映象佇列的效能,增加了程式碼複雜性,建議採用 TTLDLX 替代 immediate

概括來講,mandatory 針對的是訊息能否路由到至少一個佇列中,否則將訊息返回給生產者。immediate 針對的是訊息能否立即投遞給消費者,否則將訊息直接返回給生產者,不用將訊息存入佇列而等待消費者

Alternate Exchange

生產者在傳送訊息時,如果不設定 mandatory 引數(或設定為 false),那麼訊息在未被路由的情況下會丟失;如果設定了 mandatory(且設定成 true),那麼需要新增對應的 ReturnListener 邏輯,生產者的程式碼會變得複雜。如果既不想增加生產者的複雜,又不想訊息丟失,那麼就可以使用備份交換器(Alternate Exchange),將未被路由的訊息儲存在 RabbitMQ 中,在需要的時候再去處理這些訊息

實現程式碼如下

備份交換器實現

執行如下測試程式碼

alternate_測試程式碼

訊息透過 com.qsl.normal.exchange ,經路由鍵 123 未匹配到任何佇列,此時訊息就會傳送給 com.qsl.normal.exchange 的備份交換器 com.qsl.alternate.exchange,因為備份交換器的型別是 fanout,所以訊息會被路由到 com.qsl.alternate.exchange 繫結的所有佇列上,目前只有一個佇列 com.qsl.unrouted.queue ,所以訊息最終來到 com.qsl.unrouted.queue,訊息流轉如下

備用交換器

RabbitMQ 控制檯看佇列狀況如下

alternate_測試結果

備份交換器和普通的交換器沒有太大的區別,為了方便使用,推薦選擇 fanout 型別;你們也可以選擇其他型別,比如 directtopic,但此時需要保證訊息被重新路由到備份交換器的路由鍵和生產者發出的路由鍵是一樣的,否則訊息不能正確路由到備份交換器的佇列中,訊息會丟失!

關於備份交換器,以下幾種特殊情況需要注意

  • 如果設定的備份交換器不存在,客戶端和 RabbitMQ 伺服器都不會產生異常,此時訊息丟失
  • 如果備份交換器沒有繫結任何佇列,客戶端和 RabbitMQ 伺服器都不會產生異常,此時訊息丟失
  • 如果備份交換器沒有任何匹配的佇列,客戶端和 RabbitMQ 伺服器都不會產生異常,此時訊息丟失
  • 如果備份交換器和 mandatory 引數一起使用,mandatory 會失效

過期時長(TTL)

TTL,Time to Live 的簡稱,字面意思生存時長,也有很多人稱過期時間,個人更習慣稱過期時長

訊息的 TTL

RabbitMQ 有兩種方法對訊息設定過期時長

  1. 透過佇列屬性設定,佇列中的所有訊息都有相同的過期時長
  2. 對訊息本身進行單獨設定,每條訊息的過期時長可以不同

如果兩種方法一起使用,則訊息的過期時長以兩者之間較小值為準(而非單純的以訊息的過期時長為準)

訊息在佇列中的生存時間一旦超過設定的過期時長,就會變成 死信(Dead Message) ,消費者將無法再透過正常的路由收到該訊息

可以透過繫結 死信佇列 來消費 Dead Message

透過佇列屬性 x-message-ttl 可以設定訊息的過期時長,單位是毫秒,示例程式碼如下

訊息ttl佇列

如果不設定 TTL,訊息不會過期;如果 TTL 設定成 0,則表示除非此時可以將訊息直接投遞給消費者,否則該訊息直接被丟棄,這個特性是不是看起來很眼熟?回過頭去看看 immediatetrue 時的第 1 個特性

1.部分佇列有消費者,有消費者的佇列會立即將訊息投遞給消費者,沒有消費者的佇列會丟棄該訊息

透過引數 expiration 可以單獨設定每個訊息的過期時長,單位也是毫秒,示例程式碼如下

訊息ttl

這兩種方法的過期策略是怎樣的,大家思考下再往下看

對於設定佇列屬性 x-message-ttl 的方法,佇列中的訊息具有相同的過期時長,佇列中已過期的訊息肯定是在佇列頭部,RabbitMQ 只需要定期的從隊頭開始往隊尾掃描,一旦訊息過期則從佇列中剔除,一旦掃描到 未過期 的訊息,則本次掃描完成

對於設定引數 expiration 的方法,每個訊息可以設定不同的過期時長,那麼過期的訊息不一定在佇列頭部,如果要刪除佇列中所有過期訊息,只能掃描整個佇列,此時的成本是比較高的,所以採用惰性刪除,即訊息即將被投遞給消費者時做過期判定,如果過期則進行刪除

如果既設定了佇列屬性 x-message-ttl,又設定了 expiration,那該如何判定訊息是否過期了呢?

定期刪除 + 惰性刪除,Redis 的過期策略是不是也是這個?

佇列的 TTL

這裡針對的是佇列,而非佇列中的訊息,大家別和 訊息的 TTL 搞混了

透過引數 x-expires 可以設定佇列被自動刪除前處於未使用狀態的時長,單位是毫秒,不能設定為 0

未使用狀態需要滿足三點

  1. 佇列上沒有任何消費者
  2. 佇列也沒有被重新宣告
  3. 過期時間段內未呼叫過 Basic.Get 命令

RabbitMQ 能保證在過期時長到達後將佇列刪除,但不保障及時。RabbitMQ 重啟後,持久化的佇列的過期時長會被重新計算

如下是建立一個過期時長為 30 分鐘的佇列

ttl佇列

佇列資訊如下

ttl佇列 rabbitmq控制檯

死信佇列

死信佇列 之前,我們得先了解 DLX,全稱 Dead-Letter-Exchange,中文翻譯成 死信交換器

當訊息在一個佇列中變成死信之後

訊息變成死信的情況包括以下3種

  1. 訊息被決絕(Basic.Reject/Basic.Nack),並設定引數 requeuefalse
  2. 訊息過期
  3. 佇列達到最大長度

它能被重新傳送到另一個交換器中,這個交換器就是 DLX,而繫結到 DLX 的佇列就是 死信佇列

DLX 也是一個正常的交換器,和一般的交換器沒有區別,它可以和任何佇列進行繫結,當繫結的佇列中存在死信時,RabbitMQ 就會自動將這個訊息重新發布到設定的 DLX 上,進而被路由到 死信佇列死信佇列 也是可以被監聽的,也可以有消費者對 死信佇列 中的訊息進行消費處理的

所以,死信佇列 可以變相的實現 immediatetrue 時的第 2 種情況

2.全部佇列都沒有消費者,則將該訊息返回給生產者

為什麼是 變相,因為不是直接將訊息返回給生產者,而是生產者可以監聽 死信佇列 ,使訊息回到生產者;雖然結果一致,但實現方式還是有區別的

那麼 immediatetrue 的特性,就可以用 TTL + 死信佇列 來替代了

透過引數 x-dead-letter-exchange 可以給佇列新增 DLX;透過引數 x-dead-letter-routing-key 可以給這個 DLX 指定路由鍵,如果未配置該引數,則使用原佇列的路由鍵,實現程式碼如下

DLX實現

執行如下測試程式碼

DLX_Test

訊息透過交換器 com.qsl.normal.exchange,經路由鍵 ttlMessage 匹配到佇列 com.qsl.message.ttl.queue 中,佇列設定了 x-message-ttl 為 3000 毫秒,這段時長內佇列上沒有消費者消費這條訊息,訊息過期。由於給佇列設定了死信交換器 com.qsl.dlx.exchange,訊息會透過該交換器,經路由鍵 dlx_routing_key 匹配到佇列 com.qsl.dlx.queue 中,訊息最終儲存在該死信佇列中,訊息流轉如下

RabbitMQ 進階-死信佇列

RabbitMQ 控制檯,可以看到佇列狀況如下

死信佇列狀況

DLX 是一個非常有用的特性,它可以處理異常情況下,訊息不能夠被消費者正確消費而被置入到死信佇列中,保證訊息不被丟失;後續分析程式可以透過消費死信佇列中的訊息來分析當時所遇到的異常情況,進而改善和最佳化系統

DLX 還有一個很重要的功能,它配合 TTL 可以實現延遲佇列,具體實現請往下看

延遲佇列

延遲佇列儲存的物件是延遲訊息

延遲訊息 指的是需要延遲消費的訊息

就是當訊息傳送之後,並不想讓消費者立即拿到訊息,而是等待特定時長後,消費者才拿到訊息進行消費

延遲佇列的使用場景有很多,例如:

  1. 訂單系統中,下單完成之後 30 分鐘內完成支付,否則取消訂單
  2. 使用者註冊成功後,如果三天內沒有登陸則進行簡訊提醒
  3. 遠端控制掃地機器人,2 個小時後進行房間打掃
  4. ...

RabbitMQ 本身並沒有直接支援 延遲佇列 的功能,但是可以透過 DLXTTL 模擬出 延遲佇列 的功能,具體實現已經在上一節(死信佇列)中完成了,你們可以網上翻一翻

給大家演示 場景1 的完整示例,時間改成 1 分鐘內完成支付

生產者端配置

order 配置

消費者端配置

order 消費者

訊息傳送

order 訊息傳送

輸出日誌如下

order 日誌

實際應用中,可以根據延遲時長給延遲佇列劃分多個等級,例如

RabbitMQ 進階-多維度死信佇列

目前 RabbitMQ 提供了另外的方式來實現 延遲佇列

https://github.com/rabbitmq/rabbitmq-delayed-message-exchange

感興趣的可以去看看

總結

  1. 示例程式碼:spring-boot-rabbitmq

  2. mandatory 與 immediate

    mandatory 針對的是訊息能否路由到至少一個佇列中,否則將訊息返回給生產者

    immediate 針對的是訊息能否立即投遞給消費者,否則將訊息直接返回給生產者,不用將訊息存入佇列而等待消費者

    RabbitMQ 3.0 版本開始去掉了對 immediate 引數的支援,可以用 DLXTTL 來代替

  3. 過期時長

    訊息的過期時長有兩種設定方式:佇列的引數 x-message-ttl 和訊息的引數 expiration

    佇列也可以設定過期時長,該時長內佇列一直處於未使用狀態則會被刪除;透過佇列引數 x-expires 來設定

  4. 死信佇列

    繫結到死信交換器(DLX)上的佇列就是死信佇列

    DLX 能夠保證異常的情況下訊息不會丟失,後續透過分析死信佇列中的訊息,可以改善和最佳化系統

  5. 延遲佇列

    目前來講,實現延遲佇列的方式有兩種

    1. DLXTTL
    2. rabbitmq-delayed-message-exchange

參考

《RabbitMQ實戰指南》

相關文章