引言

在深入研究 Laravel Horizon 之前,你应该先熟悉 Laravel 的基础队列服务。如果你尚未熟悉 Laravel 提供的基本队列功能,Horizon 为 Laravel 队列新增的功能可能会让你感到困惑。

Laravel Horizon 为基于 Laravel 的Redis 队列提供了美观的仪表盘以及基于代码的配置方式。借助 Horizon,你可以轻松监控队列系统的关键指标,例如任务吞吐量、运行时长以及任务失败情况。

使用 Horizon 时,所有的队列工作者配置都会保存在一个简洁的配置文件中。通过在受版本控制的文件里定义应用的工作者配置,你可以在部署应用时轻松扩展或修改应用的队列工作者。

Horizon

安装

Laravel Horizon 要求你使用Redis 来驱动队列。因此,你需要确保在应用的 config/queue.php 配置文件中,将队列连接设置为 redis。目前,Horizon 不支持 Redis Cluster。

你可以使用 Composer 包管理器将 Horizon 安装到项目中:

composer require laravel/horizon

安装完 Horizon 后,使用 horizon:install Artisan 命令发布其资源:

php artisan horizon:install

配置

发布 Horizon 的资源后,其主配置文件会位于 config/horizon.php。该配置文件可用于配置应用的队列工作者选项。每个配置项都附带了用途说明,请务必仔细查阅此文件。

Horizon 内部使用名为 horizon 的 Redis 连接。此 Redis 连接名已被保留,不应在 database.php 配置文件中将其分配给其他 Redis 连接,也不应将其作为 horizon.php 配置文件中 use 选项的值。

内容安全策略(CSP)Nonce

如果你希望在 Horizon 视图所使用的 script 和 style 标签上,作为nonce 属性使用,并将其纳入你的内容安全策略,可以使用 Horizon::cspNonce 方法指定要使用的 nonce。该方法通常应在中间件中调用,以便为每个请求分配新的 nonce:

use Closure;
use Illuminate\Http\Request;
use Laravel\Horizon\Horizon;
use Symfony\Component\HttpFoundation\Response;

public function handle(Request $request, Closure $next): Response
{
    Horizon::cspNonce('csp-nonce');

    return $next($request);
}

你可以将此中间件添加到应用 config/horizon.php 配置文件中的 middleware 选项里:

'middleware' => [
    'web',
    App\Http\Middleware\AddHorizonCspNonce::class,
],

环境

安装完成后,你需要熟悉的首要 Horizon 配置项是 environments 配置项。该配置项是一个应用所运行环境的数组,用于定义每个环境的工作者进程选项。默认情况下,该条目包含 production 和 local 环境。不过,你可以按需添加更多环境:

'environments' => [
    'production' => [
        'supervisor-1' => [
            'maxProcesses' => 10,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
        ],
    ],

    'local' => [
        'supervisor-1' => [
            'maxProcesses' => 3,
        ],
    ],
],

你也可以定义一个通配符环境(*),当未找到其他匹配的环境时会使用该环境:

'environments' => [
    // ...

    '*' => [
        'supervisor-1' => [
            'maxProcesses' => 3,
        ],
    ],
],

启动 Horizon 时,它会使用应用当前运行环境对应的工作者进程配置选项。通常,环境由 APP_ENV 环境变量的值决定。例如,默认的 local Horizon 环境配置为启动三个工作者进程,并自动平衡分配给每个队列的工作者进程数量。默认的 production 环境配置为最多启动 10 个工作者进程,并自动平衡分配给每个队列的工作者进程数量。

你应该确保 horizon 配置文件中的 environments 部分包含了你计划运行 Horizon 的每个环境对应的条目。

监督器(Supervisors)

正如你在 Horizon 默认配置文件中所见,每个环境都可以包含一个或多个“监督器”。默认情况下,配置文件将此监督器定义为 supervisor-1;不过,你可以按需为监督器命名。每个监督器本质上负责“监督”一组工作者进程,并负责在各个队列间平衡工作者进程。

如果你希望在某个环境中定义一组新的工作者进程,可以为该环境添加额外的监督器。当你想为应用所使用的某个队列定义不同的平衡策略或工作者进程数量时,可以选择这样做。

