Laravel Queues 佇列應用實戰

bananaplan發表於2021-02-22

佇列,顧名思義,排著隊等著做事情。在生活場景中,凡是排隊的人,都是帶有目的性的、要完成某件事情,才去排隊的,要不沒有誰會閒到排隊玩兒。而在軟體應用層面,佇列是什麼,佇列有什麼優點,我們什麼時候需要用佇列,以及在實際業務場景下,如何基於佇列做具體功能實現,在這篇文章中,我將用 PHP 的 Laravel 框架,逐一進行講解和實現。

簡單的後臺任務 vs 佇列

在純粹的PHP環境中,我們可以使用 exec 函式來呼叫一個外部命令,啟動一個外部程式,執行相關任務,等待其執行結束後,當前PHP指令碼繼續往後執行,直到其生命週期的結束。這個 exec 執行的整個過程是同步的,如若要非同步的、讓其在後臺默默的執行,而不讓當前PHP指令碼程式陷入長時間等待的境地,也很好解決,只需要將 exec 執行的輸出結果,進行重定向即可,關於這一點,在PHP的官方文件中明確說明了:

具體實現方法,可以參考這篇博文:後臺程式在處理繁重的任務時,呼叫外部程式非同步執行的簡單實現

雖然,我們可以用上面的方法,簡單幾行程式碼實現,就可以達到目的,但是,面對稍微複雜一點點的場景,前面的方法就會弊端大顯。

比如,我在呼叫百度AI內容稽核API的時候,經常會發現返回 download image error 圖片下載失敗的錯誤資訊,這就導致了雖然我們非同步的執行了內容稽核的任務,但不幸的是,這一次任務執行的結果失敗了。我希望在失敗後,還能夠再一次的執行,於是我就又用 crontab 做了個定時任務,每5分鐘檢查所有待審的文章,交給百度AI去做內容稽核,以彌補第一次稽核失敗時所帶來的問題。如此,就能夠實現這樣一個較為良好的使用者體驗:當使用者釋出了文章後,系統第一次會以非同步的方式,呼叫百度AI進行內容稽核,若稽核通過,則前端使用者會在10秒內,看到自己釋出的文章稽核通過、正常釋出出去了;若百度稽核出現了錯誤資訊,則交給系統的定時任務處理,這樣前端使用者也能夠在最多5分鐘後,看到自己的文章釋出成功。

這是一個理想的方案,一切都那麼OK,然而不幸的是,我發現百度AI內容稽核介面失敗的情況有些多,若文章的稽核一直失敗,則系統定時任務會不停的再次進行稽核,結果,把我的百度AI內容稽核介面的2000條圖片稽核的免費額度,全部耗光了!!!

那可不可以每篇文章最多隻能呼叫3次百度AI稽核介面,超過則人工稽核呢?當然可以,但是這就需要再升級資料表結構,記錄當前文章的稽核次數,再在業務程式碼中,寫入相應的邏輯以達到此目的。這就有些緊耦合了,你看看為了實現上述的效果,我用了 exec 非同步執行任務,再用 crontab 設定了後臺定時執行,最後還需要改動表結構,新增一個與業務本身毫不相干的欄位,再在業務程式碼中加入稽核次數的邏輯,才能最終完成整個過程。是不是過於煩瑣和笨拙?

而以上種種,我們可以用 佇列 來搞定,Laravel 框架的 Queue 佇列,可以實現任務的 非同步執行、失敗重試、任務最大執行次數限制 等特性,很方便的實現我想要的效果。

並且之所以叫做佇列,是由於我們可以把很多要執行的任務塞進佇列,像排隊一樣按順序依次執行,並且我們也可以讓佇列做限流,限制佇列中任務處理的頻率,以降低伺服器負載的壓力,像這種高階用法,並不在本文討論之內,請自行參見官方文件。

我遇到的實際業務問題:CDN圖片伺服器 和 百度AI圖片稽核

其實,上面出現的百度AI稽核介面,返回 download image error 過多的問題,倒跟百度關係不大,而是我們CDN圖片源伺服器的問題。由於歷史原因,我們的CDN圖片源伺服器是Windows系統,用IIS部署的,實在是不知道什麼原因,最近一年來經常處於抽風狀態,感覺就像是頻寬不夠而被限流了。具體表現為:CDN從我們的源伺服器拉取新圖片時,響應時間很長,短則十秒或幾十秒,長則一兩分鐘,甚至更長時間。這對於網頁瀏覽來說,問題倒不是很大,因為雖然使用者第一次檢視新圖片時,圖片下載時間長點,但只要CDN快取下了該圖片,那麼後續的圖片顯示就會秒開。但對於百度AI圖片稽核來說,這就是致命的:

