Laravel Queues 队列应用实战

队列,顾名思义,排着队等着做事情。在生活场景中,凡是排队的人,都是带有目的性的、要完成某件事情,才去排队的,要不没有谁会闲到排队玩儿。而在软件应用层面,队列是什么,队列有什么优点,我们什么时候需要用队列,以及在实际业务场景下,如何基于队列做具体功能实现,在这篇文章中,我将用 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 命令,重启队列进程,以达到初始化时,重新加载最新任务代码的目的。

结语

本文详细阐述了队列在实际场景中的具体应用,充分体现了其相较于简单后台任务的特性和优势,并原理性的梳理了队列的运行机制,最后详细展示了具体代码实现。如此好文,真是耗尽在下一口老血,若点击寥寥,实乃天理难容。

posted @ 2021-02-22 18:35  bananaplan  阅读(1308)  评论(0编辑  收藏  举报