维护模式

当应用处于维护模式时,除非在 Horizon 配置文件中将监督器的 force 选项定义为 true,否则 Horizon 不会处理排队任务:

'environments' => [
    'production' => [
        'supervisor-1' => [
            // ...
            'force' => true,
        ],
    ],
],

默认值

在 Horizon 的默认配置文件中,你会注意到一个 defaults 配置选项。该配置选项用于指定应用监督器的默认值。监督器的默认配置值会合并到每个环境的监督器配置中,这样在定义监督器时就能避免不必要的重复。

仪表盘授权

可以通过 /horizon 路由访问 Horizon 仪表盘。默认情况下,你只能在 local 环境中访问该仪表盘。不过,在 app/Providers/HorizonServiceProvider.php 文件中,有一个授权门(authorization gate)定义。该授权门用于控制对非本地环境中 Horizon 的访问。你可以根据需要修改此门,以限制对 Horizon 安装的访问:

/**
 * 注册 Horizon 门。
 *
 * 此门用于确定在非本地环境中谁可以访问 Horizon。
 */
protected function gate(): void
{
    Gate::define('viewHorizon', function (User $user) {
        return in_array($user->email, [
            '[email protected]',
        ]);
    });
}

替代认证策略

请记住,Laravel 会自动将已认证用户注入到授权门闭包中。如果你的应用通过其他方式为 Horizon 提供安全保障,例如 IP 限制,那么 Horizon 用户可能无需“登录”。因此,你需要将上述 function (User $user) 闭包签名改为 function (User $user = null),以强制 Laravel 不要求进行认证。

最大任务尝试次数

在优化这些选项之前,请确保你熟悉 Laravel 默认的队列服务以及“尝试次数”的概念。

你可以在监督器的配置中定义任务可消耗的最大尝试次数:

'environments' => [
    'production' => [
        'supervisor-1' => [
            // ...
            'tries' => 10,
        ],
    ],
],

该选项类似于使用 Artisan 命令处理队列时的 --tries 选项。

当使用 WithoutOverlapping 或 RateLimited 等中间件时,调整 tries 选项至关重要,因为这些中间件会消耗尝试次数。为解决此问题,请在监督器层级调整 tries 配置值,或者在任务类中定义 $tries 属性。

如果未设置 tries 选项,Horizon 默认只尝试一次,除非任务类定义了 $tries,此时以任务类的定义为准,优先级高于 Horizon 配置。

将 tries 或 $tries 设置为 0 表示允许无限次尝试,这在尝试次数不确定时非常合适。为防止无限失败,可以通过在任务类中设置 $maxExceptions 属性来限制允许的异常数量。

任务超时

同样,可以在监控器(supervisor)层级设置 timeout 值,该值指定工作进程在任务被强制终止前可以运行多少秒。一旦被终止,任务会重试或被标记为失败,具体取决于队列配置:

'environments' => [
    'production' => [
        'supervisor-1' => [
            // ...
            'timeout' => 60,
        ],
    ],
],

使用 auto 负载均衡策略时,Horizon 会在缩容时将正在运行的工作进程视为“挂起”,并在超过 Horizon 超时时间后强制终止它们。请确保 Horizon 的超时时间大于任何任务层级的超时时间,否则任务可能会在执行中途被终止。此外,timeout 值应始终比 config/queue.php 配置文件中定义的 retry_after 值短几秒。否则,任务可能会被处理两次。

任务退避

可以在监控器(supervisor)层级定义 backoff 值,指定 Horizon 在重试遇到未处理异常的任务前需要等待多长时间:

'environments' => [
    'production' => [
        'supervisor-1' => [
            // ...
            'backoff' => 10,
        ],
    ],
],

还可以通过使用数组来配置“指数”退避。在此示例中,首次重试的延迟为 1 秒,第二次重试为 5 秒,第三次重试为 10 秒,如果仍有剩余尝试次数,后续每次重试的延迟都为 10 秒:

'environments' => [
    'production' => [
        'supervisor-1' => [
            // ...
            'backoff' => [1, 5, 10],
        ],
    ],
],

