Delayed Message 外掛實現 RabbitMQ 延遲佇列

五月君發表於2020-03-30

延遲佇列是為了存放那些延遲執行的訊息,待訊息過期之後消費端從佇列裡拿出來執行。

作者簡介:五月君,Nodejs Developer,慕課網認證作者,熱愛技術、喜歡分享的 90 後青年,歡迎關注 Nodejs技術棧(id:NodejsRoadmap) 和 Github 開源專案 www.nodejs.red

DLX + TTL 方式存在的時序問題

對於延遲佇列不管是 AMQP 協議或者 RabbitMQ 本身是不支援的,之前有介紹過如何使用 RabbitMQ 死信佇列(DLX) + TTL 的方式來模擬實現延遲佇列,這也是通常的一種做法,可參見我的另一篇文章 利用 RabbitMQ 死信佇列和 TTL 實現定時任務

今天我想說的是這種方式會存在一個時序問題,看下圖:

圖片描述

左側佇列 queue1 分別兩條訊息 msg1、msg2 過期時間都為 1s,輸出順序為 msg1、msg2 是沒問題的

右側佇列 queue2 分別兩條訊息 msg1、msg2 注意問題來了,msg2 的訊息過期時間為 1S 而 msg1 的訊息過期為 2S,你可能想誰先過期就誰先消費唄,顯然不是這樣的,因為這是在同一個佇列,必須前一個消費,第二個才能消費,所以就出現了時序問題

如果你的訊息過期時間是有規律的,例如,有的 1S、有的 2S,那麼我們可以以時間為維度設計為兩個佇列,如下所示:

圖片描述

上面我們將 1S 過期的訊息拆分為佇列 queue_1s,2S 過期的訊息拆分為佇列 queue_2s,事情得到進一步解決。如果此時訊息的過期時間不確定或者訊息過期時間維度過多,在消費端我們就要去監聽多個訊息佇列且對於訊息過期時間不確定的也是很難去設計的。

針對訊息無序的不妨看下以下解決方案。

Delayed Message 外掛

這裡要感謝 @神奇的包子,掘金(juejin.im/user/5bfc1b9d6fb9a049b347a9e2) 提出的 Delayed Message 外掛方案。

這裡將使用的是一個 RabbitMQ 延遲訊息外掛 rabbitmq-delayed-message-exchange,目前維護在 RabbitMQ 外掛社群,我們可以宣告 x-delayed-message 型別的 Exchange,訊息傳送時指定訊息頭 x-delay 以毫秒為單位將訊息進行延遲投遞。

圖片描述

實現原理

上面使用 DLX + TTL 的模式,訊息首先會路由到一個正常的佇列,根據設定的 TTL 進入死信佇列,與之不同的是通過 x-delayed-message 宣告的交換機,它的訊息在釋出之後不會立即進入佇列,先將訊息儲存至 Mnesia(一個分散式資料庫管理系統,適合於電信和其它需要持續執行和具備軟實時特性的 Erlang 應用。目前資料介紹的不是很多)

這個外掛將會嘗試確認訊息是否過期,首先要確保訊息的延遲範圍是 Delay > 0, Delay =< ?ERL_MAX_T(在 Erlang 中可以被設定的範圍為 (2^32)-1 毫秒),如果訊息過期通過 x-delayed-type 型別標記的交換機投遞至目標佇列,整個訊息的投遞過程也就完成了。

外掛安裝

根據你的 RabbitMQ 版本來安裝相應外掛版本,RabbitMQ community-plugins 上面有版本對應資訊可參考。

注意:需要 RabbitMQ 3.5.3 和更高版本。

# 注意要下載至你的 RabbitMQ 伺服器的 plugins 目錄下,例如:/usr/local/rabbitmq/plugins

wget https://dl.bintray.com/rabbitmq/community-plugins/3.6.x/rabbitmq_delayed_message_exchange/rabbitmq_delayed_message_exchange-20171215-3.6.x.zip

# 解壓
unzip rabbitmq_delayed_message_exchange-20171215-3.6.x.zip

# 解壓之後得到如下檔案
rabbitmq_delayed_message_exchange-20171215-3.6.x.ez
複製程式碼

啟用外掛

使用 rabbitmq-plugins enable 命令啟用外掛,啟動成功會看到如下提示:

$ rabbitmq-plugins enable rabbitmq_delayed_message_exchange
The following plugins have been enabled:
  rabbitmq_delayed_message_exchange

Applying plugin configuration to rabbit@xxxxxxxx... started 1 plugin.
複製程式碼

管理控制檯宣告 x-delayed-message 交換機

在開始程式碼之前先開啟 RabbitMQ 的管理 UI 介面,宣告一個 x-delayed-message 型別的交換機,否則你會遇到下面的錯誤:

Error: Channel closed by server: 406 (PRECONDITION-FAILED) with message "PRECONDITION_FAILED - Invalid argument, 'x-delayed-type' must be an existing exchange type"
複製程式碼

這個問題困擾我了一會兒,詳情可見 Github Issues rabbitmq-delayed-message-exchange/issues/19,正確操作如下圖所示:

圖片描述

Nodejs 程式碼實踐

上面準備工作完成了,開始我們的程式碼實踐吧,官方沒有提供 Nodejs 示例,只提供了 Java 示例,對於一個寫過 Spring Boot 專案的 Nodeer 這不是問題(此處,兄得你有點飄了啊 /:xx)其實如果有時間能多瞭解點些,你會發現還是有益的。

構建生產者

幾個注意點:

  • 交換機型別一定要設定為 x-delayed-message
  • 設定 x-delayed-type 為 direct,當然也可以是 topic 等
  • 傳送訊息時設定訊息頭 headers 的 x-delay 屬性,即延遲時間,如果不設定訊息將會立即投遞
const amqp = require('amqplib');

async function producer(msg, expiration) {
    try {
        const connection = await amqp.connect('amqp://localhost:5672');
        const exchange = 'my-delayed-exchange';
        const exchangeType = 'x-delayed-message'; // x-delayed-message 交換機的型別
        const routingKey = 'my-delayed-routingKey';
        
        const ch = await connection.createChannel();
        await ch.assertExchange(exchange, exchangeType, {
            durable: true,
            'x-delayed-type': 'direct'
        });
        
        console.log('producer msg:', msg);
        await ch.publish(exchange, routingKey, Buffer.from(msg), {
            headers: {
                'x-delay': expiration, // 一定要設定,否則無效
            }
        });

        ch.close();
    } catch(err) {
        console.log(err)
    }
}

producer('msg0 1S Expire', 1000) // 1S
producer('msg1 30S Expire', 1000 * 30) // 30S
producer('msg2 10S Expire', 1000 * 10) // 10S
producer('msg3 5S Expire', 1000 * 5) // 5S
複製程式碼

構建消費端

消費端改變不大,交換機宣告處同生產者保持一樣,設定交換機型別(x-delayed-message)和 x-delayed-type

const amqp = require('amqplib');

async function consumer() {
    const exchange = 'my-delayed-exchange';
    const exchangeType = 'x-delayed-message';
    const routingKey = 'my-delayed-routingKey';
    const queueName = 'my-delayed-queue';

    try {
        const connection = await amqp.connect('amqp://localhost:5672');
        const ch = await connection.createChannel();

        await ch.assertExchange(exchange, exchangeType, {
            durable: true,
            'x-delayed-type': 'direct'
        });
        await ch.assertQueue(queueName);
        await ch.bindQueue(queueName, exchange, routingKey);
        await ch.consume(queueName, msg => {
                console.log('consumer msg:', msg.content.toString());
        }, { noAck: true });
    } catch(err) {
        console.log('Consumer Error: ', err);
    }
}

consumer()
複製程式碼

以上示例原始碼地址:

https://github.com/Q-Angelo/project-training/tree/master/rabbitmq/rabbitmq-delayed-message-node
複製程式碼

最後,讓我們對以上程式做個測試,左側視窗展示了生產端資訊,右側視窗展示了消費端資訊,這次實現了同一個佇列裡不同過期時間的訊息,可以按照我們預先設定的 TTL 時間順序性消費,我們的目的達到了。

圖片描述

侷限性

Delayed Message 外掛實現 RabbitMQ 延遲佇列這種方式也不完全是一個銀彈,它將延遲訊息存在於 Mnesia 表中,並且在當前節點上具有單個磁碟副本,它們將在節點重啟之後倖存。

目前該外掛的當前設計並不真正適合包含大量延遲訊息(例如數十萬或數百萬)的場景,詳情參見 #/issues/72 另外該外掛的一個可變性來源是依賴於 Erlang 計時器,在系統中使用了一定數量的長時間計時器之後,它們開始爭用排程程式資源,並且時間漂移不斷累積。

外掛的禁用要慎重,以下方式可以實現將外掛禁用,但是注意如果此時還有延遲訊息未消費,那麼禁掉此外掛後所有的未消費的延遲訊息將丟失。

rabbitmq-plugins disable rabbitmq_delayed_message_exchange
複製程式碼

如果你採用了 Delayed Message 外掛這種方式來實現,對於訊息可用性要求較高的,在發現訊息之前可以先落入 DB 打標記,消費之後將訊息標記為已消費,中間可以加入定時任務做檢測,這可以進一步保證你的訊息的可靠性。

總結

經過一番實踐測試、學習之後發現,DLX + TTLDelayed Message 外掛這兩種 RabbitMQ 延遲訊息解決方案都有一定的侷限性。

如果你的訊息 TTL 是相同的,使用 DLX + TTL 的這種方式是沒問題的,對於我來說目前還是優選。

如果你的訊息 TTL 過期值是可變的,可以嘗試下使用 Delayed Message 外掛,對於某些應用而言它可能很好用,對於那些可能會達到高容量延遲訊息應用而言,則不是很好。

關於 RabbitMQ 延遲佇列,如果你有更多其它實現,歡迎關注公眾號 “Nodejs技術棧” 在後臺取得我的聯絡方式進行討論,我很期待。

Reference

相關文章