队列
简介
构建 Web 应用时,你可能会遇到一些耗时任务,例如解析和存储上传的 CSV 文件,这些任务在典型 Web 请求中执行时间过长。Laravel 允许你轻松创建可在后台处理的队列任务。通过将耗时任务移至队列,应用可以以极快的速度响应 Web 请求,为客户提供更好的用户体验。
典型使用场景:
- 数据导入/导出:用户上传 CSV 文件后,后台异步解析入库,前端立即返回「处理中」
- 视频/音频处理:用户上传视频后,后台转码为多分辨率,完成后通知用户
- 邮件发送:批量发送营销邮件,避免阻塞 HTTP 响应
- 报表生成:定时生成大型数据报表,生成完成后邮件通知
- 第三方 API 调用:调用外部支付、物流 API,失败自动重试
- 图片处理:批量裁剪、加水印、生成缩略图
Laravel 队列在各种不同的队列后端(如 Amazon SQS、Redis 或关系型数据库)上提供统一的队列 API。
Laravel 的队列配置选项存储在应用的 config/queue.php 配置文件中。在此文件中,你会找到框架内置的每个队列驱动的连接配置,包括 database、Amazon SQS、Redis 和 Beanstalkd 驱动,以及一个同步驱动(会立即执行任务,用于开发或测试)。还包含一个 null 队列驱动,会丢弃队列任务。
信息
Laravel Horizon 是 Redis 队列的可视化面板和配置系统。查看完整的 Horizon 文档了解更多信息。
连接与队列
开始使用 Laravel 队列之前,重要的是理解「连接」和「队列」之间的区别。在 config/queue.php 配置文件中,有一个 connections 配置数组。此选项定义了到后端队列服务(如 Amazon SQS、Beanstalk 或 Redis)的连接。但任何给定的队列连接可以有多个「队列」,可以将其视为不同的堆栈或成堆的队列任务。
注意,queue 配置文件中的每个连接配置示例都包含一个 queue 属性。这是当任务发送到给定连接时将被分发到的默认队列。换句话说,如果你分发任务时未明确定义应分发到哪个队列,任务将被放置在连接配置的 queue 属性中定义的队列上:
use App\Jobs\ProcessPodcast;
// 此任务发送到默认连接的默认队列...
ProcessPodcast::dispatch();
// 此任务发送到默认连接的 "emails" 队列...
ProcessPodcast::dispatch()->onQueue('emails');
一些应用可能永远不需要将任务推送到多个队列,而是偏好只有一个简单队列。但是,将任务推送到多个队列对于希望优先处理或分段处理任务的应用特别有用,因为 Laravel 队列 worker 允许你指定应按优先级处理哪些队列。例如,如果你将任务推送到 high 队列,可以运行一个给予它们更高处理优先级的 worker:
php artisan queue:work --queue=high,default
场景示例:电商系统中,订单创建任务放 high 队列优先处理,邮件通知和日志归档放 default 队列:
# 高优先级 worker:先处理 high 队列,空闲时处理 default
php artisan queue:work --queue=high,default
# 低优先级 worker:只处理 default 队列
php artisan queue:work --queue=default
驱动说明和前提条件
Database
要使用 database 队列驱动,需要一张数据库表来存储任务。通常,这已包含在 Laravel 默认的 0001_01_01_000002_create_jobs_table.php 数据库迁移中;但如果你的应用不包含此迁移,可以使用 make:queue-table Artisan 命令创建:
php artisan make:queue-table
php artisan migrate
Redis
要使用 redis 队列驱动,应在 config/database.php 配置文件中配置 Redis 数据库连接。
注意
redis 队列驱动不支持 serializer 和 compression Redis 选项。
Redis 集群
如果你的 Redis 队列连接使用 Redis 集群,队列名必须包含键哈希标签。这是为了确保给定队列的所有 Redis 键都被放置在同一个哈希槽中:
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', '{default}'),
'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
阻塞
使用 Redis 队列时,可以使用 block_for 配置选项指定驱动在遍历 worker 循环并重新轮询 Redis 数据库之前应等待任务变为可用的时间。
根据队列负载调整此值可能比持续轮询 Redis 数据库查找新任务更高效。例如,可以将值设为 5,表示驱动在等待任务变为可用时应阻塞 5 秒:
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => 5,
'after_commit' => false,
],
注意
将 block_for 设为 0 会导致队列 worker 无限阻塞直到有任务可用。这也会阻止 SIGTERM 等信号被处理,直到下一个任务被处理完毕。
SQS 溢出存储
Amazon SQS 限制了排队消息负载的最大大小。如果需要分发负载可能超过此限制的任务,可以配置 Laravel 将超大的 SQS 负载存储在缓存存储中,并通过 SQS 发送一个指针。要启用此功能,在 SQS 队列连接配置中添加 overflow 数组:
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
'overflow' => [
'enabled' => env('SQS_OVERFLOW_ENABLED', false),
'store' => env('SQS_OVERFLOW_STORE'),
'always' => false,
'delete_after_processing' => true,
'flush_on_clear' => env('SQS_OVERFLOW_FLUSH_ON_CLEAR', false),
],
],
启用溢出存储后,Laravel 会将至少 1 MB 的负载存储在配置的缓存存储中。如果 always 选项为 true,则每个 SQS 负载无论大小都会存储在缓存存储中。由于排队任务在处理时需要从缓存存储中检索负载,应选择能够保留负载直到 worker 处理它们的存储。默认情况下,存储的负载在其任务成功处理并从 SQS 删除后会被删除。
如果 flush_on_clear 选项为 true,当 queue:clear 命令清除 SQS 队列时,配置的溢出缓存存储将被刷新。由于刷新缓存存储可能会删除该存储中的所有项目,因此在启用此选项时应配置 SQS 溢出存储使用专用缓存存储。
其他驱动前提条件
以下依赖项用于列出的队列驱动。这些依赖项可以通过 Composer 包管理器安装:
- Amazon SQS:
aws/aws-sdk-php ~3.0 - Beanstalkd:
pda/pheanstalk ~5.0 - Redis:
predis/predis ~2.0或 phpredis PHP 扩展 - MongoDB:
mongodb/laravel-mongodb
创建任务
生成任务类
默认情况下,应用的所有可队列任务存储在 app/Jobs 目录中。如果 app/Jobs 目录不存在,运行 make:job Artisan 命令时会自动创建:
php artisan make:job ProcessPodcast
生成的类将实现 Illuminate\Contracts\Queue\ShouldQueue 接口,指示 Laravel 该任务应被推送到队列异步运行。
信息
可以通过存根发布自定义任务存根。
类结构
任务类非常简单,通常只包含一个 handle 方法,在队列处理任务时被调用。首先,让我们看一个示例任务类。在此示例中,我们假设管理一个播客发布服务,需要在发布前处理上传的播客文件:
<?php
namespace App\Jobs;
use App\Models\Podcast;
use App\Services\AudioProcessor;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessPodcast implements ShouldQueue
{
use Queueable;
/**
* 创建新任务实例。
*/
public function __construct(
public Podcast $podcast,
) {}
/**
* 执行任务。
*/
public function handle(AudioProcessor $processor): void
{
// 处理上传的播客...
}
}
在此示例中,注意我们可以直接将 Eloquent 模型传递给队列任务的构造函数。由于任务使用的 Queueable trait,Eloquent 模型及其加载的关联关系在任务处理时会被优雅地序列化和反序列化。
如果队列任务在构造函数中接受 Eloquent 模型,则只有模型的标识符会被序列化到队列。当任务实际处理时,队列系统会自动从数据库重新获取完整的模型实例及其加载的关联关系。这种模型序列化方式使得发送到队列驱动的任务负载更小。
handle 方法依赖注入
handle 方法在队列处理任务时被调用。注意,我们可以在任务的 handle 方法上类型提示依赖。Laravel 服务容器会自动注入这些依赖。
如果你想完全控制容器如何向 handle 方法注入依赖,可以使用容器的 bindMethod 方法。bindMethod 方法接受一个回调,接收任务和容器。在回调中,你可以随意调用 handle 方法。通常,应从 App\Providers\AppServiceProvider 服务提供者的 boot 方法中调用此方法:
use App\Jobs\ProcessPodcast;
use App\Services\AudioProcessor;
use Illuminate\Contracts\Foundation\Application;
$this->app->bindMethod([ProcessPodcast::class, 'handle'], function (ProcessPodcast $job, Application $app) {
return $job->handle($app->make(AudioProcessor::class));
});
注意
二进制数据(如原始图片内容)在传递给队列任务之前应通过 base64_encode 函数处理。否则,任务在被放入队列时可能无法正确序列化为 JSON。
队列关联关系
由于所有加载的 Eloquent 模型关联关系在任务入队时也会被序列化,序列化的任务字符串有时会变得很大。此外,当任务被反序列化并从数据库重新获取模型关联关系时,它们会被完整获取。在任务入队过程序列化模型之前应用的任何先前的关联约束,在任务反序列化时都不会被应用。因此,如果你想使用给定关联的子集,应在队列任务中重新约束该关联。
或者,为了防止关联被序列化,可以在设置属性值时在模型上调用 withoutRelations 方法。此方法将返回不含已加载关联关系的模型实例:
/**
* 创建新任务实例。
*/
public function __construct(
Podcast $podcast,
) {
$this->podcast = $podcast->withoutRelations();
}
如果只需要移除特定关联而保留其余关联,可以使用 withoutRelation 方法:
$this->podcast = $podcast->withoutRelation('comments');
如果使用 PHP 构造函数属性提升并希望指示 Eloquent 模型不应序列化其关联关系,可以使用 WithoutRelations 属性:
use Illuminate\Queue\Attributes\WithoutRelations;
/**
* 创建新任务实例。
*/
public function __construct(
#[WithoutRelations]
public Podcast $podcast,
) {}
为方便起见,如果想序列化所有模型而不含关联关系,可以将 WithoutRelations 属性应用于整个类,而不是应用于每个模型:
<?php
namespace App\Jobs;
use App\Models\DistributionPlatform;
use App\Models\Podcast;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\WithoutRelations;
#[WithoutRelations]
class ProcessPodcast implements ShouldQueue
{
use Queueable;
/**
* 创建新任务实例。
*/
public function __construct(
public Podcast $podcast,
public DistributionPlatform $platform,
) {}
}
如果任务接收的是 Eloquent 模型集合或数组而非单个模型,则该集合中的模型在任务反序列化和执行时不会恢复其关联关系。这是为了防止处理大量模型的任务过度消耗资源。
唯一任务
注意
唯一任务需要支持锁的缓存驱动。目前,memcached、redis、dynamodb、database、file 和 array 缓存驱动支持原子锁。
注意
唯一任务约束不适用于批次中的任务。
有时,你可能希望确保特定任务在任意时刻只有一个实例在队列中。可以通过在任务类上实现 ShouldBeUnique 接口来实现。此接口不要求你在类上定义任何额外方法:
<?php
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
// ...
}
在上面的示例中,UpdateSearchIndex 任务是唯一的。因此,如果该任务的另一个实例已在队列中且尚未完成处理,则任务不会被分发。
在某些情况下,你可能想定义使任务唯一的特定「键」,或指定一个超时时间,超过此时间后任务不再保持唯一。为此,可以使用 UniqueFor 属性并在任务类上定义 uniqueId 方法:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\Attributes\UniqueFor;
#[UniqueFor(3600)]
class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
/**
* 产品实例。
*
* @var \App\Models\Product
*/
public $product;
/**
* 获取任务的唯一 ID。
*/
public function uniqueId(): string
{
return $this->product->id;
}
}
在上面的示例中,UpdateSearchIndex 任务按产品 ID 唯一。因此,在现有任务完成处理之前,具有相同产品 ID 的任何新分发都将被忽略。此外,如果现有任务在一小时内未被处理,唯一锁将被释放,可以分发具有相同唯一键的另一个任务到队列。
注意
如果你的应用从多个 Web 服务器或容器分发任务,应确保所有服务器都与同一个中央缓存服务器通信,以便 Laravel 能准确确定任务是否唯一。
保持任务唯一直到处理开始
默认情况下,唯一任务在任务完成处理或失败所有重试尝试后「解锁」。但是,有时你可能希望任务在处理之前立即解锁。为此,任务应实现 ShouldBeUniqueUntilProcessing 契约而非 ShouldBeUnique 契约:
<?php
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
// ...
}
唯一任务锁
在幕后,当分发 ShouldBeUnique 任务时,Laravel 会尝试获取具有 uniqueId 键的锁。如果锁已被持有,则不分发任务。此锁在任务完成处理或失败所有重试尝试后释放。默认情况下,Laravel 会使用默认缓存驱动获取此锁。但如果想使用另一个驱动获取锁,可以定义 uniqueVia 方法返回应使用的缓存驱动:
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Facades\Cache;
class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
// ...
/**
* 获取唯一任务锁的缓存驱动。
*/
public function uniqueVia(): Repository
{
return Cache::driver('redis');
}
}
信息
如果只需要限制任务的并发处理,请使用 WithoutOverlapping 任务中间件。
防抖任务
有时,你可能希望确保当同一任务在短时间窗口内被多次分发时,只有最新的分发改际执行。可以通过在任务上添加 DebounceFor 属性来实现:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\DebounceFor;
#[DebounceFor(30)]
class UpdateSearchIndex implements ShouldQueue
{
use Queueable;
/**
* 创建新任务实例。
*/
public function __construct(public int $productId)
{
}
/**
* 获取任务的防抖 ID。
*/
public function debounceId(): string
{
return (string) $this->productId;
}
}
在上面的示例中,在 30 秒内为同一产品重复分发 UpdateSearchIndex 将对任务进行防抖,使只有最新的分发改行运行。
如果想限制频繁重复分发任务可被延迟的最长时间,可以向 DebounceFor 属性提供 maxWait 参数:
#[DebounceFor(30, maxWait: 120)]
class UpdateSearchIndex implements ShouldQueue
{
use Queueable;
// ...
}
可以通过在任务上定义 debounceVia 方法来自定义用于防抖跟踪的缓存存储:
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Facades\Cache;
public function debounceVia(): Repository
{
return Cache::driver('redis');
}
如果防抖任务被更新的分发取代,Laravel 将分发 Illuminate\Queue\Events\JobDebounced 事件并从队列中移除被取代的任务。
注意
防抖任务和唯一任务是互斥的。使用 DebounceFor 属性的任务不应实现 ShouldBeUnique。
注意
如果你的应用从多个 Web 服务器或容器分发防抖任务,应确保所有服务器都与同一个中央缓存服务器通信。
场景示例:用户频繁编辑文档时,搜索索引更新任务会被防抖,30 秒内只执行最后一次更新,避免重复索引:
// 用户连续编辑文档 10 次,只有最后一次编辑触发的更新会执行
UpdateSearchIndex::dispatch($document->id);
加密任务
Laravel 允许你通过加密确保任务数据的隐私和完整性。首先,只需在任务类上添加 ShouldBeEncrypted 接口。此类上添加此接口后,Laravel 会在将任务推送到队列之前自动加密:
<?php
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
class UpdateSearchIndex implements ShouldQueue, ShouldBeEncrypted
{
// ...
}
任务中间件
任务中间件允许你在队列任务执行前后包装自定义逻辑,减少任务本身的样板代码。例如,考虑以下 handle 方法,它利用 Laravel 的 Redis 限流功能,每五秒只允许处理一个任务:
use Illuminate\Support\Facades\Redis;
/**
* Execute the job.
*/
public function handle(): void
{
Redis::throttle('key')->block(0)->allow(1)->every(5)->then(function () {
info('Lock obtained...');
// Handle job...
}, function () {
// Could not obtain lock...
return $this->release(5);
});
}
虽然这段代码是有效的,但 handle 方法的实现会变得杂乱,因为它充斥着 Redis 限流逻辑。此外,这个限流逻辑必须为任何其他需要限流的任务重复编写。与其在 handle 方法中进行限流,不如定义一个处理限流的任务中间件:
<?php
namespace App\Jobs\Middleware;
use Closure;
use Illuminate\Support\Facades\Redis;
class RateLimited
{
/**
* Process the queued job.
*
* @param \Closure(object): void $next
*/
public function handle(object $job, Closure $next): void
{
Redis::throttle('key')
->block(0)->allow(1)->every(5)
->then(function () use ($job, $next) {
// Lock obtained...
$next($job);
}, function () use ($job) {
// Could not obtain lock...
$job->release(5);
});
}
}
如你所见,与路由中间件类似,任务中间件接收正在处理的任务和一个应该被调用以继续处理任务的回调。
可以使用 make:job-middleware Artisan 命令生成新的任务中间件类。创建任务中间件后,可以通过从任务的 middleware 方法返回它们来附加到任务上。此方法不存在于 make:job Artisan 命令生成的任务脚手架中,因此需要手动添加到任务类中:
use App\Jobs\Middleware\RateLimited;
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new RateLimited];
}
限流
虽然我们刚刚演示了如何编写自己的限流任务中间件,Laravel 实际上包含了一个限流中间件,你可以用来限制任务的处理速率。与路由限流器类似,任务限流器使用 RateLimiter 门面的 for 方法定义。
例如,你可能希望允许用户每小时备份数据一次,而对高级客户不施加此限制。为此,可以在 AppServiceProvider 的 boot 方法中定义一个 RateLimiter:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
RateLimiter::for('backups', function (object $job) {
return $job->user->vipCustomer()
? Limit::none()
: Limit::perHour(1)->by($job->user->id);
});
}
在上面的示例中,我们定义了一个每小时的速率限制;但是,你也可以使用 perMinute 方法轻松定义基于分钟的速率限制。此外,你可以向速率限制的 by 方法传递任何值;不过,此值通常用于按客户分段速率限制:
return Limit::perMinute(50)->by($job->user->id);
定义速率限制后,可以使用 Illuminate\Queue\Middleware\RateLimited 中间件将限流器附加到任务上。每次任务超过速率限制时,此中间件会将任务释放回队列,并根据速率限制持续时间添加适当的延迟:
use Illuminate\Queue\Middleware\RateLimited;
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new RateLimited('backups')];
}
将限流任务释放回队列仍然会增加任务的总尝试次数。你可能需要相应地调整任务类上的 Tries 和 MaxExceptions 属性。或者,你可能希望使用 retryUntil 方法 来定义任务不再被尝试的时间。
使用 releaseAfter 方法,你还可以指定释放的任务在再次尝试之前必须经过的秒数:
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('backups'))->releaseAfter(60)];
}
如果不想在任务被限流时重试,可以使用 dontRelease 方法:
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('backups'))->dontRelease()];
}
场景示例:SaaS 应用中,免费用户每小时只能备份一次数据,VIP 用户不受限制:
// AppServiceProvider 中定义限流器
RateLimiter::for('backups', function (object $job) {
return $job->user->isVip()
? Limit::none()
: Limit::perHour(1)->by($job->user->id);
});
// 任务类中使用限流中间件
public function middleware(): array
{
return [(new RateLimited('backups'))->releaseAfter(300)]; // 5分钟后重试
}
使用 Redis 限流
如果你使用 Redis,可以使用 Illuminate\Queue\Middleware\RateLimitedWithRedis 中间件,它专为 Redis 优化,比基本限流中间件更高效:
use Illuminate\Queue\Middleware\RateLimitedWithRedis;
public function middleware(): array
{
return [new RateLimitedWithRedis('backups')];
}
connection 方法可用于指定中间件应使用的 Redis 连接:
return [(new RateLimitedWithRedis('backups'))->connection('limiter')];
防止任务重叠
Laravel 包含一个 Illuminate\Queue\Middleware\WithoutOverlapping 中间件,允许你基于任意键防止任务重叠。当队列任务修改只应由一个任务同时修改的资源时,这非常有用。
例如,假设你有一个更新用户信用评分的队列任务,你想防止同一用户 ID 的信用评分更新任务重叠。为此,可以从任务的 middleware 方法返回 WithoutOverlapping 中间件:
use Illuminate\Queue\Middleware\WithoutOverlapping;
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping($this->user->id)];
}
将重叠任务释放回队列仍然会增加任务的总尝试次数。你可能需要相应地调整任务类上的 Tries 和 MaxExceptions 属性。例如,保持 Tries 默认为 1 将阻止任何重叠任务稍后被重试。
同一类型的任何重叠任务将被释放回队列。你还可以指定释放的任务在再次尝试之前必须经过的秒数:
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping($this->order->id))->releaseAfter(60)];
}
如果你想立即删除任何重叠任务使其不被重试,可以使用 dontRelease 方法:
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping($this->order->id))->dontRelease()];
}
WithoutOverlapping 中间件由 Laravel 的原子锁功能提供支持。有时,你的任务可能会以锁未释放的方式意外失败或超时。因此,你可以使用 expireAfter 方法显式定义锁过期时间。例如,下面的示例将指示 Laravel 在任务开始处理三分钟后释放 WithoutOverlapping 锁:
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping($this->order->id))->expireAfter(180)];
}
WithoutOverlapping 中间件需要支持锁的缓存驱动。目前,memcached、redis、dynamodb、database、file 和 array 缓存驱动支持原子锁。
场景示例:电商系统中,同一订单的库存扣减、发票生成、物流创建任务不能同时执行,避免数据竞争:
public function middleware(): array
{
return [
(new WithoutOverlapping("order:{$this->order->id}"))
->releaseAfter(30)
->expireAfter(300),
];
}
跨任务类共享锁键
默认情况下,WithoutOverlapping 中间件只会阻止同一类的任务重叠。因此,虽然两个不同的任务类可能使用相同的锁键,但它们不会被阻止重叠。但是,你可以使用 shared 方法指示 Laravel 跨任务类应用键:
use Illuminate\Queue\Middleware\WithoutOverlapping;
class ProviderIsDown
{
// ...
public function middleware(): array
{
return [
(new WithoutOverlapping("status:{$this->provider}"))->shared(),
];
}
}
class ProviderIsUp
{
// ...
public function middleware(): array
{
return [
(new WithoutOverlapping("status:{$this->provider}"))->shared(),
];
}
}
异常节流
Laravel 包含一个 Illuminate\Queue\Middleware\ThrottlesExceptions 中间件,允许你对异常进行节流。一旦任务抛出给定数量的异常,所有进一步执行该任务的尝试将延迟到指定的时间间隔过后。此中间件对于与不稳定的第三方服务交互的任务特别有用。
例如,假设一个与第三方 API 交互的队列任务开始抛出异常。要节流异常,可以从任务的 middleware 方法返回 ThrottlesExceptions 中间件。通常,此中间件应与实现基于时间的尝试的任务配对使用:
use DateTime;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new ThrottlesExceptions(10, 5 * 60)];
}
/**
* Determine the time at which the job should timeout.
*/
public function retryUntil(): DateTime
{
return now()->plus(minutes: 30);
}
中间件接受的第一个构造参数是任务在被节流前可以抛出的异常数量,第二个构造参数是任务被节流后再次尝试之前应经过的秒数。在上面的代码示例中,如果任务连续抛出 10 个异常,我们将等待 5 分钟再尝试该任务,受 30 分钟时间限制约束。
当任务抛出异常但尚未达到异常阈值时,任务通常会立即重试。但是,你可以通过在将中间件附加到任务时调用 backoff 方法来指定此类任务应延迟的分钟数:
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(10, 5 * 60))->backoff(5)];
}
backoff 方法也接受接收抛出异常的闭包,允许动态确定延迟:
use App\Exceptions\RateLimitedException;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Throwable;
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(10, 5 * 60))->backoff(
fn (Throwable $throwable) => $throwable instanceof RateLimitedException
? $throwable->retryAfterMinutes()
: 5
)];
}
在内部,此中间件使用 Laravel 的缓存系统实现限流,任务的类名用作缓存「键」。你可以通过在将中间件附加到任务时调用 by 方法来覆盖此键。如果你有多个任务与同一个第三方服务交互,并且希望它们共享一个公共的节流「桶」以确保它们遵守一个共享的限制,这可能很有用:
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(10, 10 * 60))->by('key')];
}
默认情况下,此中间件会节流每个异常。你可以在将中间件附加到任务时调用 when 方法来修改此行为。只有当提供给 when 方法的闭包返回 true 时,异常才会被节流:
use Illuminate\Http\Client\HttpClientException;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(10, 10 * 60))->when(
fn (Throwable $throwable) => $throwable instanceof HttpClientException
)];
}
与 when 方法不同(它将任务释放回队列或抛出异常),deleteWhen 方法允许你在给定异常发生时完全删除任务:
use App\Exceptions\CustomerDeletedException;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(2, 10 * 60))->deleteWhen(CustomerDeletedException::class)];
}
如果你希望将节流的异常报告给应用的异常处理器,可以在将中间件附加到任务时调用 report 方法。可选地,你可以向 report 方法提供一个闭包,只有当给定闭包返回 true 时才会报告异常:
use Illuminate\Http\Client\HttpClientException;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new ThrottlesExceptions(10, 10 * 60))->report(
fn (Throwable $throwable) => $throwable instanceof HttpClientException
)];
}
场景示例:调用第三方支付网关 API,如果连续失败 10 次则暂停 5 分钟再重试,避免被对方封禁 IP:
public function middleware(): array
{
return [
(new ThrottlesExceptions(10, 5 * 60))
->when(fn (Throwable $e) => $e instanceof PaymentGatewayException)
->report(fn (Throwable $e) => true) // 报告所有支付网关异常
->backoff(30), // 非节流异常 30 秒后重试
];
}
使用 Redis 节流异常
如果你使用 Redis,可以使用 Illuminate\Queue\Middleware\ThrottlesExceptionsWithRedis 中间件,它专为 Redis 优化,比基本异常节流中间件更高效:
use Illuminate\Queue\Middleware\ThrottlesExceptionsWithRedis;
public function middleware(): array
{
return [new ThrottlesExceptionsWithRedis(10, 10 * 60)];
}
connection 方法可用于指定中间件应使用的 Redis 连接:
return [(new ThrottlesExceptionsWithRedis(10, 10 * 60))->connection('limiter')];
释放任务
Release 中间件允许你在不执行任务的情况下将其释放回队列。Release::when 方法会在给定条件为 true 时释放任务,而 Release::unless 方法会在条件为 false 时释放任务:
use Illuminate\Queue\Middleware\Release;
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
Release::when($condition, releaseAfter: 60),
];
}
将任务释放回队列仍然会增加任务的总尝试次数。你可能需要相应地调整任务类上的 Tries 和 MaxExceptions 属性。
你也可以向 when 和 unless 方法传递闭包以进行更复杂的条件评估:
use Illuminate\Queue\Middleware\Release;
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
Release::when(function (): bool {
return ! $this->order->isPaid();
}, releaseAfter: 60),
];
}
场景示例:订单处理任务在支付未完成时释放回队列,60 秒后重试:
public function middleware(): array
{
return [
Release::when(fn () => ! $this->order->isPaid(), releaseAfter: 60),
];
}
跳过任务
Skip 中间件允许你指定任务应被跳过/删除,而无需修改任务的逻辑。Skip::when 方法会在给定条件为 true 时删除任务,而 Skip::unless 方法会在条件为 false 时删除任务:
use Illuminate\Queue\Middleware\Skip;
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
Skip::when($condition),
];
}
你也可以向 when 和 unless 方法传递闭包以进行更复杂的条件评估:
use Illuminate\Queue\Middleware\Skip;
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
Skip::when(function (): bool {
return $this->shouldSkip();
}),
];
}
分发任务
编写完任务类后,可以使用任务自身的 dispatch 方法分发它。传递给 dispatch 方法的参数将被传递给任务的构造函数:
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPodcast;
use App\Models\Podcast;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PodcastController extends Controller
{
/**
* Store a new podcast.
*/
public function store(Request $request): RedirectResponse
{
$podcast = Podcast::create(/* ... */);
// ...
ProcessPodcast::dispatch($podcast);
return redirect('/podcasts');
}
}
如果你想有条件地分发任务,可以使用 dispatchIf 和 dispatchUnless 方法:
ProcessPodcast::dispatchIf($accountActive, $podcast);
ProcessPodcast::dispatchUnless($accountSuspended, $podcast);
在新的 Laravel 应用中,database 连接被定义为默认队列。你可以通过更改应用 .env 文件中的 QUEUE_CONNECTION 环境变量来指定不同的默认队列连接。
延迟分发
如果你想指定任务不应立即可供队列 worker 处理,可以在分发任务时使用 delay 方法。例如,让我们指定任务在分发 10 分钟后才可供处理:
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPodcast;
use App\Models\Podcast;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PodcastController extends Controller
{
/**
* Store a new podcast.
*/
public function store(Request $request): RedirectResponse
{
$podcast = Podcast::create(/* ... */);
// ...
ProcessPodcast::dispatch($podcast)
->delay(now()->plus(minutes: 10));
return redirect('/podcasts');
}
}
在某些情况下,任务可能配置了默认延迟。如果你需要绕过此延迟并分发任务以立即处理,可以使用 withoutDelay 方法:
ProcessPodcast::dispatch($podcast)->withoutDelay();
Amazon SQS 队列服务的最大延迟时间为 15 分钟。
场景示例:用户注册后延迟 5 分钟发送引导邮件,给用户足够时间完善资料:
SendWelcomeEmail::dispatch($user)->delay(now()->plus(minutes: 5));
同步分发
如果你想立即(同步)分发任务,可以使用 dispatchSync 方法。使用此方法时,任务不会被排队,而是在当前进程内立即执行:
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPodcast;
use App\Models\Podcast;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PodcastController extends Controller
{
/**
* Store a new podcast.
*/
public function store(Request $request): RedirectResponse
{
$podcast = Podcast::create(/* ... */);
// Create podcast...
ProcessPodcast::dispatchSync($podcast);
return redirect('/podcasts');
}
}
延迟同步分发
使用延迟同步分发,你可以在当前进程内分发任务进行处理,但会在 HTTP 响应发送给用户之后执行。这允许你同步处理「排队」任务而不减慢用户的应用体验。要延迟同步任务的执行,将任务分发到 deferred 连接:
RecordDelivery::dispatch($order)->onConnection('deferred');
deferred 连接也作为默认的故障转移队列。
类似地,background 连接在 HTTP 响应发送给用户后处理任务;但是,任务在单独生成的 PHP 进程中处理,使 PHP-FPM / 应用 worker 可以处理另一个传入的 HTTP 请求:
RecordDelivery::dispatch($order)->onConnection('background');
批量分发
如果你需要一次分发多个独立任务,且不需要批次跟踪或回调,可以使用 Bus 门面的 bulk 方法。Laravel 会按任务配置的队列连接和队列名分组,并将每组批量推送到适当的队列:
use App\Jobs\ProcessUser;
use Illuminate\Support\Facades\Bus;
Bus::bulk(
$users->map(fn ($user) => new ProcessUser($user))
);
分发前准备任务
如果任务在被推入队列之前需要准备或检查其状态,任务可以实现 Illuminate\Contracts\Queue\PreparesForDispatch 接口。Laravel 会在分发任务之前调用任务的 prepareForDispatch 方法。如果此方法返回 false,任务将不会被分发:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\PreparesForDispatch;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
class SyncPodcasts implements PreparesForDispatch, ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct(
public array $podcastIds,
) {}
/**
* Prepare the job before dispatching.
*/
public function prepareForDispatch(): bool
{
return collect($this->podcastIds)
->reject(fn (int $id) => Cache::has("podcast-syncing:{$id}"))
->isNotEmpty();
}
}
任务与数据库事务
虽然在数据库事务中分发任务完全没问题,但你应该特别注意确保任务能够成功执行。在事务中分发任务时,任务可能会在父事务提交之前被 worker 处理。当这种情况发生时,你在数据库事务中对模型或数据库记录所做的任何更新可能尚未反映在数据库中。此外,在事务中创建的任何模型或数据库记录可能不存在于数据库中。
幸运的是,Laravel 提供了几种解决此问题的方法。首先,你可以在队列连接的配置数组中设置 after_commit 连接选项:
'redis' => [
'driver' => 'redis',
// ...
'after_commit' => true,
],
当 after_commit 选项为 true 时,你可以在数据库事务中分发任务;但 Laravel 会等到未提交的父数据库事务提交后再实际分发任务。当然,如果当前没有打开的数据库事务,任务将立即被分发。
如果事务因事务期间发生的异常而回滚,在该事务期间分发的任务将被丢弃。
将 after_commit 配置选项设为 true 还会导致所有队列事件监听器、邮件、通知和广播事件在所有打开的数据库事务提交后才被分发。
内联指定提交分发行为
如果你没有将 after_commit 队列连接配置选项设为 true,你仍然可以指示特定任务应在所有打开的数据库事务提交后才被分发。为此,你可以在分发操作上链式调用 afterCommit 方法:
use App\Jobs\ProcessPodcast;
ProcessPodcast::dispatch($podcast)->afterCommit();
同样,如果 after_commit 配置选项设为 true,你可以指示特定任务应立即分发,无需等待任何打开的数据库事务提交:
ProcessPodcast::dispatch($podcast)->beforeCommit();
任务链
任务链允许你指定一组队列任务在主任务成功执行后按顺序运行。如果链中的一个任务失败,其余任务将不会运行。要执行队列任务链,可以使用 Bus 门面提供的 chain 方法。Laravel 的命令总线是一个底层组件,队列任务分发就是在其之上构建的:
use App\Jobs\OptimizePodcast;
use App\Jobs\ProcessPodcast;
use App\Jobs\ReleasePodcast;
use Illuminate\Support\Facades\Bus;
Bus::chain([
new ProcessPodcast,
new OptimizePodcast,
new ReleasePodcast,
])->dispatch();
除了链式任务类实例外,你还可以链式闭包:
Bus::chain([
new ProcessPodcast,
new OptimizePodcast,
function () {
Podcast::update(/* ... */);
},
])->dispatch();
在任务中使用 $this->delete() 方法删除任务不会阻止链中的后续任务被处理。只有链中的任务失败时,链才会停止执行。
场景示例:电商订单处理流程——依次执行库存扣减、发票生成、物流创建、通知发送:
use App\Jobs\ProcessOrder;
use App\Jobs\DeductInventory;
use App\Jobs\GenerateInvoice;
use App\Jobs\SendOrderConfirmation;
use Illuminate\Support\Facades\Bus;
Bus::chain([
new ProcessOrder($order),
new DeductInventory($order),
new GenerateInvoice($order),
new SendOrderConfirmation($order),
])->onQueue('orders')->catch(function (Throwable $e) use ($order) {
// 任何步骤失败时回滚或通知管理员
$order->markAsFailed();
Notification::route('mail', 'admin@example.com')
->notify(new OrderProcessingFailed($order, $e));
})->dispatch();
链的连接和队列
如果你想指定链式任务应使用的连接和队列,可以使用 onConnection 和 onQueue 方法。这些方法指定应使用的队列连接和队列名,除非队列任务被显式分配了不同的连接/队列:
Bus::chain([
new ProcessPodcast,
new OptimizePodcast,
new ReleasePodcast,
])->onConnection('redis')->onQueue('podcasts')->dispatch();
向链中添加任务
有时,你可能需要从链中的另一个任务内向现有任务链前插或追加任务。你可以使用 prependToChain 和 appendToChain 方法实现:
/**
* Execute the job.
*/
public function handle(): void
{
// ...
// Prepend to the current chain, run job immediately after current job...
$this->prependToChain(new TranscribePodcast);
// Append to the current chain, run job at end of chain...
$this->appendToChain(new TranscribePodcast);
}
链失败
当链式任务中有一个任务失败时,可以使用 catch 方法指定一个应在链中任务失败时被调用的闭包。给定的回调将接收导致任务失败的 Throwable 实例:
use Illuminate\Support\Facades\Bus;
use Throwable;
Bus::chain([
new ProcessPodcast,
new OptimizePodcast,
new ReleasePodcast,
])->catch(function (Throwable $e) {
// A job within the chain has failed...
})->dispatch();
由于链回调被序列化并由 Laravel 队列在稍后执行,因此不应在链回调中使用 $this 变量。
自定义队列和连接
分发到特定队列
通过将任务推送到不同队列,你可以「分类」队列任务,甚至优先分配 worker 处理各种队列。请注意,这不会将任务推送到队列配置文件中定义的不同队列「连接」,而是推送到单个连接内的特定队列。要指定队列,在分发任务时使用 onQueue 方法:
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPodcast;
use App\Models\Podcast;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PodcastController extends Controller
{
/**
* Store a new podcast.
*/
public function store(Request $request): RedirectResponse
{
$podcast = Podcast::create(/* ... */);
// Create podcast...
ProcessPodcast::dispatch($podcast)->onQueue('processing');
return redirect('/podcasts');
}
}
或者,你可以通过在任务构造函数中调用 onQueue 方法来指定任务的队列:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessPodcast implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct()
{
$this->onQueue('processing');
}
}
分发到特定连接
如果你的应用与多个队列连接交互,可以使用 onConnection 方法指定将任务推送到哪个连接:
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPodcast;
use App\Models\Podcast;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class PodcastController extends Controller
{
/**
* Store a new podcast.
*/
public function store(Request $request): RedirectResponse
{
$podcast = Podcast::create(/* ... */);
// Create podcast...
ProcessPodcast::dispatch($podcast)->onConnection('sqs');
return redirect('/podcasts');
}
}
你可以链式调用 onConnection 和 onQueue 方法来指定任务的连接和队列:
ProcessPodcast::dispatch($podcast)
->onConnection('sqs')
->onQueue('processing');
或者,你可以通过在任务构造函数中调用 onConnection 方法来指定任务的连接:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessPodcast implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct()
{
$this->onConnection('sqs');
}
}
队列路由
你可以使用 Queue 门面的 route 方法为特定任务类定义默认连接和队列。当你希望确保某些任务始终使用特定队列,而无需在任务上指定连接或队列时,这非常有用。
除了路由特定任务类外,你还可以向 route 方法传递接口、trait 或父类。当你这样做时,任何实现该接口、使用该 trait 或继承该父类的任务都将自动使用配置的连接和队列。
通常,你应该从服务提供者的 boot 方法调用 route 方法:
use App\Concerns\RequiresVideo;
use App\Jobs\ProcessPodcast;
use App\Jobs\ProcessVideo;
use Illuminate\Support\Facades\Queue;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Queue::route(ProcessPodcast::class, connection: 'redis', queue: 'podcasts');
Queue::route(RequiresVideo::class, queue: 'video');
}
当指定连接而不指定队列时,任务将被发送到默认队列:
Queue::route(ProcessPodcast::class, connection: 'redis');
你也可以通过向 route 方法传递数组来一次路由多个任务类:
Queue::route([
ProcessPodcast::class => ['podcasts', 'redis'], // Queue and connection
ProcessVideo::class => 'videos', // Queue only (uses default connection)
]);
队列路由仍然可以在任务级别上被覆盖。
指定最大尝试次数/超时值
最大尝试次数
任务尝试是 Laravel 队列系统的核心概念,驱动许多高级功能。虽然一开始可能令人困惑,但在修改默认配置之前了解它们的工作方式很重要。
当任务被分发时,它被推送到队列上。然后 worker 拾取它并尝试执行。这就是一次任务尝试。
然而,尝试不一定意味着任务的 handle 方法被执行了。尝试也可能通过以下几种方式被「消耗」:
- 任务在执行期间遇到未处理的异常。
- 任务通过
$this->release()被手动释放回队列。 WithoutOverlapping或RateLimited等中间件无法获取锁并释放了任务。- 任务超时。
- 任务的
handle方法运行并完成,没有抛出异常。
你可能不希望无限期地尝试任务。因此,Laravel 提供了多种方式来指定任务可以被尝试的次数或时长。
默认情况下,Laravel 只尝试任务一次。如果你的任务使用 WithoutOverlapping 或 RateLimited 等中间件,或者你手动释放任务,你可能需要通过 tries 选项增加允许的尝试次数。
指定任务最大尝试次数的一种方法是通过 Artisan 命令行的 --tries 开关。这将适用于 worker 处理的所有任务,除非正在处理的任务指定了它可以被尝试的次数:
php artisan queue:work --tries=3
如果任务超过其最大尝试次数,它将被视为「失败」任务。有关处理失败任务的更多信息,请查阅失败任务文档。如果向 queue:work 命令提供 --tries=0,任务将被无限重试。
你可以通过在任务类上使用 Tries 属性定义最大尝试次数来采取更精细的方法。如果在任务上指定了最大尝试次数,它将优先于命令行上提供的 --tries 值:
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\Tries;
#[Tries(5)]
class ProcessPodcast implements ShouldQueue
{
// ...
}
如果你需要对特定任务最大尝试次数的动态控制,可以在任务上定义 tries 方法:
/**
* Determine number of times the job may be attempted.
*/
public function tries(): int
{
return 5;
}
基于时间的尝试
作为定义任务在失败前可以被尝试次数的替代方案,你可以定义任务不再被尝试的时间。这允许任务在给定时间范围内被尝试任意次数。要定义任务不再被尝试的时间,在任务类中添加 retryUntil 方法。此方法应返回 DateTime 实例:
use DateTime;
/**
* Determine the time at which the job should timeout.
*/
public function retryUntil(): DateTime
{
return now()->plus(minutes: 10);
}
如果同时定义了 retryUntil 和 tries,Laravel 优先使用 retryUntil 方法。
你也可以在队列事件监听器和队列通知上定义 Tries 属性或 retryUntil 方法。
最大异常数
有时你可能希望指定任务可以被尝试多次,但如果重试是由给定数量的未处理异常触发的(而不是由 release 方法直接释放),则应该失败。为此,你可以在任务类上使用 Tries 和 MaxExceptions 属性:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Redis;
#[Tries(25)]
#[MaxExceptions(3)]
class ProcessPodcast implements ShouldQueue
{
use Queueable;
/**
* Execute the job.
*/
public function handle(): void
{
Redis::throttle('key')->allow(10)->every(60)->then(function () {
// Lock obtained, process the podcast...
}, function () {
// Unable to obtain lock...
return $this->release(10);
});
}
}
在此示例中,如果应用无法获取 Redis 锁,任务将被释放 10 秒,并继续重试最多 25 次。但是,如果任务抛出 3 个未处理的异常,任务将失败。
通过异常停止重试
有时异常表示队列任务应立即失败而不是被释放以进行另一次尝试。你可以在应用的 bootstrap/app.php 文件中使用 dontRetry 异常方法配置应停止任务重试的异常类型:
use App\Exceptions\InvalidPodcastSourceException;
use Illuminate\Foundation\Configuration\Exceptions;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->dontRetry([
InvalidPodcastSourceException::class,
]);
})
如果你需要更多控制何时应停止重试,可以向 dontRetryWhen 方法提供一个闭包。当闭包返回 true 时,任务将被标记为失败且不会被重试:
use App\Exceptions\PodcastProcessingException;
use Illuminate\Foundation\Configuration\Exceptions;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->dontRetryWhen(function (PodcastProcessingException $e) {
return $e->reason() === 'Subscription expired';
});
})
超时
通常,你大致知道你期望队列任务需要多长时间。因此,Laravel 允许你指定「超时」值。默认情况下,超时值为 60 秒。如果任务处理时间超过超时值指定的秒数,处理任务的 worker 将以错误退出。通常,worker 会由服务器上配置的进程管理器自动重启。
可以使用 Artisan 命令行的 --timeout 开关指定任务可以运行的最大秒数:
php artisan queue:work --timeout=30
如果任务因持续超时而超过最大尝试次数,它将被标记为失败。
你也可以使用任务类上的 Timeout 属性定义任务允许运行的最大秒数。如果在任务上指定了超时,它将优先于命令行上指定的任何超时:
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\Timeout;
#[Timeout(120)]
class ProcessPodcast implements ShouldQueue
{
// ...
}
有时,IO 阻塞进程(如套接字或传出 HTTP 连接)可能不遵守你指定的超时。因此,使用这些功能时,你也应尝试使用其 API 指定超时。例如,使用 Guzzle 时,你应始终指定连接和请求超时值。
必须安装 PCNTL PHP 扩展才能指定任务超时。此外,任务的「超时」值应始终小于其 「retry after」 值。否则,任务可能在实际完成执行或超时之前就被重新尝试。当 queue:work 命令与 --once 选项一起调用时,--timeout 选项无效。
超时即失败
如果你想指示任务在超时时应被标记为失败,可以在任务类上使用 FailOnTimeout 属性:
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\FailOnTimeout;
#[FailOnTimeout]
class ProcessPodcast implements ShouldQueue
{
// ...
}
默认情况下,当任务超时时,它会消耗一次尝试并被释放回队列(如果允许重试)。但是,如果你将任务配置为超时即失败,它将不会被重试,无论 tries 设置的值如何。
SQS FIFO 和公平队列
Laravel 支持 Amazon SQS FIFO(先进先出)和公平队列。FIFO 队列允许你按照任务发送的确切顺序处理任务,并通过消息去重确保恰好一次处理。
FIFO 队列需要消息组 ID 来确定哪些任务可以并行处理。具有相同组 ID 的任务按顺序处理,而具有不同组 ID 的消息可以并发处理。
Laravel 提供了流畅的 onGroup 方法来在分发任务时指定消息组 ID:
ProcessOrder::dispatch($order)
->onGroup("customer-{$order->customer_id}");
SQS FIFO 队列支持消息去重以确保恰好一次处理。在任务类中实现 deduplicationId 方法以提供自定义去重 ID:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessSubscriptionRenewal implements ShouldQueue
{
use Queueable;
// ...
/**
* Get the job's deduplication ID.
*/
public function deduplicationId(): string
{
return "renewal-{$this->subscription->id}";
}
}
公平队列
如果你使用 SQS 标准队列,设置消息组会启用公平排队。换句话说,一旦你分配了组,SQS 将使用它们来维护跨租户/工作负载的公平交付。不需要额外的 Laravel 配置。
除了在分发时调用 onGroup 外,你还可以直接在任务上定义 messageGroup 方法:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessOrder implements ShouldQueue
{
use Queueable;
// ...
/**
* Get the job's message group.
*/
public function messageGroup(): string
{
return "customer-{$this->order->customer_id}";
}
}
FIFO 监听器、邮件和通知
使用 FIFO 队列时,你还需要在监听器、邮件和通知上定义消息组。或者,你可以将这些对象的队列实例分发到非 FIFO 队列。
要为队列事件监听器定义消息组,在监听器上定义 messageGroup 方法。你还可以选择性地定义 deduplicationId 方法:
<?php
namespace App\Listeners;
class SendShipmentNotification
{
// ...
/**
* Get the job's message group.
*/
public function messageGroup(): string
{
return 'shipments';
}
/**
* Get the job's deduplication ID.
*/
public function deduplicationId(): string
{
return "shipment-notification-{$this->shipment->id}";
}
}
当发送将被排入 FIFO 队列的邮件时,你应在发送通知时调用 onGroup 方法和可选的 withDeduplicator 方法:
use App\Mail\InvoicePaid;
use Illuminate\Support\Facades\Mail;
$invoicePaid = (new InvoicePaid($invoice))
->onGroup('invoices')
->withDeduplicator(fn () => 'invoices-'.$invoice->id);
Mail::to($request->user())->send($invoicePaid);
当发送将被排入 FIFO 队列的通知时,你应在发送通知时调用 onGroup 方法和可选的 withDeduplicator 方法:
use App\Notifications\InvoicePaid;
$invoicePaid = (new InvoicePaid($invoice))
->onGroup('invoices')
->withDeduplicator(fn () => 'invoices-'.$invoice->id);
$user->notify($invoicePaid);
队列故障转移
failover 队列驱动在推送任务到队列时提供自动故障转移功能。如果 failover 配置的主队列连接因任何原因失败,Laravel 将自动尝试将任务推送到列表中配置的下一个连接。这对于确保队列可靠性至关重要的生产环境中的高可用性特别有用。
要配置故障转移队列连接,指定 failover 驱动并提供按顺序尝试的连接名数组。默认情况下,Laravel 在应用的 config/queue.php 配置文件中包含了一个示例故障转移配置:
'failover' => [
'driver' => 'failover',
'connections' => [
'redis',
'database',
'sync',
],
],
配置使用 failover 驱动的连接后,你需要在应用的 .env 文件中将故障转移连接设置为默认队列连接以使用故障转移功能:
QUEUE_CONNECTION=failover
接下来,为故障转移连接列表中的每个连接启动至少一个 worker:
php artisan queue:work redis
php artisan queue:work database
你不需要为使用 sync、background 或 deferred 队列驱动的连接运行 worker,因为这些驱动在当前 PHP 进程内处理任务。
当队列连接操作失败且故障转移被激活时,Laravel 将分发 Illuminate\Queue\Events\QueueFailedOver 事件,允许你报告或记录队列连接已失败。
如果你使用 Laravel Horizon,请记住 Horizon 仅管理 Redis 队列。如果你的故障转移列表包含 database,你应该在 Horizon 旁边运行一个常规的 php artisan queue:work database 进程。
错误处理
如果任务在处理期间抛出异常,任务将自动被释放回队列以便再次尝试。任务将继续被释放,直到它被尝试了应用允许的最大次数。最大尝试次数由 queue:work Artisan 命令上使用的 --tries 开关定义。或者,最大尝试次数可以在任务类本身上定义。有关运行队列 worker 的更多信息可以在下面找到。
手动释放任务
有时你可能希望手动将任务释放回队列,以便稍后再次尝试。你可以通过调用 release 方法实现:
/**
* Execute the job.
*/
public function handle(): void
{
// ...
$this->release();
}
默认情况下,release 方法会将任务释放回队列以立即处理。但是,你可以通过向 release 方法传递整数或日期实例来指示队列在给定秒数经过后才使任务可供处理:
$this->release(10);
$this->release(now()->plus(seconds: 10));
手动使任务失败
有时你可能需要手动将任务标记为「失败」。为此,你可以调用 fail 方法:
/**
* Execute the job.
*/
public function handle(): void
{
// ...
$this->fail();
}
如果你想因为你捕获的异常而将任务标记为失败,可以将异常传递给 fail 方法。或者,为方便起见,你可以传递字符串错误消息,它将被转换为异常:
$this->fail($exception);
$this->fail('Something went wrong.');
有关失败任务的更多信息,请查看处理任务失败的文档。
在特定异常时使任务失败
FailOnException 任务中间件允许你在抛出特定异常时短路重试。这允许在瞬态异常(如外部 API 错误)时重试,但在持久性异常(如用户权限被撤销)时永久使任务失败:
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Middleware\FailOnException;
use Illuminate\Support\Facades\Http;
#[Tries(3)]
class SyncChatHistory implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct(
public User $user,
) {}
/**
* Execute the job.
*/
public function handle(): void
{
$this->user->authorize('sync-chat-history');
$response = Http::throw()->get(
"https://chat.laravel.test/?user={$this->user->uuid}"
);
// ...
}
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
new FailOnException([AuthorizationException::class])
];
}
}
任务批处理
Laravel 的任务批处理功能允许你轻松地并行执行一组任务,然后在这批任务执行完成后执行一些操作。
开始之前,你应该创建一个数据库迁移来构建一个表,该表将包含有关任务批次的元信息,例如完成百分比。此迁移可以使用 make:queue-batches-table Artisan 命令生成:
php artisan make:queue-batches-table
php artisan migrate
定义可批处理任务
要定义可批处理任务,你应该像平常一样创建可队列任务;但是,你应在任务类上添加 Illuminate\Bus\Batchable trait。此 trait 提供对 batch 方法的访问,可用于检索任务正在执行的当前批次:
<?php
namespace App\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ImportCsv implements ShouldQueue
{
use Batchable, Queueable;
/**
* Execute the job.
*/
public function handle(): void
{
if ($this->batch()->cancelled()) {
// Determine if the batch has been cancelled...
return;
}
// Import a portion of the CSV file...
}
}
分发批次
要分发一批任务,你应该使用 Bus 门面的 batch 方法。当然,批处理在与完成回调结合使用时最为有用。因此,你可以使用 then、catch 和 finally 方法为批次定义完成回调。这些回调在被调用时都会接收一个 Illuminate\Bus\Batch 实例。
当运行多个队列 worker 时,批次中的任务将并行处理。因此,任务完成的顺序可能与它们被添加到批次的顺序不同。有关如何按顺序运行一系列任务的信息,请查阅任务链和批次文档。
在此示例中,我们将想象排队一批任务,每个任务处理 CSV 文件中给定数量的行:
use App\Jobs\ImportCsv;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Throwable;
$batch = Bus::batch([
new ImportCsv(1, 100),
new ImportCsv(101, 200),
new ImportCsv(201, 300),
new ImportCsv(301, 400),
new ImportCsv(401, 500),
])->before(function (Batch $batch) {
// The batch has been created but no jobs have been added...
})->progress(function (Batch $batch) {
// A single job has completed successfully...
})->then(function (Batch $batch) {
// All jobs completed successfully...
})->catch(function (Batch $batch, Throwable $e) {
// Batch job failure detected...
})->finally(function (Batch $batch) {
// The batch has finished executing...
})->dispatch();
return $batch->id;
可以通过 $batch->id 属性访问的批次 ID 可用于在批次被分发后查询 Laravel 命令总线以获取有关批次的信息。
由于批次回调被序列化并由 Laravel 队列在稍后执行,因此不应在回调中使用 $this 变量。此外,由于批处理任务被包装在数据库事务中,不应在任务中执行触发隐式提交的数据库语句。
场景示例:导入 10 万行 CSV 数据,将数据分成 500 个批次并行处理,完成后发送通知:
use App\Jobs\ImportCsvRow;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Notification;
$chunks = array_chunk($csvRows, 200); // 每个任务处理 200 行
$batch = Bus::batch(
collect($chunks)->map(fn ($chunk, $i) => new ImportCsvRow($chunk, $i * 200))
)->progress(function (Batch $batch) {
// 更新进度到缓存,前端轮询展示
Cache::put("import-progress:{$batch->id}", $batch->progress(), 300);
})->then(function (Batch $batch) {
// 所有任务成功完成
Notification::route('mail', $user->email)
->notify(new ImportCompleted($batch->id));
})->catch(function (Batch $batch, Throwable $e) {
// 首个失败任务
Log::error('CSV import batch failed', [
'batch_id' => $batch->id,
'failed_jobs' => $batch->failedJobs,
'error' => $e->getMessage(),
]);
})->name('Import CSV - ' . now()->format('Y-m-d H:i'))
->onConnection('redis')
->onQueue('imports')
->dispatch();
return $batch->id;
命名批次
一些工具如 Laravel Horizon 和 Laravel Telescope 可能在批次被命名时提供更用户友好的调试信息。要为批次分配任意名称,你可以在定义批次时调用 name 方法:
$batch = Bus::batch([
// ...
])->then(function (Batch $batch) {
// All jobs completed successfully...
})->name('Import CSV')->dispatch();
批次连接和队列
如果你想指定批处理任务应使用的连接和队列,可以使用 onConnection 和 onQueue 方法。所有批处理任务必须在同一连接和队列中执行:
$batch = Bus::batch([
// ...
])->then(function (Batch $batch) {
// All jobs completed successfully...
})->onConnection('redis')->onQueue('imports')->dispatch();
链和批次
你可以通过将链式任务放在数组中,在批次内定义一组链式任务。例如,我们可以并行执行两个任务链,并在两个任务链都完成处理后执行回调:
use App\Jobs\ReleasePodcast;
use App\Jobs\SendPodcastReleaseNotification;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
Bus::batch([
[
new ReleasePodcast(1),
new SendPodcastReleaseNotification(1),
],
[
new ReleasePodcast(2),
new SendPodcastReleaseNotification(2),
],
])->then(function (Batch $batch) {
// All jobs completed successfully...
})->dispatch();
反过来,你可以在链中运行任务批次,通过在链中定义批次。例如,你可以先运行一批任务来发布多个播客,然后运行一批任务来发送发布通知:
use App\Jobs\FlushPodcastCache;
use App\Jobs\ReleasePodcast;
use App\Jobs\SendPodcastReleaseNotification;
use Illuminate\Support\Facades\Bus;
Bus::chain([
new FlushPodcastCache,
Bus::batch([
new ReleasePodcast(1),
new ReleasePodcast(2),
]),
Bus::batch([
new SendPodcastReleaseNotification(1),
new SendPodcastReleaseNotification(2),
]),
])->dispatch();
向批次添加任务
有时从批处理任务内向批次添加额外任务可能很有用。这种模式在需要批处理数千个任务(在 Web 请求期间分发可能耗时太长)时很有用。因此,你可以选择分发一批初始的「加载器」任务,用更多任务填充批次:
$batch = Bus::batch([
new LoadImportBatch,
new LoadImportBatch,
new LoadImportBatch,
])->then(function (Batch $batch) {
// All jobs completed successfully...
})->name('Import Contacts')->dispatch();
在此示例中,我们将使用 LoadImportBatch 任务用额外任务填充批次。为此,我们可以在通过任务的 batch 方法访问的批次实例上使用 add 方法:
use App\Jobs\ImportContacts;
use Illuminate\Support\Collection;
/**
* Execute the job.
*/
public function handle(): void
{
if ($this->batch()->cancelled()) {
return;
}
$this->batch()->add(Collection::times(1000, function () {
return new ImportContacts;
}));
}
你只能从属于同一批次的任务内向该批次添加任务。
检查批次
提供给批次完成回调的 Illuminate\Bus\Batch 实例具有多种属性和方法,帮助你与检查给定批次任务:
// The UUID of the batch...
$batch->id;
// The name of the batch (if applicable)...
$batch->name;
// The number of jobs assigned to the batch...
$batch->totalJobs;
// The number of jobs that have not been processed by the queue...
$batch->pendingJobs;
// The number of jobs that have failed...
$batch->failedJobs;
// The number of jobs that have been processed thus far...
$batch->processedJobs();
// The completion percentage of the batch (0-100)...
$batch->progress();
// Indicates if the batch has finished executing...
$batch->finished();
// Cancel the execution of the batch...
$batch->cancel();
// Indicates if the batch has been cancelled...
$batch->cancelled();
从路由返回批次
所有 Illuminate\Bus\Batch 实例都是 JSON 可序列化的,这意味着你可以直接从应用的路由返回它们,以检索包含批次信息(包括完成进度)的 JSON 负载。这使得在应用 UI 中显示有关批次完成进度的信息变得方便。
要通过 ID 检索批次,可以使用 Bus 门面的 findBatch 方法:
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Route;
Route::get('/batch/{batchId}', function (string $batchId) {
return Bus::findBatch($batchId);
});
取消批次
有时你可能需要取消给定批次的执行。这可以通过在 Illuminate\Bus\Batch 实例上调用 cancel 方法实现:
/**
* Execute the job.
*/
public function handle(): void
{
if ($this->user->exceedsImportLimit()) {
$this->batch()->cancel();
return;
}
if ($this->batch()->cancelled()) {
return;
}
}
正如你在前面的示例中可能注意到的,批处理任务通常应在继续执行之前确定其对应的批次是否已被取消。但是,为方便起见,你可以将 SkipIfBatchCancelled 中间件分配给任务。顾名思义,此中间件将指示 Laravel 在其对应批次已被取消时不处理任务:
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [new SkipIfBatchCancelled];
}
批次失败
当批处理任务失败时,catch 回调(如果已分配)将被调用。此回调仅对批次中第一个失败的任务调用一次。
允许失败
当批次中的任务失败时,Laravel 将自动将批次标记为「已取消」。如果你愿意,可以禁用此行为,使任务失败不会自动将批次标记为已取消。这可以通过在分发批次时调用 allowFailures 方法实现:
$batch = Bus::batch([
// ...
])->then(function (Batch $batch) {
// All jobs completed successfully...
})->allowFailures()->dispatch();
你可以选择向 allowFailures 方法提供一个闭包,该闭包将在每次任务失败时执行:
$batch = Bus::batch([
// ...
])->allowFailures(function (Batch $batch, $exception) {
// Handle individual job failures...
})->dispatch();
重试失败的批处理任务
为方便起见,Laravel 提供了 queue:retry-batch Artisan 命令,允许你轻松重试给定批次的所有失败任务。此命令接受应重试其失败任务的批次 UUID:
php artisan queue:retry-batch 32dbc76c-4f82-4749-b610-a639fe0099b5
清理批次
如果不进行清理,job_batches 表会很快积累记录。为缓解此问题,你应该调度 queue:prune-batches Artisan 命令每天运行:
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:prune-batches')->daily();
默认情况下,所有超过 24 小时的已完成批次将被清理。你可以在调用命令时使用 hours 选项确定保留批次数据的时间。例如,以下命令将删除所有 48 小时前完成的批次:
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:prune-batches --hours=48')->daily();
有时,job_batches 表可能会积累从未成功完成的批次记录,例如任务失败且该任务从未被成功重试的批次。你可以使用 unfinished 选项指示 queue:prune-batches 命令清理这些未完成的批次记录:
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:prune-batches --hours=48 --unfinished=72')->daily();
同样,job_batches 表也可能积累已取消批次的记录。你可以使用 cancelled 选项指示 queue:prune-batches 命令清理这些已取消的批次记录:
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:prune-batches --hours=48 --cancelled=72')->daily();
在 DynamoDB 中存储批次
Laravel 还支持在 DynamoDB 而非关系型数据库中存储批次元信息。但是,你需要手动创建一个 DynamoDB 表来存储所有批次记录。
通常,此表应命名为 job_batches,但你应该根据应用 queue 配置文件中 queue.batching.table 配置值来命名表。
DynamoDB 批次表配置
job_batches 表应具有名为 application 的字符串主分区键和名为 id 的字符串主排序键。键的 application 部分将包含应用 app 配置文件中 name 配置值定义的应用名称。由于应用名称是 DynamoDB 表键的一部分,你可以使用同一个表为多个 Laravel 应用存储任务批次。
此外,如果你想利用自动批次清理,可以为表定义 ttl 属性。
DynamoDB 配置
接下来,安装 AWS SDK 以便你的 Laravel 应用可以与 Amazon DynamoDB 通信:
composer require aws/aws-sdk-php
然后,将 queue.batching.driver 配置选项的值设为 dynamodb。此外,你应在 batching 配置数组中定义 key、secret 和 region 配置选项。这些选项将用于与 AWS 认证。使用 dynamodb 驱动时,queue.batching.database 配置选项不是必需的:
'batching' => [
'driver' => env('QUEUE_BATCHING_DRIVER', 'dynamodb'),
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => 'job_batches',
],
在 DynamoDB 中清理批次
当使用 DynamoDB 存储任务批次信息时,用于清理存储在关系型数据库中的批次的典型清理命令将不起作用。相反,你可以利用 DynamoDB 的原生 TTL 功能来自动删除旧批次的记录。
如果你使用 ttl 属性定义了 DynamoDB 表,你可以定义配置参数来指示 Laravel 如何清理批次记录。queue.batching.ttl_attribute 配置值定义持有 TTL 的属性名,而 queue.batching.ttl 配置值定义批次记录可以从 DynamoDB 表中删除前的秒数,相对于记录最后一次被更新的时间:
'batching' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'dynamodb'),
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => 'job_batches',
'ttl_attribute' => 'ttl',
'ttl' => 60 * 60 * 24 * 7, // 7 days...
],
队列闭包
除了将任务类分发到队列外,你还可以分发闭包。这非常适合需要在当前请求周期之外执行的快速、简单任务。当将闭包分发到队列时,闭包的代码内容会进行加密签名,以确保它在传输过程中不被篡改:
use App\Models\Podcast;
$podcast = Podcast::find(1);
dispatch(function () use ($podcast) {
$podcast->publish();
});
要为队列闭包分配一个名称(可被队列报告面板使用,也会被 queue:work 命令显示),可以使用 name 方法:
dispatch(function () {
// ...
})->name('Publish Podcast');
使用 catch 方法,你可以提供一个在队列闭包耗尽所有队列配置的重试尝试后未能成功完成时执行的闭包:
use Throwable;
dispatch(function () use ($podcast) {
$podcast->publish();
})->catch(function (Throwable $e) {
// This job has failed...
});
由于 catch 回调被序列化并由 Laravel 队列在稍后执行,因此不应在 catch 回调中使用 $this 变量。
运行队列 Worker
queue:work 命令
Laravel 包含一个 Artisan 命令,它会启动队列 worker 并处理推入队列的新任务。你可以使用 queue:work Artisan 命令运行 worker。注意,一旦 queue:work 命令启动,它将持续运行直到手动停止或关闭终端:
php artisan queue:work
要保持 queue:work 进程在后台永久运行,你应该使用 Supervisor 等进程监视器来确保队列 worker 不会停止运行。
如果你希望命令输出中包含已处理的任务 ID、连接名和队列名,可以在调用 queue:work 命令时包含 -v 标志:
php artisan queue:work -v
请记住,队列 worker 是长生命周期的进程,会将已启动的应用状态存储在内存中。因此,它们在启动后不会注意到代码库中的更改。所以,在部署过程中,请确保重启队列 worker。此外,请记住,应用创建或修改的任何静态状态不会在任务之间自动重置。
或者,你可以运行 queue:listen 命令。使用 queue:listen 命令时,当你想要重新加载更新的代码或重置应用状态时,不需要手动重启 worker;但是,此命令的效率明显低于 queue:work 命令:
php artisan queue:listen
场景示例:开发环境使用 queue:listen 自动加载代码变更,生产环境使用 queue:work + Supervisor 保持高性能:
# 开发环境 - 代码修改后自动生效
php artisan queue:listen --tries=3
# 生产环境 - 高性能常驻进程
php artisan queue:work --tries=3 --max-time=3600
运行多个队列 Worker
要为队列分配多个 worker 并并发处理任务,你只需启动多个 queue:work 进程。这可以在本地通过终端中的多个标签页完成,也可以在生产环境中使用进程管理器的配置设置完成。使用 Supervisor 时,可以使用 numprocs 配置值。
指定连接和队列
你还可以指定 worker 应使用哪个队列连接。传递给 work 命令的连接名应对应于 config/queue.php 配置文件中定义的连接之一:
php artisan queue:work redis
默认情况下,queue:work 命令只处理给定连接上默认队列的任务。但是,你可以通过只处理给定连接的特定队列来进一步自定义队列 worker。例如,如果你的所有邮件都在 redis 队列连接的 emails 队列中处理,你可以发出以下命令启动只处理该队列的 worker:
php artisan queue:work redis --queue=emails
处理指定数量的任务
可以使用 --once 选项指示 worker 只处理队列中的单个任务:
php artisan queue:work --once
可以使用 --max-jobs 选项指示 worker 处理给定数量的任务然后退出。此选项与 Supervisor 结合使用可能很有用,这样你的 worker 在处理给定数量的任务后会自动重启,释放它们可能积累的任何内存:
php artisan queue:work --max-jobs=1000
处理所有排队任务后退出
可以使用 --stop-when-empty 选项指示 worker 处理所有任务然后优雅退出。如果你希望在队列为空后关闭容器,此选项在 Docker 容器中处理 Laravel 队列时很有用:
php artisan queue:work --stop-when-empty
处理指定秒数的任务
可以使用 --max-time 选项指示 worker 处理给定秒数的任务然后退出。此选项与 Supervisor 结合使用可能很有用,这样你的 worker 在处理任务给定时间后会自动重启,释放它们可能积累的任何内存:
# Process jobs for one hour and then exit...
php artisan queue:work --max-time=3600
Worker 睡眠持续时间
当队列上有可用任务时,worker 将持续处理任务,任务之间没有延迟。但是,sleep 选项确定了如果没有可用任务时 worker 将「睡眠」的秒数。当然,在睡眠期间,worker 不会处理任何新任务:
php artisan queue:work --sleep=3
维护模式和队列
当你的应用处于维护模式时,不会处理任何队列任务。一旦应用退出维护模式,任务将继续正常处理。
要强制队列 worker 即使启用了维护模式也处理任务,可以使用 --force 选项:
php artisan queue:work --force
资源注意事项
守护进程队列 worker 在处理每个任务之前不会「重启」框架。因此,你应在每个任务完成后释放任何重资源。例如,如果你使用 GD 库进行图片处理,应该在完成图片处理后使用 imagedestroy 释放内存。
队列优先级
有时你可能希望优先处理队列。例如,在 config/queue.php 配置文件中,你可以将 redis 连接的默认 queue 设为 low。但是,偶尔你可能希望将任务推送到 high 优先级队列:
dispatch((new Job)->onQueue('high'));
要启动一个确保所有 high 队列任务在继续处理 low 队列任务之前被处理的 worker,请向 work 命令传递逗号分隔的队列名列表:
php artisan queue:work --queue=high,low
队列 Worker 与部署
由于队列 worker 是长生命周期进程,如果不重启它们,它们不会注意到代码的更改。因此,使用队列 worker 部署应用的最简单方法是在部署过程中重启 worker。你可以通过发出 queue:restart 命令优雅地重启所有 worker:
php artisan queue:restart
此命令将指示所有队列 worker 在完成当前任务处理后优雅退出,以便不会丢失任何现有任务。由于队列 worker 会在 queue:restart 命令执行时退出,你应该运行 Supervisor 等进程管理器来自动重启队列 worker。
队列使用缓存来存储重启信号,因此在使用此功能之前,你应该验证是否为应用正确配置了缓存驱动。
响应 Worker 信号
当队列 worker 在处理任务时收到 SIGQUIT、SIGTERM 或 SIGINT 等终止信号时,worker 将在退出前完成当前任务。但是,你的任务可能需要在进程被服务器或容器编排器停止之前对信号做出反应。例如,长时间运行的导入任务可能需要停止拉取新记录并保存当前进度。
要从任务内部响应 worker 信号,实现 Illuminate\Contracts\Queue\Interruptible 接口并在任务上定义 interrupted 方法。worker 收到的信号编号将传递给 interrupted 方法:
<?php
namespace App\Jobs;
use App\Models\Import;
use Illuminate\Contracts\Queue\Interruptible;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ImportProducts implements ShouldQueue, Interruptible
{
use Queueable;
protected bool $shouldStop = false;
/**
* Create a new job instance.
*/
public function __construct(
public Import $import,
) {}
/**
* Execute the job.
*/
public function handle(): void
{
foreach ($this->import->pendingRows() as $row) {
if ($this->shouldStop) {
break;
}
// Import the product row...
}
$this->import->saveProgress();
}
/**
* Handle a signal received by the queue worker.
*/
public function interrupted(int $signal): void
{
$this->shouldStop = true;
}
}
interrupted 方法仅在任务当前正在运行时 worker 收到进程信号时被调用。它不是超时或任务失败方法的替代。
任务过期和超时
任务过期
在 config/queue.php 配置文件中,每个队列连接定义了一个 retry_after 选项。此选项指定队列连接在重试正在处理的任务之前应等待的秒数。例如,如果 retry_after 的值设为 90,则任务如果在处理 90 秒后未被释放或删除,将被释放回队列。通常,你应将 retry_after 值设为任务合理完成处理所需的最大秒数。
唯一不包含 retry_after 值的队列连接是 Amazon SQS。SQS 会根据在 AWS 控制台中管理的默认可见性超时重试任务。
Worker 超时
queue:work Artisan 命令暴露了 --timeout 选项。默认情况下,--timeout 值为 60 秒。如果任务处理时间超过超时值指定的秒数,处理任务的 worker 将以错误退出。通常,worker 会由服务器上配置的进程管理器自动重启:
php artisan queue:work --timeout=60
retry_after 配置选项和 --timeout CLI 选项是不同的,但协同工作以确保任务不会丢失且任务只被成功处理一次。
--timeout 值应始终比 retry_after 配置值至少短几秒。这将确保处理冻结任务的 worker 始终在任务被重试之前被终止。如果你的 --timeout 选项比 retry_after 配置值长,你的任务可能会被处理两次。
暂停和恢复队列 Worker
有时你可能需要暂时阻止队列 worker 处理新任务而不完全停止 worker。例如,你可能希望在系统维护期间暂停任务处理。Laravel 提供了 queue:pause 和 queue:continue Artisan 命令来暂停和恢复队列 worker。
要暂停特定队列,提供队列连接名和队列名:
php artisan queue:pause database:default
在此示例中,database 是队列连接名,default 是队列名。一旦队列被暂停,任何处理该队列任务的 worker 将继续完成其当前任务,但在队列恢复之前不会拾取任何新任务。
要恢复处理已暂停队列上的任务,使用 queue:continue 命令:
php artisan queue:continue database:default
恢复队列后,worker 将立即开始处理该队列上的新任务。请注意,暂停队列不会停止 worker 进程本身——它只会阻止 worker 处理指定队列上的新任务。
Worker 重启和暂停信号
默认情况下,队列 worker 在每次任务迭代时轮询缓存驱动以获取重启和暂停信号。虽然此轮询对于响应 queue:restart 和 queue:pause 命令至关重要,但它确实引入了少量性能开销。
如果你需要优化性能且不需要这些中断功能,可以通过在 Queue 门面上调用 withoutInterruptionPolling 方法来全局禁用此轮询。这通常应在 AppServiceProvider 的 boot 方法中完成:
use Illuminate\Support\Facades\Queue;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Queue::withoutInterruptionPolling();
}
或者,你可以通过在 Illuminate\Queue\Worker 类上设置静态 $restartable 或 $pausable 属性来单独禁用重启或暂停轮询:
use Illuminate\Queue\Worker;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Worker::$restartable = false;
Worker::$pausable = false;
}
当中断轮询被禁用时,worker 将不会响应 queue:restart 或 queue:pause 命令(取决于禁用了哪些功能)。
Supervisor 配置
在生产环境中,你需要一种方法来保持 queue:work 进程运行。queue:work 进程可能因各种原因停止运行,例如超过 worker 超时或执行 queue:restart 命令。
因此,你需要配置一个进程监视器,能够检测 queue:work 进程何时退出并自动重启它们。此外,进程监视器还允许你指定要并发运行的 queue:work 进程数量。Supervisor 是 Linux 环境中常用的进程监视器,我们将在以下文档中讨论如何配置它。
场景示例:生产服务器上运行 8 个队列 worker 进程,每个进程处理 1000 个任务后自动重启以释放内存:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-jobs=1000 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=8
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600
安装 Supervisor
Supervisor 是 Linux 操作系统的进程监视器,会在 queue:work 进程失败时自动重启它们。要在 Ubuntu 上安装 Supervisor,可以使用以下命令:
sudo apt-get install supervisor
如果你觉得自己配置和管理 Supervisor 太复杂,可以考虑使用 Laravel Cloud,它提供了一个全托管平台来运行 Laravel 队列 worker。
配置 Supervisor
Supervisor 配置文件通常存储在 /etc/supervisor/conf.d 目录中。在此目录中,你可以创建任意数量的配置文件来指示 supervisor 如何监视你的进程。例如,让我们创建一个 laravel-worker.conf 文件来启动和监视 queue:work 进程:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /home/forge/app.com/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=forge
numprocs=8
redirect_stderr=true
stdout_logfile=/home/forge/app.com/worker.log
stopwaitsecs=3600
在此示例中,numprocs 指令将指示 Supervisor 运行八个 queue:work 进程并监视所有进程,在它们失败时自动重启。你应更改配置的 command 指令以反映你期望的队列连接和 worker 选项。
你应确保 stopwaitsecs 的值大于最长运行任务消耗的秒数。否则,Supervisor 可能会在任务完成处理之前终止它。
启动 Supervisor
创建配置文件后,你可以使用以下命令更新 Supervisor 配置并启动进程:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "laravel-worker:*"
有关 Supervisor 的更多信息,请查阅 Supervisor 文档。
处理失败任务
有时你的队列任务会失败。别担心,事情并不总是按计划进行!Laravel 包含一种便捷的方式来指定任务应被尝试的最大次数。异步任务超过此尝试次数后,将被插入到 failed_jobs 数据库表中。同步分发的任务失败时不会存储在此表中,其异常会立即被应用处理。
新 Laravel 应用中通常已存在创建 failed_jobs 表的迁移。但是,如果你的应用不包含此表的迁移,可以使用 make:queue-failed-table 命令创建迁移:
php artisan make:queue-failed-table
php artisan migrate
运行队列 worker 进程时,你可以使用 queue:work 命令上的 --tries 开关指定任务应被尝试的最大次数。如果不为 --tries 选项指定值,任务将只被尝试一次或按任务类的 Tries 属性指定的次数尝试:
php artisan queue:work redis --tries=3
使用 --backoff 选项,你可以指定 Laravel 在重试遇到异常的任务之前应等待的秒数。默认情况下,任务会被立即释放回队列以便再次尝试:
php artisan queue:work redis --tries=3 --backoff=3
如果你希望按任务配置 Laravel 在重试遇到异常的任务之前应等待的秒数,可以在任务类上使用 Backoff 属性:
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\Backoff;
#[Backoff(3)]
class ProcessPodcast implements ShouldQueue
{
// ...
}
如果你需要更复杂的逻辑来确定任务的退避时间,可以在任务类上定义 backoff 方法:
/**
* Calculate the number of seconds to wait before retrying the job.
*/
public function backoff(): int
{
return 3;
}
你可以通过定义退避值数组来轻松配置「指数」退避。在此示例中,第一次重试的延迟为 1 秒,第二次重试为 5 秒,第三次重试为 10 秒,如果还有更多尝试,则每次后续重试都为 10 秒:
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\Backoff;
#[Backoff([1, 5, 10])]
class ProcessPodcast implements ShouldQueue
{
// ...
}
失败任务后清理
当特定任务失败时,你可能希望向用户发送警报或恢复任务部分完成的操作。为此,你可以在任务类上定义 failed 方法。导致任务失败的 Throwable 实例将传递给 failed 方法:
<?php
namespace App\Jobs;
use App\Models\Podcast;
use App\Services\AudioProcessor;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class ProcessPodcast implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct(
public Podcast $podcast,
) {}
/**
* Execute the job.
*/
public function handle(AudioProcessor $processor): void
{
// Process uploaded podcast...
}
/**
* Handle a job failure.
*/
public function failed(?Throwable $exception): void
{
// Send user notification of failure, etc...
}
}
在调用 failed 方法之前会实例化任务的新实例;因此,在 handle 方法中可能发生的任何类属性修改都将丢失。
失败的任务不一定是遇到未处理异常的任务。当任务耗尽所有允许的尝试次数时,也可能被视为失败。这些尝试可以通过以下几种方式被消耗:
- 任务超时。
- 任务在执行期间遇到未处理异常。
- 任务被手动或通过中间件释放回队列。
如果最后一次尝试因任务执行期间抛出的异常而失败,该异常将传递给任务的 failed 方法。但是,如果任务因达到最大允许尝试次数而失败,$exception 将是 Illuminate\Queue\MaxAttemptsExceededException 的实例。类似地,如果任务因超过配置的超时而失败,$exception 将是 Illuminate\Queue\TimeoutExceededException 的实例。
场景示例:视频处理任务失败后,通知用户并清理临时文件:
public function failed(?Throwable $exception): void
{
// 清理临时文件
Storage::deleteDirectory("temp/videos/{$this->video->id}");
// 标记视频为处理失败
$this->video->update(['status' => 'failed']);
// 通知用户
$this->video->user->notify(new VideoProcessingFailed($this->video, $exception));
// 如果是超时异常,发送到 Sentry
if ($exception instanceof TimeoutExceededException) {
Sentry::captureException($exception);
}
}
重试失败任务
要查看已插入 failed_jobs 数据库表的所有失败任务,可以使用 queue:failed Artisan 命令:
php artisan queue:failed
queue:failed 命令将列出任务 ID、连接、队列、失败时间和其他有关任务的信息。任务 ID 可用于重试失败的任务。例如,要重试 ID 为 ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece 的失败任务,发出以下命令:
php artisan queue:retry ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece
如有必要,你可以向命令传递多个 ID:
php artisan queue:retry ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece 91401d2c-0784-4f43-824c-34f94a33c24d
你还可以重试特定队列的所有失败任务:
php artisan queue:retry --queue=name
要重试所有失败任务,执行 queue:retry 命令并传递 all 作为 ID:
php artisan queue:retry all
如果你想删除失败任务,可以使用 queue:forget 命令:
php artisan queue:forget 91401d2c-0784-4f43-824c-34f94a33c24d
使用 Horizon 时,应使用 horizon:forget 命令而不是 queue:forget 命令来删除失败任务。
要从 failed_jobs 表中删除所有失败任务,可以使用 queue:flush 命令:
php artisan queue:flush
queue:flush 命令会从队列中删除所有失败任务记录,无论失败任务有多旧。你可以使用 --hours 选项只删除一定小时数之前或更早失败的任务:
php artisan queue:flush --hours=48
忽略缺失模型
当将 Eloquent 模型注入任务时,模型会在被放入队列之前自动序列化,并在任务处理时从数据库重新检索。但是,如果模型在任务等待 worker 处理期间被删除,你的任务可能会因 ModelNotFoundException 而失败。
为方便起见,你可以选择使用任务类上的 DeleteWhenMissingModels 属性自动删除具有缺失模型的任务。当此属性存在时,Laravel 将悄悄丢弃任务而不抛出异常:
<?php
namespace App\Jobs;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
#[DeleteWhenMissingModels]
class ProcessPodcast implements ShouldQueue
{
// ...
}
清理失败任务
你可以通过调用 queue:prune-failed Artisan 命令清理应用 failed_jobs 表中的记录:
php artisan queue:prune-failed
默认情况下,所有超过 24 小时的失败任务记录将被清理。如果你向命令提供 --hours 选项,则只保留最近 N 小时内插入的失败任务记录。例如,以下命令将删除所有 48 小时前插入的失败任务记录:
php artisan queue:prune-failed --hours=48
在 DynamoDB 中存储失败任务
Laravel 还支持在 DynamoDB 而非关系型数据库表中存储失败任务记录。但是,你必须手动创建一个 DynamoDB 表来存储所有失败任务记录。通常,此表应命名为 failed_jobs,但你应该根据应用 queue 配置文件中 queue.failed.table 配置值来命名表。
failed_jobs 表应具有名为 application 的字符串主分区键和名为 uuid 的字符串主排序键。键的 application 部分将包含应用 app 配置文件中 name 配置值定义的应用名称。由于应用名称是 DynamoDB 表键的一部分,你可以使用同一个表为多个 Laravel 应用存储失败任务。
此外,确保安装 AWS SDK 以便你的 Laravel 应用可以与 Amazon DynamoDB 通信:
composer require aws/aws-sdk-php
接下来,将 queue.failed.driver 配置选项的值设为 dynamodb。此外,你应在失败任务配置数组中定义 key、secret 和 region 配置选项。这些选项将用于与 AWS 认证。使用 dynamodb 驱动时,queue.failed.database 配置选项不是必需的:
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'dynamodb'),
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => 'failed_jobs',
],
禁用失败任务存储
你可以通过将 queue.failed.driver 配置选项的值设为 null 来指示 Laravel 丢弃失败任务而不存储它们。通常,这可以通过 QUEUE_FAILED_DRIVER 环境变量实现:
QUEUE_FAILED_DRIVER=null
失败任务事件
如果你想注册在任务失败时被调用的事件监听器,可以使用 Queue 门面的 failing 方法。例如,我们可以从 Laravel 包含的 AppServiceProvider 的 boot 方法中附加一个闭包到此事件:
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\ServiceProvider;
use Illuminate\Queue\Events\JobFailed;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
// ...
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Queue::failing(function (JobFailed $event) {
// $event->connectionName
// $event->job
// $event->exception
});
}
}
从队列中清除任务
使用 Horizon 时,应使用 horizon:clear 命令而不是 queue:clear 命令从队列中清除任务。
如果你想从默认连接的默认队列中删除所有任务,可以使用 queue:clear Artisan 命令:
php artisan queue:clear
你还可以提供 connection 参数和 queue 选项来从特定连接和队列中删除任务:
php artisan queue:clear redis --queue=emails
从队列中清除任务仅适用于 SQS、Redis 和 database 队列驱动。此外,SQS 消息删除过程最多需要 60 秒,因此在清除队列后最多 60 秒内发送到 SQS 队列的任务也可能被删除。
监控队列
如果你的队列收到突然涌入的任务,可能会变得不堪重负,导致任务完成的等待时间变长。如果你愿意,Laravel 可以在队列任务计数超过指定阈值时向你发出警报。
要开始使用,你应该调度 queue:monitor 命令每分钟运行。该命令接受你希望监视的队列名以及你期望的任务计数阈值:
php artisan queue:monitor redis:default,redis:deployments --max=100
仅调度此命令不足以触发通知你队列过载状态的警报。当命令遇到任务计数超过阈值的队列时,将分发 Illuminate\Queue\Events\QueueBusy 事件。你可以在应用的 AppServiceProvider 中监听此事件,以便向你或你的开发团队发送通知:
use App\Notifications\QueueHasLongWaitTime;
use Illuminate\Queue\Events\QueueBusy;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Event::listen(function (QueueBusy $event) {
Notification::route('mail', 'dev@example.com')
->notify(new QueueHasLongWaitTime(
$event->connectionName,
$event->queue,
$event->size
));
});
}
测试
在测试分发任务的代码时,你可能希望指示 Laravel 不实际执行任务本身,因为任务的代码可以独立于分发它的代码直接测试。当然,要测试任务本身,你可以在测试中实例化任务实例并直接调用 handle 方法。
你可以使用 Queue 门面的 fake 方法来防止队列任务实际被推送到队列。调用 Queue 门面的 fake 方法后,你可以断言应用尝试将任务推送到队列:
<?php
use App\Jobs\AnotherJob;
use App\Jobs\ShipOrder;
use Illuminate\Support\Facades\Queue;
test('orders can be shipped', function () {
Queue::fake();
// Perform order shipping...
// Assert that no jobs were pushed...
Queue::assertNothingPushed();
// Assert a job was pushed to a given queue...
Queue::assertPushedOn('queue-name', ShipOrder::class);
// Assert a job was pushed
Queue::assertPushed(ShipOrder::class);
// Assert a job was pushed exactly once...
Queue::assertPushedOnce(ShipOrder::class);
// Assert a job was pushed twice...
Queue::assertPushedTimes(ShipOrder::class, 2);
// Assert a job was not pushed...
Queue::assertNotPushed(AnotherJob::class);
// Assert that a closure was pushed to the queue...
Queue::assertClosurePushed();
// Assert that a closure was not pushed...
Queue::assertClosureNotPushed();
// Assert the total number of jobs that were pushed...
Queue::assertCount(3);
});
<?php
namespace Tests\Feature;
use App\Jobs\AnotherJob;
use App\Jobs\ShipOrder;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class ExampleTest extends TestCase
{
public function test_orders_can_be_shipped(): void
{
Queue::fake();
// Perform order shipping...
// Assert that no jobs were pushed...
Queue::assertNothingPushed();
// Assert a job was pushed to a given queue...
Queue::assertPushedOn('queue-name', ShipOrder::class);
// Assert a job was pushed
Queue::assertPushed(ShipOrder::class);
// Assert a job was pushed exactly once...
Queue::assertPushedOnce(ShipOrder::class);
// Assert a job was pushed twice...
Queue::assertPushedTimes(ShipOrder::class, 2);
// Assert a job was not pushed...
Queue::assertNotPushed(AnotherJob::class);
// Assert that a closure was pushed to the queue...
Queue::assertClosurePushed();
// Assert that a closure was not pushed...
Queue::assertClosureNotPushed();
// Assert the total number of jobs that were pushed...
Queue::assertCount(3);
}
}
你可以向 assertPushed、assertNotPushed、assertClosurePushed 或 assertClosureNotPushed 方法传递闭包,以断言被推送的任务通过了给定的「真值测试」。如果至少有一个通过给定真值测试的任务被推送,则断言将成功:
use Illuminate\Queue\CallQueuedClosure;
Queue::assertPushed(function (ShipOrder $job) use ($order) {
return $job->order->id === $order->id;
});
Queue::assertClosurePushed(function (CallQueuedClosure $job) {
return $job->name === 'validate-order';
});
伪造部分任务
如果你只需要伪造特定任务而允许其他任务正常执行,可以将应被伪造的任务类名传递给 fake 方法:
test('orders can be shipped', function () {
Queue::fake([
ShipOrder::class,
]);
// Perform order shipping...
// Assert a job was pushed twice...
Queue::assertPushedTimes(ShipOrder::class, 2);
});
public function test_orders_can_be_shipped(): void
{
Queue::fake([
ShipOrder::class,
]);
// Perform order shipping...
// Assert a job was pushed twice...
Queue::assertPushedTimes(ShipOrder::class, 2);
}
你可以使用 except 方法伪造除一组指定任务外的所有任务:
Queue::fake()->except([
ShipOrder::class,
]);
测试任务链
要测试任务链,你需要使用 Bus 门面的伪造功能。Bus 门面的 assertChained 方法可用于断言任务链被分发。assertChained 方法接受一个链式任务数组作为第一个参数:
use App\Jobs\RecordShipment;
use App\Jobs\ShipOrder;
use App\Jobs\UpdateInventory;
use Illuminate\Support\Facades\Bus;
Bus::fake();
// ...
Bus::assertChained([
ShipOrder::class,
RecordShipment::class,
UpdateInventory::class
]);
如上例所示,链式任务数组可以是任务类名的数组。但是,你也可以提供实际任务实例的数组。这样做时,Laravel 将确保任务实例与你的应用分发的链式任务具有相同的类和相同的属性值:
Bus::assertChained([
new ShipOrder,
new RecordShipment,
new UpdateInventory,
]);
你可以使用 assertDispatchedWithoutChain 方法断言任务被推送时没有任务链:
Bus::assertDispatchedWithoutChain(ShipOrder::class);
测试链修改
如果链式任务向现有链前插或追加任务,你可以使用任务的 assertHasChain 方法断言任务具有预期的剩余任务链:
$job = new ProcessPodcast;
$job->handle();
$job->assertHasChain([
new TranscribePodcast,
new OptimizePodcast,
new ReleasePodcast,
]);
assertDoesntHaveChain 方法可用于断言任务的剩余链为空:
$job->assertDoesntHaveChain();
测试链式批次
如果你的任务链包含一批任务,你可以通过在链断言中插入 Bus::chainedBatch 定义来断言链式批次符合你的预期:
use App\Jobs\ShipOrder;
use App\Jobs\UpdateInventory;
use Illuminate\Bus\PendingBatch;
use Illuminate\Support\Facades\Bus;
Bus::assertChained([
new ShipOrder,
Bus::chainedBatch(function (PendingBatch $batch) {
return $batch->jobs->count() === 3;
}),
new UpdateInventory,
]);
测试任务批次
Bus 门面的 assertBatched 方法可用于断言一批任务被分发。传递给 assertBatched 方法的闭包接收一个 Illuminate\Bus\PendingBatch 实例,可用于检查批次中的任务:
use Illuminate\Bus\PendingBatch;
use Illuminate\Support\Facades\Bus;
Bus::fake();
// ...
Bus::assertBatched(function (PendingBatch $batch) {
return $batch->name == 'Import CSV' &&
$batch->jobs->count() === 10;
});
hasJobs 方法可在待处理批次上使用,以验证批次包含预期的任务。此方法接受任务实例、类名或闭包的数组:
Bus::assertBatched(function (PendingBatch $batch) {
return $batch->hasJobs([
new ProcessCsvRow(row: 1),
new ProcessCsvRow(row: 2),
new ProcessCsvRow(row: 3),
]);
});
使用闭包时,闭包将接收任务实例。预期的任务类型将从闭包的类型提示中推断:
Bus::assertBatched(function (PendingBatch $batch) {
return $batch->hasJobs([
fn (ProcessCsvRow $job) => $job->row === 1,
fn (ProcessCsvRow $job) => $job->row === 2,
fn (ProcessCsvRow $job) => $job->row === 3,
]);
});
你可以使用 assertBatchCount 方法断言给定数量的批次被分发:
Bus::assertBatchCount(3);
你可以使用 assertNothingBatched 断言没有批次被分发:
Bus::assertNothingBatched();
测试任务/批次交互
此外,你偶尔可能需要测试单个任务与其底层批次的交互。例如,你可能需要测试任务是否取消了其批次的进一步处理。为此,你需要通过 withFakeBatch 方法为任务分配一个伪造批次。withFakeBatch 方法返回一个包含任务实例和伪造批次的元组:
[$job, $batch] = (new ShipOrder)->withFakeBatch();
$job->handle();
$this->assertTrue($batch->cancelled());
$this->assertEmpty($batch->added);
测试任务/队列交互
有时,你可能需要测试队列任务是否将自己释放回队列。或者,你可能需要测试任务是否删除了自己。你可以通过实例化任务并调用 withFakeQueueInteractions 方法来测试这些队列交互。
一旦任务的队列交互被伪造,你可以在任务上调用 handle 方法。调用任务后,各种断言方法可用于验证任务的队列交互:
use App\Exceptions\CorruptedAudioException;
use App\Jobs\ProcessPodcast;
$job = (new ProcessPodcast)->withFakeQueueInteractions();
$job->handle();
$job->assertReleased(delay: 30);
$job->assertDeleted();
$job->assertNotDeleted();
$job->assertFailed();
$job->assertFailedWith(CorruptedAudioException::class);
$job->assertNotFailed();
任务事件
使用 Queue 门面的 before 和 after 方法,你可以指定在队列任务处理之前或之后执行的回调。这些回调是执行额外日志记录或为仪表板递增统计数据的好机会。通常,你应该从服务提供者的 boot 方法调用这些方法。例如,我们可以使用 Laravel 包含的 AppServiceProvider:
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\ServiceProvider;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
// ...
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Queue::before(function (JobProcessing $event) {
// $event->connectionName
// $event->job
// $event->job->payload()
});
Queue::after(function (JobProcessed $event) {
// $event->connectionName
// $event->job
// $event->job->payload()
});
}
}
使用 Queue 门面的 looping 方法,你可以指定在 worker 尝试从队列获取任务之前执行的回调。例如,你可能注册一个闭包来回滚之前失败任务留下的任何未关闭事务:
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
Queue::looping(function () {
while (DB::transactionLevel() > 0) {
DB::rollBack();
}
});
Laravel 还会在队列 worker 无法从队列获取任务时分发 Illuminate\Queue\Events\WorkerIdle 事件:
use Illuminate\Queue\Events\WorkerIdle;
use Illuminate\Support\Facades\Event;
Event::listen(function (WorkerIdle $event) {
// $event->connectionName
// $event->queue
// $event->workerOptions
});