基於Redis的任務排程設計方案

breeze發表於2018-03-04

原文連結:blog.breezelin.cn/scheme-redi…

一個閘道器伺服器就跟快餐店一樣,總是希望客人來得快、去得也快,這樣在相同時間內才可以服務更多的客人。如果快餐店的服務員在一個顧客點餐、等餐和結賬時都全程跟陪的話,那麼這個服務員大部分時間都是在空閒的等待。應該有專門的服務員負責點餐,專門的服務員負責送餐,專門的服務員負責結賬,這樣才能提高效率。同樣道理,閘道器伺服器中也需要分工明確。舉個例子:

假設有一個申請傳送重置密碼郵件的閘道器介面,須知道傳送一封郵件可能會花費上好幾秒鐘,如果閘道器伺服器直接線上上給使用者傳送重置密碼郵件,高併發的情況下就很容易造成網路擁擠。但實際上,閘道器伺服器並非一定要等待郵件傳送成功後才能響應使用者,完全可以先告知使用者郵件會傳送的,而後再線上下把郵件傳送出去(就像快餐店裡點餐的服務員跟顧客說先去找位置坐,飯菜做好後會有人給他送過去)。

那麼是誰來把郵件傳送出去呢?

任務佇列

為了閘道器介面能夠儘快響應使用者請求,無需即時知道結果的耗時操作可以交由任務佇列機制來處理。 任務佇列機制中包含兩種角色,一個是任務生產者,一個是任務消費者,而任務佇列是兩者之間的紐帶:

  • 生產者往佇列裡放入任務;
  • 消費者從佇列裡取出任務。

任務佇列的整體執行流程是:任務生產者把當前操作的關鍵資訊(後續可以根據這些資訊還原出當前操作)抽象出來,比如傳送重置密碼的郵件,我們只需要當前使用者郵箱和使用者名稱就可以了;任務生產者把任務放進佇列,實際就是把任務的關鍵資訊儲存起來,這裡會用到MySQL、Redis之類資料儲存工具,常用的是Redis;而任務消費者就不斷地從資料庫中取出任務資訊,逐一執行。

任務生產者的工作是任務分發,一般由線上的閘道器服務程式執行;任務消費者的工作是任務排程,一般由線下的程式執行,這樣即使任務耗時再多,也不阻塞閘道器服務。

這裡主要討論的是任務排程(任務消費者)的程式設計。

簡單直接

假設我們用Redis列表List儲存任務資訊,列表鍵名是queues:default,任務釋出就是往列表queues:default後追加資料:

<?php
// PHP虛擬碼
    Redis::rpush('queues:default', serialize($task));
複製程式碼

那麼任務排程可以這樣簡單直接的實現:

<?php
// PHP虛擬碼
Class Worker {

    public function schedule() {
        while(1) {
            $seri = Redis::lpop('queues:default');
            if($seri) {
                $task = unserialize($seri);
                $this->handle($task);
                continue;
            }
            sleep(1);
        }
    }

    public function handle($task) {
        // do something time-consuming
    }
}

$worker = new Worker;
$worker->schedule();
複製程式碼

意外保險

上面程式碼是直接從queues:default列表中移出第一個任務(lpop),因為handle($task)函式是一個耗時的操作,過程中若是遇到什麼意外導致了整個程式退出,這個任務可能還沒執行完成,可是任務資訊已經完全丟失了。保險起見,對schedule()函式進行以下修改:

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lindex('queues:default', 0);
            if($seri) {
                $task = unserialize($seri);
                $this->handle($task);
                Redis::lpop('queues:default');
                continue;
            }
            sleep(1);
        }
    }
...
複製程式碼

即在任務完成後才將任務資訊從列表中移除。

延時執行

queues:default列表中的任務都是需要即時執行的,但是有些任務是需要間隔一段時間後或者在某個時間點上執行,那麼可以引入一個有序集合,命名為queues:default:delayed,來存放這些任務。任務釋出時需要指明執行的時間點$time

<?php
// PHP虛擬碼
    Redis::zadd('queues:default:delayed', $time, serialize($task));
複製程式碼

任務排程時,如果queues:default列表已經空了,就從queues:default:delayed集合中取出到達執行時間的任務放入queues:default列表中:

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lindex('queues:default', 0);
            if($seri) {
                $task = unserialize($seri);
                $this->handle($task);
                Redis::lpop('queues:default');
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:delayed', 0, time());
            if($seri_arr) {
                Redis::rpush('queues:default', $seri_arr);
                continue;
            }
            sleep(1);
        }
    }
