redis應用系列二:非同步訊息佇列:生產/消費模式實現及優化

笨小孩發表於2021-05-24

[TOC]
面試中關於redis中經常會被如何實現非同步佇列?以及存在什麼問題,怎麼改進,鑑於次今天進行非同步佇列實現和優化
說明:

  • 非同步訊息佇列是什麼?
  • 非同步訊息佇列能解決什麼問題?
  • 什麼時候用?
  • 什麼地方用?

以上問題請參考 訊息佇列
基於list實現的生產/消費模式佇列,在應用中使用場景最為廣泛,以下是具體的常見實現過程以及分析

redis應用系列二:非同步訊息佇列安全性實現和過程分析

生產/消費模式有三個基本的元素

  • 生產者(producer):用於組裝訊息,並將組裝的訊息通過lpush(左進)的方式壓入佇列,供消費者消費此訊息
  • 佇列:用於承載訊息的載體,在佇列裡面,被生產者壓入的訊息按照一定順序進行存放,保證有序性
  • 消費者(consumer):用於將佇列裡面的訊息按照先進先出(此處採用rpop)的原則彈出佇列容器中

生產者實現

    /**
     * 準備處理訊息key
     */
    static $todoKey = 'TO_SEND_MSG';
    /**
     * 處理中訊息key
     */
    static $doingKey = 'SENDING_MSG';
    /**
     * 無限制阻塞
     */
    static $timeout = 0;

    /**
     * 生產
     */
    public function producer()
    {

        $userARr = [
            'user1@mail1.com',
            'user2@mail2.com',
            'user3@mail3.com',
        ];
        Redis::lpush(self::$todoKey, $userARr);
    }

生產者producer用於生產佇列,利用redis的list結構中lpush從左向佇列中壓入(可以批量)需要處理的訊息,此訊息在list中按照順序進行存放,入隊相對簡單,至此完成了佇列入隊

常見消費觸發方式

  • 1、死迴圈方式讀取:易實現,故障時無法及時恢復(比較適合做秒殺,比較集中,運維集中維護)
  • 2、定時任務:壓力均分,有處理上限;(需要合理設定時間間隔,不要等上一個任務沒有完成下一個任務又開始了)
  • 3、守護程式:類似於php-fpm 和php-cg,需要shell基礎
    鑑於此我們採用死迴圈的方式進行消費,具體消費過程設計如下對比

消費者

示例1

   /**
     * 消費
     */
    public function consumer1()
    {
        /**
         * 迴圈執行消費
         */
        while (true) {
            $msg = Redis::rpop(self::$todoKey);
            if ($msg) {
                // TODO 傳送訊息業務
            }
        }
    }

這種實現方式也是很多人會採用的實現方式,但是存在問題:

  • 當待處理的訊息self::$todoKey為空的情況下:消費服務將會密集的向redis服務重複執行’rpop’命令,將造成資源嚴重浪費,對系統效能有嚴重摺損,因此並不可取
  • 改進:可以考慮當訊息為空的時候,進行sleep程式碼如下:

示例2

/**
     * 消費
     */
    public function consumer2()
    {
        /**
         * 迴圈執行消費
         */
        while (true) {
            $msg = Redis::rpop(self::$todoKey);
            if ($msg) {
                // TODO 傳送訊息業務
            } else {
                // 等待10s
                sleep(10);
            }
        }
    }

加入sleep後,解決了佇列為空資料時候造成的服務端資源浪費問題,但是又引入新問題:

  • 如果有訊息入隊的情況下,程式阻塞在sleep處,程式將會無法及時響應訊息,訊息及時性會打折扣(排除業務允許延遲的的情況),到此一般業務已經能夠滿足,但是對於追求效能的業務場景並不盡人意,需要繼續探索,請看下面示例:

示例3

    /**
     * 消費
     */
    public function consumer3()
    {
        /**
         * 迴圈執行消費
         */
        while (true) {
            // timeout=0 無限制阻塞式消費
            $msg = Redis::brpop(self::$todoKey, self::$timeout);
            if ($msg) {
                // TODO 傳送訊息業務
            }
        }
    }