其他工作进程选项

除了 tries、timeout 和 backoff 外,每个监控器(supervisor)还支持其他几个选项,用于控制其工作进程的行为以及自动重启的时机。对于长时间运行的进程,定期重启工作进程是一个好习惯,因为这有助于防止内存泄漏:

'environments' => [
    'production' => [
        'supervisor-1' => [
            // ...
            'memory' => 128,
            'maxJobs' => 1000,
            'maxTime' => 3600,
            'sleep' => 3,
            'rest' => 0,
            'nice' => 0,
        ],
    ],
],
  • memory 定义单个工作进程在重启之前可以使用的最大内存量,单位为兆字节。默认情况下,该值为 128。
  • maxJobs 定义工作进程在重启之前应处理的任务数量。值为 0 表示不应基于已处理的任务数量来重启工作进程。默认情况下,该值为 0。
  • maxTime 定义工作进程在重启之前应运行多少秒。值为 0 表示不应基于时间来重启工作进程。默认情况下,该值为 0。
  • sleep 定义当没有可用任务时,工作进程再次轮询队列获取新任务之前应等待的秒数。默认情况下,该值为 3。
  • rest 定义在处理每个任务之间暂停的秒数。默认情况下,该值为 0。
  • nice 定义工作进程的“niceness”(调度优先级)。值越高,进程的优先级越低。默认情况下,该值为 0。

静默任务

有时,你可能不想查看应用或第三方包调度的某些任务。与其让这些任务占用“已完成任务”列表的空间,不如将其设为静默。开始使用此功能,请将任务的类名添加到应用的 horizon 配置文件中的 silenced 配置选项里:

'silenced' => [
    App\Jobs\ProcessPodcast::class,
],

除了静默单个任务类,Horizon 还支持基于标签来静默任务。如果你想隐藏共享同一标签的多个任务,这会很有用:

'silenced_tags' => [
    'notifications'
],

或者,你想要设为静默的任务可以实现 Laravel\Horizon\Contracts\Silenced 接口。如果任务实现了该接口,即便它不在 silenced 配置数组中,也会被自动设为静默:

use Laravel\Horizon\Contracts\Silenced;

class ProcessPodcast implements ShouldQueue, Silenced
{
    use Queueable;

    // ...
}

负载均衡策略

每个监控器(supervisor)可以处理一个或多个队列,但与 Laravel 默认的队列系统不同,Horizon 允许你从三种工作进程负载均衡策略中选择:auto、simple 和 false。

自动负载均衡

auto 策略是默认策略,会根据队列当前的工作负载调整每个队列的工作进程数量。例如,如果你的 notifications 队列有 1000 个待处理任务,而 default 队列为空,Horizon 会为你的 notifications 队列分配更多工作进程,直到队列清空。

使用 auto 策略时,还可以配置 minProcesses 和 maxProcesses 配置选项:

  • minProcesses 定义每个队列的最小工作进程数量。该值必须大于或等于 1。
  • maxProcesses 定义 Horizon 可以在所有队列中扩展到的最大工作进程总数。该值通常应大于队列数量乘以 minProcesses 值。要防止监控器(supervisor)生成任何进程,可将该值设为 0。

例如,你可以将 Horizon 配置为每个队列至少维持一个进程,且最多扩展到总计 10 个工作进程:

'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default', 'notifications'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 1,
            'maxProcesses' => 10,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
        ],
    ],
],

autoScalingStrategy 配置选项决定了 Horizon 将如何为队列分配更多工作进程。你可以从三种策略中选择:

  • time 策略会根据清空队列所需的估计总时间来分配工作进程。
  • size 策略会根据队列中的任务总数来分配工作进程。
  • log 策略会根据队列中任务数量的对数来分配工作进程。这能防止明显更大的队列获得不成比例的大量工作进程。

balanceMaxShift 和 balanceCooldown 配置值决定了 Horizon 扩展以满足工作进程需求的速度。在上述示例中,每三秒最多会创建或销毁一个进程。你可以根据应用的需求,自由调整这些值。

队列优先级与自动负载均衡