...
複製程式碼

任務超時

預估任務正常執行所需的最大時間值,若是任務執行超過了這個時間,可能是過程中遇到一些意外,如果任由它繼續卡著,那麼後面的任務就會無法被執行了。 首先我們給任務設定一個時限屬性timeout,然後在執行任務前先給程式本身設定一個鬧鐘訊號,timeout後收到訊號說明任務執行超時,需要退出當前程式(用supervisor守護程式時,程式自身退出,supervisor會自動再拉起)。 注意:pcntl_alarm($timeout)會覆蓋之前鬧鐘訊號,而pcntl_alarm(0)會取消鬧鐘訊號;任務超時後,當前任務放入queues:default:delayed集合中延時執行,以免再次阻塞佇列。

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lindex('queues:default', 0);
            if($seri) {
                $task = unserialize($seri);
                $this->timeoutHanle($task);
                $this->handle($task);
                Redis::lpop('queues:default');
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:delayed', 0, time());
            if($seri_arr) {
                Redis::rpush('queues:default', $seri_arr);
                continue;
            }
            pcntl_alarm(0);
            sleep(1);
        }
    }

    public function timeoutHanle($task) {
        $timeout = (int)$task->timeout;
        if ($timeout > 0) {
            pcntl_signal(SIGALRM, function () {
                $seri = Redis::lpop('queues:default');
                Redis::zadd('queues:default:delayed', time()+10), $seri);
                posix_kill(getmypid(), SIGKILL);
            });
        }
        pcntl_alarm($timeout);
    }
...
複製程式碼

併發執行

上面程式碼,直觀上沒什麼問題,但是在多程式併發執行的時候,有些任務可能會被重複執行,是因為沒能及時將當前執行的任務從queues:default列表中移出,其他程式也可以讀取到。為了避免重複執行的問題,我們需要引入一個有序集合SortedSet存放正在執行的任務,命名為queues:default:reserved。 首先任務是從queues:default列表中直接移出,然後開始執行任務前先把任務放進queues:default:reserved集合中,任務完成了再從queues:default:reserved集合中移出。 再結合任務超時,假設一個任務執行時間不可能超過60*60秒(可以按需調整),在queues:default列表為空的時候,queues:default:reserved集合中有任務已經存放超過了60*60秒,那麼有可能是某些程式在執行任務是意外退出了,所以把這些任務放到queues:default:delayed集合中稍後執行。

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lpop('queues:default', 0);
            if($seri) {
                Redis::zadd('queues:default:reserved', time()+10, $seri);
                $task = unserialize($seri);                
                $this->timeoutHanle($task);
                $this->handle($task);
                Redis::zrem('queues:default:reserved', $seri);
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:delayed', 0, time());
            if($seri_arr) {
                Redis::rpush('queues:default', $seri_arr);
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:reserved', 0, time()-60*60);
            if($seri_arr) {
                foreach($seri_arr as $seri) {
                    Redis::zadd('queues:default:delayed', time()+10, $seri);
                }
            }

            sleep(1);
        }
    }

    public function timeoutHanle($task) {
        $timeout = (int)$task->timeout;
        if ($timeout > 0) {
            pcntl_signal(SIGALRM, function () use ($task) {
                $seri = serialize($task);
                Redis::zrem('queues:default:reserved', $seri);
                Redis::zadd('queues:default:delayed', time()+10), $seri);
                posix_kill(getmypid(), SIGKILL);
            });
        }
        pcntl_alarm($timeout);
    }
...
複製程式碼

其他

失敗重試

以上程式碼沒有檢驗任務是否執行成功,應該有任務失敗的處理機制:比如給任務設定一個最多重試次數屬性retry_times,任務每執行一次retry_times,任務執行失敗時,若是retry_times等於0,則將任務放入queues:default:failed列表中不在執行;否則放入放到queues:default:delayed集合中稍後執行。

休眠時間

以上程式碼是程式忙時連續執行,閒時休眠一秒,可以按需調整優化。

事件監聽

若是需要在任務執行成功或失敗時進行某些操作,可以給任務設定成功操作方法afterSucceeded()或失敗操作方法afterFailed(),在相應的時候回撥。

最後

以上講述了一個任務排程程式的逐步演變,設計方案很大程度上參考了Laravel Queue。 用工具,知其然,知其所以然。

相關文章