幸好redis中只需要一個命令:brpop搞定上面問題,brpop在佇列有資料的時候進行出隊操作,在佇列沒有資料的時候進行阻塞等待,知道佇列中有資料,看起來到此一切完美,大功告成的樣子,但是作為程式設計的我們還得考慮極端情況:

  • 如果消費服務端出現異常,由於服務端沒有進行備份,那麼將會出現訊息丟失情況,這種情況的解決請看下面示例

示例4

    /**
     * 消費
     */
    public function consumer4()
    {
        while (true) {
            do {
                // 迴圈阻阻塞執行,消費端取到訊息的同時,原子性的把該訊息放入一個正在處理中的$doingKey列表(進行備份)
                $msg = Redis::brpoplpush(self::$todoKey, self::$doingKey, self::$timeout);
                try {
                    // TODO 傳送訊息業務
                    // 業務未出現異常,處理完業務後,則從正在處理的list中刪除當前處理的訊息,直到處理中list訊息為空
                    Redis::lrem(self::$doingKey, 1, $msg);
                } catch (\Exception $e) {
                    // TODO 異常
                    /**
                     * 業務出現異常,處理異常,
                     * 通常處理異常有兩種方法
                     * 1.將異常訊息重新放到待消費$todoKey的list中
                     * 2.單獨處理異常訊息(如發郵件通知,人工處理)
                     * 3.單獨起一個指令碼程式,處理長時間存在於處理中$doingKey->list訊息
                     */

                }
            } while (Redis::llen(self::$todoKey) || Redis::llen(self::$doingKey));
        }
        /**
         * 到此時,佇列已經滿足了安全性和效能(高可用)要求
         * 但是,對業務的處理上面此種方法並不完美
         * 為了儘可能地完善,還需要寫一個服務端定時指令碼
         * 此指令碼用於監測和處理長時間存在於處理中$doingKey->list訊息
         */
    }

上面程式碼中利用brpoplpush在消費端取到訊息的同時原子性的把該訊息放入一個正在處理中的$doingKey列表(進行備份):

  • 如果處理業務沒有出現異常,那麼業務處理完成後從正在處理中的$doingKey列表lrem刪除當前已經處理ok的訊息
  • 但是如果處理訊息業務出現異常,不用慌,我們已經在處理中的$doingKey列表中備份了異常訊息,對於異常訊息處理,得根據具體業務具體實現:常見有兩種:見上面說明。

到此:我們可以說已經兼顧了佇列的安全性,高效性,但是感覺在處理異常上面增加了複雜度,那麼有沒有更簡單的實現方法呢?見下面示例:

示例5

    /**
     * 消費
     */
    public function consumer5()
    {
        /**
         * 利用旋轉列表功能
         * 重置處理中訊息list的key 和 待處理訊息list為同一個key
         */
        while (true) {
            self::$doingKey = self::$todoKey;
            do {
                // 迴圈阻阻塞執行,消費端取到訊息的同時,原子性的把該訊息放入一個正在處理中的列表(進行備份)
                // 此處由於處理中和待處理list為同一個,利用brpoplpush旋轉列表
                $msg = Redis::brpoplpush(self::$todoKey, self::$doingKey, self::$timeout);
                try {
                    // TODO 傳送訊息業務
                    // 業務未出現異常,處理完業務後,則刪除當前處理的訊息,直到處理中list訊息為空
                    Redis::lrem(self::$doingKey, 1, $msg);
                } catch (\Exception $e) {
                    // 業務出現異常,繼續迴圈,直到
                }
            } while (Redis::llen(self::$todoKey));
        }

    }

此段程式碼跟示例4唯一的不同就是:self::$doingKey = self::$todoKey;

  • 1.利用list實現旋轉列表功能,在同一個佇列中,從尾部出隊的同時,從頭部入隊,如果沒有異常則刪除頭部入隊訊息,如果出現異常,那麼一直迴圈處理,當然這種處理有侷限性:
    A.勿刪的可能性
    B.如果一個異常訊息一直得不到正確處理,就會一直佔用資源
    c.此種方法慎用

綜合考慮:示例4就是一種最優解
但是:蘿蔔白菜各有所愛,不同服務業務場景選擇不同實現,我們就是得搞清楚他們是蘿蔔還是白菜

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

相關文章