[TOC]
面試中關於redis中經常會被如何實現非同步佇列?以及存在什麼問題,怎麼改進,鑑於次今天進行非同步佇列實現和優化
說明:
- 非同步訊息佇列是什麼?
- 非同步訊息佇列能解決什麼問題?
- 什麼時候用?
- 什麼地方用?
以上問題請參考 訊息佇列
基於list實現的生產/消費模式佇列,在應用中使用場景最為廣泛,以下是具體的常見實現過程以及分析
生產/消費模式有三個基本的元素
- 生產者(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 協議》,轉載必須註明作者和本文連結