使用者釋出包含圖片的內容 -> 百度AI圖片稽核 -> 從CDN獲取圖片 -> 我可是新圖片哦,還要從源伺服器拉取,對不起,可能要等1分鐘哦 -> 百度AI伺服器:“垃圾,我等不了你,掛了吧” -> 響應 download image error,看到了吧,這就是癥結所在。

所以,雖然沒有能力從根本上解決圖片源伺服器的問題,但是用Laravel佇列的方式,解決百度AI圖片稽核的問題,還是可以的。

Laravel 的佇列實現

在開始程式碼實現之前,我們還是應該搞清楚兩個概念:1、佇列 2、任務。

佇列,其實與業務程式碼無關,而是一種執行機制:首先從 快取資料 中,定時拉取要執行的 任務,然後在後臺執行。如果執行失敗,可以將此 任務 重新放回,待下一次重新執行;若執行成功,則將其釋放,任務結束。這個執行機制的運轉,是由 Laravel 框架維護的,我們無需寫任何程式碼。

我們要寫的只是任務,將我們要執行的業務程式碼,放到任務中,再分發給佇列,此任務就會自動執行。

那佇列從哪裡得知有任務要執行呢,又如何記錄任務執行失敗的資訊呢?其實,我上面說的 快取資料,就是佇列獲取任務資訊的地方,它可以是 資料庫,也可以儲存在 redis 裡。而且,如果任務執行失敗,Laravel 也會將失敗資訊,儲存在資料庫中。

此例中,我們採取用資料庫,記錄任務資訊,可以方便的看到任務資訊的直觀資料。正式上線時,當然可以採用 redis,以實現更高的執行效率,畢竟一個與硬碟打交道,一個與記憶體打交道,誰快誰慢不言而喻。

首先,執行下面的 artisan 命令,建立2張表:jobsfailed_jobs,以儲存 任務資訊 和 失敗資訊。

php artisan queue:table
php artisan queue:failed-table
php artisan migrate

然後,我們要配置佇列的連線方式,也就是你到底要從 資料庫 還是 redis 中,去獲取任務資訊。開啟 config/queue.php 配置檔案,在最上面,你會發現預設連線:'default' => env('QUEUE_CONNECTION', 'sync'),可以看到,配置先從 .env 檔案中讀取,如若沒有,則預設為 sync。我們可以開啟 .env,在其中新增或修改為 QUEUE_CONNECTION=database,如果你用 redis 作為預設連線,則配置為 QUEUE_CONNECTION=redis【注意,用 redis 方式,要安裝 predis PHP擴充套件包】。而那個 sync 的預設配置,則代表預設以同步的方式,執行任務,也就是說,任務分發出去以後,不入佇列,而是立即在當前程式中同步執行,這跟你正常在主程式中執行業務程式碼沒啥區別,任務會阻塞當前程式。

做好以上配置後,我們可以建立一個任務,寫上我們的業務程式碼,可執行以下 artisan 命令,快速建立任務程式碼模版類:

php artisan make:job TestJob

我們建立了一個叫 TestJob 的任務類,初始程式碼長這樣:

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class TestJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        
    }
}

我們只需要將業務程式碼,放進 handle() 中即可,比如,我們簡單的在其中打點日誌,人為的將任務放回佇列,讓其30秒後繼續執行,並設定最大重試次數:

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\Log;

class TestJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 3;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        Log::info('TestJob handle');

        Log::info('TestJob release');
        $this->release(30);

        Log::info('TestJob end');
    }
}

注意 public $tries = 3;,是設定此任務最多嘗試執行的次數,並且我們在 handle() 中通過 $this->release(30);,人為的將其放回佇列,讓其30秒後再試。

下面,我們就需要開啟佇列了,前面說過,佇列的執行機制,是由 Laravel 自動控制的,我們只需通過下面的 artisan 命令,啟動佇列:

php artisan queue:work

好了,一個佇列程式就會啟動,並不停的查詢 jobs 資料表中,是否有任務要執行。

下面,我們在某個 Controller 中,將上面寫的 TestJob 分發給佇列,讓其自動後臺非同步執行:

class IndexController extends Controller
{
    public function test(Request $request)
    {
        \App\Jobs\TestJob::dispatch();
    }
}

通過瀏覽器,訪問此控制器的路由地址,觸發其中的任務分發,你就會在控制檯上看到任務執行的資訊輸出,並且會在 jobs 資料表中,查詢到當前正在執行的任務資訊:

並且,由於我們設定了30秒重試,並且限制了最多嘗試執行3次,所以在 failed_jobs 資料表中,也會記錄任務執行超過指定次數的失敗資訊:

要停止佇列,只需在命令列視窗 ctrl+c 中斷當前程式即可。

剩下的,自然就是寫上合理的業務邏輯程式碼,比如呼叫百度AI圖片稽核介面,在其返回 download image error 時,通過 $this->release(300) 將其放回佇列,並讓它5分鐘後再試。而5分鐘後,CDN應該就有了圖片的快取,自然百度AI稽核介面去下載圖片就會毫無障礙了,並且也杜絕瞭如若一直拿不到圖片,就會一直失敗下去,從而浪費圖片稽核免費額度的問題。

