redis應用系列三:延遲訊息佇列正確實現姿勢

笨小孩發表於2021-06-05

[TOC]
延遲佇列和非同步佇列說明

  • 1、有效期:限時活動、拼團…
  • 2、超時處理:取消超時未支付訂單、超時自動確認收貨…
  • 3、延遲處理:機器人點贊/觀看數/評論/關注、等待依賴條件…
  • 4、重試:網路異常重試、叫車派單、依賴條件未滿足重試…
  • 5、定時任務:智慧裝置定時啟動…
  • 延遲
  • 佇列(其實不能稱為佇列,更貼切的應該是不重複的元素集)
  • 延遲任務
  • 資料量大
  • 實時性要求高
  • 時間輪
  • RabbitMQ死性佇列
  • 基於redis的zset實現
專案 比較
複雜性 redis < RabbitMQ < 時間輪
安全性 redis < RabbitMQ <= 時間輪
效能 redis > 時間輪 > RabbitMQ

鑑於redis實現延遲佇列是一種理解起來較為直觀,可以快速落地的方案,使用 Redis 叢集來支援高併發和高可用,並且可以依賴redis自身持久化可以實現安全性保證,是一種不錯的延遲佇列的實現方案,以下實現方案研究均基於redis

  • 基於key的過期監聽

    實現思路:
    1.起一個執行緒,開啟對任務key(如:未支付訂單單號)的watch;
    2.一旦key過期(訂單未支付超時時間),觸發event進行業務邏輯處理(取消訂單)。
    注:這種方法處理起來簡單,但是在資料量較大的情況下,這種方案對效能影響較大

  • 基於zset集合

    實現思路:
    1.利用zadd向集合中插入元素,以元素(訂單)的時間戳(超時時間)作為score
    2.利用zrangebyscore以【0-當前時間戳】進行獲取需要處理的元素(即為超時待處理訂單)
    注:這種方法相較於上面方法,實現較為複雜,但是對效能保證較好,因此是應用最廣泛的延遲佇列實現方案

下面是基於redis的zset實現延遲佇列的具體示例

生產者

    /**
     * 生產
     */
    public function producer()
    {
        $orderMap = [
            'orderNo.1' => time() + 10,//訂單1需要10s之後進行消費處理
            'orderNo.2' => time() + 100,//訂單2需要100s之後進行消費處理
            'orderNo.3' => time() + 1000,//訂單3需要1000s之後進行消費處理
        ];
        Redis::connection()->zadd(self::$delayKey, $orderMap);
    }

生產者:

  1. 利用zadd,以延遲時間戳作為score,元素唯一標誌(訂單號不重複)作為value,向需要處理的key中插入元素即可

注意:因為是集合,因此需要保證集合元素的唯一性(score可以相同)

完成生產者,那麼如何消費呢?常見的消費有以下幾種

消費者

示例1

    /**
     * 消費
     */
    public function consumer1()
    {
        /**
         * 迴圈執行消費
         */
        while (true) {
            $msg = Redis::connection()->zrangebyscore(self::$delayKey, 0, time(), ['limit' => ['offset' => 0, 'count' => 1]]);
            if ($msg) {
                // 刪除當前處理訊息
                if (Redis::connection()->zrem(self::$delayKey, $msg)) {
                    // TODO 業務處理並返回處理結果
                }
            }
        }
    }

示例1中:
1.利用zrangebyscore按照score(此處為元素對應超時時間戳)進行獲取滿足條件的集合元素(超時待取消訂單);
2.當有滿足的條件的元素(超時待取消訂單),先刪除該元素(保證不被其他程式取到),再進行業務邏輯處理;
3.此處為了考慮效能,建議每次只處理1(count=1)條訊息資料,offset=0,

思考:
在常規處理中,該實現已經能夠滿足業務需求,

  • 但是這種實現有沒有什麼問題?
    when延遲佇列滿足條件的元素為空(或者集合為空)時候:
    程式會頻繁不斷向redis服務獲取滿足條件元素,這樣會造成redis服務資源佔用和浪費,
  • 怎麼辦呢?
    可以在沒有取到滿足的條件時候讓程式阻塞一段時間unsleep(100000);
    這種方法實際上就是用時間換取資源,注意控制阻塞時間長短,不宜太短,也不宜太長(影響即時性),完整程式碼見示例2

示例2

    /**
     * 消費
     */
    public function consumer2()
    {
        /**
         * 迴圈執行消費
         */
        while (true) {
            $msg = Redis::connection()->zrangebyscore(self::$delayKey, 0, time() + 1500, ['limit' => ['offset' => 0, 'count' => 1]]);
            if ($msg) {
                // 刪除當前處理訊息
                if (Redis::connection()->zrem(self::$delayKey, $msg)) {
                    // TODO 業務處理並返回處理結果
                }

            } else {
                /**
                 * 阻塞1s,減少redis服務資源浪費
                 */
                unsleep(100000);
            }
        }
    }

示例2:
思考:
這種實現看起來已經完全沒有問題(單例部署架構中)

  • 在分散式架構中呢?
    有可能出現zrangebyscorezrem非同一個客戶端的問題,即原子性問題
  • 怎麼辦呢?
    採用lua指令碼解決,見示例3

示例3


    /**
     * 消費
     * 沒有備份機制的lua指令碼
     */
    public function consumer3()
    {
        $luaScript = <<<EOF
local delayKey = KEYS[1]
local start = ARGV[1]
local endTime = ARGV[2]
local limitNum = ARGV[3]
local result = redis.call('zrangebyscore',delayKey,start,endTime,'limit',0, limitNum)

if next(result) ~= nil  
then 
    local res = redis.call('zrem',delayKey,unpack(result))
    if res > 0
    then
        return result
    end 
else
    return {}
end
EOF;
        /**
         * 迴圈執行消費
         */
        while (true) {
            $startTime = 0;
            $endTime   = time();
            $limit     = 1;
            /**
             * 為了防止併發,保證原子性操作
             * 利用lua指令碼獲取滿足條件的訊息,並刪除滿足條件訊息
             * 返回滿足條件的訊息
             */
            $msg = Redis::connection()->eval($luaScript, 1, self::$delayKey, $startTime, $endTime + 9999999, $limit);
            if ($msg) {
                // TODO 業務處理並返回處理結果
            } else {
                /**
                 * 阻塞1s,減少redis服務資源浪費
                 */
                unsleep(100000);
            }
        }
    }

總結:方案3中解決上面存在的問題,是一種最優解

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章