使用 auto 负载均衡策略时,Horizon 不会在队列之间强制实行严格优先级。监控器(supervisor)配置中队列的顺序不会影响工作进程的分配方式。相反,Horizon 依靠所选的 autoScalingStrategy,根据队列负载动态分配工作进程。

例如,在以下配置中,尽管 high 队列排在列表首位,但并未在 default 队列之上获得优先处理:

'environments' => [
    'production' => [
        'supervisor-1' => [
            // ...
            'queue' => ['high', 'default'],
            'minProcesses' => 1,
            'maxProcesses' => 10,
        ],
    ],
],

如果需要在队列之间强制执行相对优先级,可以定义多个监控器(supervisor)并显式分配处理资源:

'environments' => [
    'production' => [
        'supervisor-1' => [
            // ...
            'queue' => ['default'],
            'minProcesses' => 1,
            'maxProcesses' => 10,
        ],
        'supervisor-2' => [
            // ...
            'queue' => ['images'],
            'minProcesses' => 1,
            'maxProcesses' => 1,
        ],
    ],
],

在此示例中,默认 queue 最多可扩展至 10 个进程,而 images 队列则限制为一个进程。这种配置可确保您的队列能够独立扩展。

调度资源密集型任务时,有时最好将其分配给一个 maxProcesses 值受限的专用队列。否则,这些任务可能会消耗过多 CPU 资源,导致系统过载。

简单均衡

simple 策略会将工作进程均匀分配到指定的队列中。使用此策略时,Horizon 不会自动扩展工作进程的数量。相反,它会使用固定数量的进程:

'environments' => [
    'production' => [
        'supervisor-1' => [
            // ...
            'queue' => ['default', 'notifications'],
            'balance' => 'simple',
            'processes' => 10,
        ],
    ],
],

在上述示例中,Horizon 会将 5 个进程分配给每个队列,将总共 10 个进程均匀拆分。

如果您想单独控制分配给每个队列的工作进程数量,可以定义多个监督器(supervisor):

'environments' => [
    'production' => [
        'supervisor-1' => [
            // ...
            'queue' => ['default'],
            'balance' => 'simple',
            'processes' => 10,
        ],
        'supervisor-notifications' => [
            // ...
            'queue' => ['notifications'],
            'balance' => 'simple',
            'processes' => 2,
        ],
    ],
],

通过此配置,Horizon 会将 10 个进程分配给 default 队列,将 2 个进程分配给 notifications 队列。

不均衡

当 balance 选项设置为 false 时,Horizon 将严格按照队列列出的顺序处理,这与 Laravel 的默认队列系统类似。不过,如果任务开始堆积,它仍会扩展工作进程的数量:

'environments' => [
    'production' => [
        'supervisor-1' => [
            // ...
            'queue' => ['default', 'notifications'],
            'balance' => false,
            'minProcesses' => 1,
            'maxProcesses' => 10,
        ],
    ],
],

在上述示例中,default 队列中的任务始终优先于 notifications 队列中的任务。例如,如果 default 中有 1,000 个任务,而 notifications 中只有 10 个任务,Horizon 会先完全处理完所有 default 任务,再处理 notifications 中的任何任务。

您可以使用 minProcesses 和 maxProcesses 选项控制 Horizon 扩展工作进程的能力:

  • minProcesses 定义工作进程总数的下限。此值必须大于或等于 1。
  • maxProcesses 定义 Horizon 可扩展至的工作进程总数上限。

升级 Horizon

升级到 Horizon 的新主版本时,请务必仔细阅读升级指南。

运行 Horizon

在应用程序的 config/horizon.php 配置文件中配置好监督器和工作进程后,您可以使用 horizon Artisan 命令启动 Horizon。这一条命令将启动当前环境下所有已配置的工作进程:

php artisan horizon

您可以使用 horizon:pause 和 horizon:continue Artisan 命令暂停 Horizon 进程,并指示其继续处理任务:

php artisan horizon:pause

php artisan horizon:continue

您还可以使用 horizon:pause-supervisor 和 horizon:continue-supervisor Artisan 命令暂停和继续特定的 Horizon 监督器:

php artisan horizon:pause-supervisor supervisor-1

php artisan horizon:continue-supervisor supervisor-1

您可以使用 horizon:status Artisan 命令查看 Horizon 进程的当前状态:

php artisan horizon:status

您可以使用 horizon:supervisor-status Artisan 命令查看特定 Horizon 监督器的当前状态:

php artisan horizon:supervisor-status supervisor-1

您可以使用 horizon:terminate Artisan 命令优雅地终止 Horizon 进程。当前正在处理的任务会被完成后,Horizon 将停止执行:

php artisan horizon:terminate

自动重启 Horizon

在本地开发过程中,您可以运行 horizon:listen 命令。使用 horizon:listen 命令时,当您想重新加载更新后的代码,无需手动重启 Horizon。使用此功能之前,您应确保本地开发环境中已安装 Node。此外,您应该在项目中安装 Chokidar 文件监听库:

npm install --save-dev chokidar

安装好 Chokidar 后,您可以使用 horizon:listen 命令启动 Horizon:

php artisan horizon:listen

在 Docker 或 Vagrant 环境中运行时,您应使用 --poll 选项:

php artisan horizon:listen --poll

您可以在应用程序的 config/horizon.php 配置文件中,使用 watch 配置选项配置需要监听的目录和文件:

'watch' => [
    'app',
    'bootstrap',
    'config',
    'database',
    'public/**/*.php',
    'resources/**/*.php',
    'routes',
    'composer.lock',
    '.env',
],

部署 Horizon

当您准备好将 Horizon 部署到应用程序的生产服务器时,应配置一个进程监控器来监控 php artisan horizon 命令,若该命令意外退出,则自动重启。不用担心,我们将在下文讨论如何安装进程监控器。

在应用程序的部署过程中,您应指示 Horizon 进程终止,以便由您的进程监控器重启它,并使其接收代码更改:

php artisan horizon:terminate

安装 Supervisor

Supervisor 是 Linux 操作系统的进程监控器,若 horizon 进程停止执行,它会自动重启该进程。要在 Ubuntu 上安装 Supervisor,您可以使用以下命令。如果您使用的不是 Ubuntu,可能也可以通过操作系统的包管理器安装 Supervisor:

sudo apt-get install supervisor

如果自行配置 Supervisor 听起来过于繁琐,可以考虑使用 Laravel Cloud,它可以为您的 Laravel 应用程序管理后台进程。

Supervisor 配置

Supervisor 配置文件通常存放在服务器的 /etc/supervisor/conf.d 目录中。在此目录中,您可以创建任意数量的配置文件,以指示 Supervisor 应如何监控您的进程。例如,我们创建一个 horizon.conf 文件,用于启动并监控一个 horizon 进程:

[program:horizon]
process_name=%(program_name)s
command=php /home/forge/example.com/artisan horizon
autostart=true
autorestart=true
user=forge
redirect_stderr=true
stdout_logfile=/home/forge/example.com/horizon.log
stopwaitsecs=3600

定义 Supervisor 配置时,应确保 stopwaitsecs 的值大于运行时间最长的任务所消耗的秒数。否则,Supervisor 可能会在任务处理完成前将其终止。

尽管上述示例适用于基于 Ubuntu 的服务器,但在其他服务器操作系统中,Supervisor 配置文件的位置和扩展名可能会有所不同。请参阅您服务器的文档以获取更多信息。

启动 Supervisor

创建好配置文件后,您可以使用以下命令更新 Supervisor 配置并启动被监控的进程:

sudo supervisorctl reread

sudo supervisorctl update

sudo supervisorctl start horizon

有关运行 Supervisor 的更多信息,请参阅Supervisor 文档。

标签

Horizon 允许您为任务分配“标签”,包括 mailable(邮件类)、广播事件、通知和排队事件监听器。事实上,Horizon 会根据与任务关联的 Eloquent 模型,智能且自动地为大多数任务添加标签。例如,请看以下任务:

<?php

namespace App\Jobs;