練氣期 和 築基期 修士,別被 supervisor 嚇到了

可是,現在大多數人心中還盤旋著一個問題:啟動佇列的命令執行後,其一直佔用當前命令列視窗,當關掉視窗後,佇列程式也會被關掉,那有什麼方法,能讓其在後臺執行呢?於是,看到官方文件提到的用 supervisor 來管理程式,從而實現讓佇列程式後臺執行。

我最近一段時間,迷上了聽 凡人修仙傳,裡面把修仙者的功力分為這麼幾個層次:練氣期、築基期、結丹期、元嬰期、化神期...以在下這十幾年的修為,自忖勉強能腆列結丹初、中期的境界,其後還有結丹後期、元嬰初期、元嬰中期、元嬰後期,而大部分躋身元嬰期的前輩高人,待到壽元將近,也無法突破元嬰初期,可見越往後,修煉越是難上加難。在下即使窮盡一生,估計也只能到結丹後期,頂多假嬰的境界,往後哪怕再進一步,都是痴心妄想。至於化神,呵呵,簡直就是...

而當練氣期和築基期的道友,第一次看到 supervisor 的時候,估計是會被懵到了,這是什麼東西?我沒見過你,啊~不要過來...

沒關係,我們當然可以不用 supervisor,也可以讓佇列程式在後臺執行,如何實現?如果你認真看了,並且理解了 後臺程式在處理繁重的任務時,呼叫外部程式非同步執行的簡單實現 這篇文章所講述的內容,我想實現方法,就無需多言了,其核心無非就是 nohup 要執行的命令 > /dev/null 2>&1 &,如此,可讓佇列程式處於後臺執行。

用上 supervisor 讓你更省心

雖然,我們可以跳過 supervisor,也可以讓佇列程式處於 background 後臺執行,但是重啟佇列很麻煩。要知道,佇列程式是常駐記憶體的,它在啟動時,載入的應用的一些資料,會長久的儲存在記憶體中,其後,你對應用的程式碼所做的一些修改和調整,都必須要重啟佇列程式,才會生效。所以,當你修改了你的 Job 任務中的程式碼後,你需要重啟佇列。而官方文件中提到,重啟佇列的命令是:

php artisan queue:restart

不幸的是,當你執行了上面的命令,你會發現,雖然你的佇列程式被 kill 掉了,但是它並沒有再次啟動啊,下面只能自己手動的啟動它。這是因為,上述命令只是傳送了殺掉當前佇列程式的訊號,但是再次啟動,實際上卻是交由 supervisor 完成的。這就是我們需要 supervisor 的原因,能讓你重啟的時候,更爽一點。

那如何通過 supervisor 來管理我們的程式,實現程式被殺掉後,自動啟動,以保持其始終處於執行狀態的目的呢?

別怕,整個過程,其實也很簡單。

首先,在 linux 伺服器上,通過包管理器 yumapt-get【視你所用的linux版本而定】,安裝 supervisor,然後找到其配置檔案 /etc/supervisord.conf【檔案位置視你具體情況而定】,翻到最底下一行配置:

[include]
files = supervisord.d/*.ini

保證此行配置沒有被註釋掉,然後找到 supervisord.d 目錄,在其中新建 my-app-worker.ini 檔案,檔名最好【因為我沒嘗試過不一致會如何】和下面配置中的 my-app-worker 保持一致,至於具體叫什麼,可自行修改,內容類似如下:

[program:my-app-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /www/wwwroot/my_laravel_project/artisan queue:work --sleep=3 --tries=3
autostart=true
autorestart=true
user=www
numprocs=1
redirect_stderr=true
stdout_logfile=/www/wwwroot/my_laravel_project/storage/logs/worker.log

其中 commandstdout_logfile 配置你自己專案的路徑,以讓 supervisor 能啟動你的專案的佇列程式,並能將錯誤日誌儲存下來【當然你也可以不儲存錯誤日誌資訊】。

最後,啟動 supervisor,我這裡是在 centos 下用 yum 安裝後,直接通過 service start supervisord 讓其作為服務開機自啟。至於其他系統的啟動方式,請自行參見 supervisor 的文件。

至此,就完成了 supervisor 管理並監控 Laravel 的佇列程式,在你修改 Job 任務程式碼後,執行 php artisan queue:restart 命令,重啟佇列程式,以達到初始化時,重新載入最新任務程式碼的目的。

結語

本文詳細闡述了佇列在實際場景中的具體應用,充分體現了其相較於簡單後臺任務的特性和優勢,並原理性的梳理了佇列的執行機制,最後詳細展示了具體程式碼實現。如此好文,真是耗盡在下一口老血,若點選寥寥,實乃天理難容。

相關文章