use App\Models\Video;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class RenderVideo implements ShouldQueue
{
    use Queueable;

    /**
     * 创建新的作业实例。
     */
    public function __construct(
        public Video $video,
    ) {}

    /**
     * 执行作业。
     */
    public function handle(): void
    {
        // ...
    }
}

如果此作业以具有值为 1 的 id 属性的 App\Models\Video 实例加入队列,它将自动接收标签 App\Models\Video:1。这是因为 Horizon 会在作业的属性中搜索任何 Eloquent 模型。如果找到 Eloquent 模型,Horizon 将使用模型的类名和主键智能地为作业添加标签:

use App\Jobs\RenderVideo;
use App\Models\Video;

$video = Video::find(1);

RenderVideo::dispatch($video);

手动为作业添加标签

如果你想为可排队对象之一手动定义标签,可以在该类上定义 tags 方法:

class RenderVideo implements ShouldQueue
{
    /**
     * 获取应分配给该作业的标签。
     *
     * @return array<int, string>
     */
    public function tags(): array
    {
        return ['render', 'video:'.$this->video->id];
    }
}

手动为事件监听器添加标签

在检索排队事件监听器的标签时,Horizon 会自动将事件实例传递给 tags 方法,允许你将事件数据添加到标签中:

class SendRenderNotifications implements ShouldQueue
{
    /**
     * 获取应分配给该监听器的标签。
     *
     * @return array<int, string>
     */
    public function tags(VideoRendered $event): array
    {
        return ['video:'.$event->video->id];
    }
}

通知

配置 Horizon 发送 Slack 或短信通知时,你应该查看相关通知渠道的前提条件。

如果你想在某个队列等待时间过长时收到通知,可以使用 Horizon::routeMailNotificationsTo、Horizon::routeSlackNotificationsTo 和 Horizon::routeSmsNotificationsTo 方法。你可以在应用程序的 App\Providers\HorizonServiceProvider 的 boot 方法中调用这些方法:

/**
 * 引导任何应用程序服务。
 */
public function boot(): void
{
    parent::boot();

    Horizon::routeSmsNotificationsTo('15556667777');
    Horizon::routeMailNotificationsTo('[email protected]');
    Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}

配置通知等待时间阈值

你可以在应用程序的 config/horizon.php 配置文件中配置多少秒被视为“长等待”。该文件中的 waits 配置选项允许你控制每个连接/队列组合的长等待阈值。任何未定义的连接/队列组合将默认采用 60 秒的长等待阈值:

'waits' => [
    'redis:critical' => 30,
    'redis:default' => 60,
    'redis:batch' => 120,
],

将队列的阈值设置为 0 将禁用该队列的长等待通知。

指标

Horizon 包含一个指标仪表盘,提供有关作业和队列的等待时间及吞吐量的信息。为了填充该仪表盘,你应该配置 Horizon 的 snapshot Artisan 命令,使其在应用程序的 routes/console.php 文件中每五分钟运行一次:

use Illuminate\Support\Facades\Schedule;

Schedule::command('horizon:snapshot')->everyFiveMinutes();

你可以使用应用程序 config/horizon.php 配置文件中的 metrics.trim_snapshots 选项,配置 Horizon 为其指标图表保留多少个快照。由于该选项限制的是快照的数量而非其时效,因此保留期限取决于 horizon:snapshot 命令运行的频率:

'metrics' => [
    'trim_snapshots' => [
        'job' => 24,
        'queue' => 24,
    ],
],

如果你想删除所有指标数据,可以调用 horizon:clear-metrics Artisan 命令:

php artisan horizon:clear-metrics

删除失败的作业

如果你想删除一个失败的作业,可以使用 horizon:forget 命令。horizon:forget 命令接受失败作业的 ID 或 UUID 作为其唯一参数:

php artisan horizon:forget 5

如果你想删除所有失败的作业,可以向 horizon:forget 命令提供 --all 选项:

php artisan horizon:forget --all

清空队列中的作业

如果你想删除应用程序默认队列中的所有作业,可以使用 horizon:clear Artisan 命令来实现:

php artisan horizon:clear

你可以提供 queue 选项,以便从特定队列中删除作业:

php artisan horizon:clear --queue=emails