接口里直接发邮件、生成报表或者调用第三方 API 的写法,在流量上来之后会让响应时间变得无法忍受。ThinkPHP 8 内置的队列组件就是把这类耗时操作从请求线程里剥离出来的标准答案。本文假设你已经搭好了 ThinkPHP 8 项目,我们将一步步把队列从配置写到生产环境守护,中间会穿插不少实际踩过的细节。
一、为什么不用简单的异步函数,而是用队列
PHP 的 fastcgi_finish_request() 或者直接写一些 hack 的确能实现“接口返回后继续执行”,但可靠性很差——进程一旦重启,任务直接丢失;执行失败也没有重试机制。ThinkPHP 的队列组件基于标准的驱动设计,任务可以被持久化到 Redis、数据库甚至是消息队列里,消费进程是独立守护的,失败可以自动重试,还能记录失败日志。在我们要做的所有异步场景里,它都是更稳妥的选择。
二、极简环境准备与驱动选择
首先确认你的 ThinkPHP 版本是 8.x。队列组件在框架安装时通常不会主动引入,需要单独安装官方扩展:
composer require topthink/think-queue
驱动上,本地开发可以用数据库驱动方便查看数据,线上强烈建议使用 Redis。下面给出的配置以 Redis 为主要示例,数据库驱动只做简要说明。
确保 Redis 扩展已安装且服务正常运行。项目根目录下的 .env 里配置好连接信息:
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=你的密码
随后打开 config/queue.php(没有就新建),写入驱动配置:
<?php
return [
'default' => 'redis',
'connections' => [
'redis' => [
'type' => 'redis',
'queue' => 'default',
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', 6379),
'password' => env('REDIS_PASSWORD', ''),
'select' => 0,
'timeout' => 0,
'persistent' => false,
],
// 数据库驱动示例,生产不建议
'database' => [
'type' => 'database',
'queue' => 'default',
'table' => 'jobs',
'connection' => 'mysql',
],
],
'failed' => [
'type' => 'database',
'table' => 'failed_jobs',
],
];
如果你必须使用数据库驱动,需要先在数据库中创建 jobs 表和 failed_jobs 表。框架提供了迁移命令,不过要手动执行一下:
php think queue:table
php think queue:failed-table
php think migrate:run
两个命令会分别在数据库里生成对应的表结构,不用自己手写 SQL。
三、编写第一个任务类
任务类本质就是一个实现了 handle 方法的普通 PHP 类。框架约定任务类放在 app/job 目录下,手动创建即可。以一个“新用户注册后发送欢迎邮件”的任务为例:
<?php
namespace appjob;
class WelcomeEmail
{
public function handle(array $data): void
{
// $data 是从控制器传过来的用户信息
$email = $data['email'] ?? '';
$username = $data['username'] ?? '用户';
// 假设这里调用了具体的邮件发送逻辑
// Mail::send($email, '欢迎注册', "你好{$username},欢迎加入。");
// 为了演示方便,这里用日志代替
trace("发送欢迎邮件至 {$email},用户名为 {$username}", 'queue');
// 如果发生异常,任务会自动标记为失败
// 你也可以根据逻辑主动抛出异常触发重试
}
}
注意这里的 handle 方法的参数,分发任务时传递的数据会序列化后传入。你不需要在中间件里做额外配置,只要数据是可序列化的就行。尽量避免在任务里注入过于庞大的对象,传关键的 ID 和字段就够了。
四、在控制器中分发任务
分发任务有几种常用姿势:立即执行、延迟执行、以及链式执行。在控制器里引入 thinkfacadeQueue 即可:
<?php
namespace appcontroller;
use thinkfacadeQueue;
use appjobWelcomeEmail;
class Register
{
public function store()
{
// ... 用户注册逻辑,已获得用户数据 $user
$userData = [
'email' => 'newuser@example.com',
'username' => '张三',
];
// 1. 立即推送到默认队列
Queue::push(WelcomeEmail::class, $userData);
// 2. 推送到指定队列名称(方便按业务拆分进程)
Queue::push(WelcomeEmail::class, $userData, 'email');
// 3. 延迟 60 秒执行,非常适合冷却期操作
Queue::later(60, WelcomeEmail::class, $userData);
// 4. 链式任务:上一个任务完成后自动触发下一个
// Queue::chain([
// ['job' => WelcomeEmail::class, 'data' => $userData],
// ['job' => AnotherJob::class, 'data' => []],
// ]);
return json(['status' => '注册成功,邮件将稍后发送']);
}
}
不同队列名只是 redis 里的 key 不同,消费时需要单独指定队列名。这个设计对业务拆分非常友好——你可以让四个消费者专门处理邮件队列,另外两个消费者专门处理报表生成,互不干扰。
五、让队列真正“跑”起来
任务被推到 Redis 后,还需要独立的消费者进程去执行。在项目根目录执行以下命令就会启动一个单次消费进程:
php think queue:listen --queue=email
这个命令会持续监听 email 队列,拿到任务后调用对应类的 handle 方法。不指定 --queue 参数时默认监听 default 队列。
listen 与 work 的区别很重要。listen 在每次执行任务前会检查代码是否有变动,适合开发环境;而线上建议使用 work 命令,它是长驻进程,不重复加载框架文件,性能更高:
php think queue:work --queue=email --daemon
加了 --daemon 后进程会在后台运行。但这种直接启动的方式一旦终端关闭或者服务器重启,消费者就会挂掉。生产环境必须配合进程守护工具。
六、用 Supervisor 守护消费者进程
Supervisor 是 Linux 下管理常驻进程的标准工具。安装方式因系统而异,Ubuntu 直接 apt install supervisor。安装后在 /etc/supervisor/conf.d/ 下新建一个配置文件,比如 think-queue.conf:
[program:think-queue-email]
process_name=%(program_name)s_%(process_num)02d
command=php /你的项目路径/think queue:work --queue=email --daemon
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/think-queue-email.log
这里 numprocs=2 表示同时启动两个消费者进程,可以并行处理任务。配置完成后执行:
supervisorctl reread
supervisorctl update
supervisorctl start think-queue-email:*
之后每次重启服务器,Supervisor 会自动把队列消费者拉起来,再也不用担心进程断掉。查看运行状态用 supervisorctl status,任务处理日志会写在指定的 log 文件里。
七、失败处理与重试机制
任务执行过程中如果抛出异常,默认行为是立即标记失败并记录到失败表(前提是你配置了 failed 连接)。框架允许你定义最大重试次数和重试间隔。在任务类里定义两个属性即可:
<?php
namespace appjob;
class WelcomeEmail
{
// 最大尝试次数
public $tries = 3;
// 重试间隔(秒)
public $retryDelay = 30;
public function handle(array $data): void
{
// ... 业务逻辑
}
}
当任务执行失败时,消费者会等待 30 秒后重新尝试,总共尝试 3 次。3 次后仍然失败,任务会被移入 failed_jobs 表。你可以定期查看这张表,或者编写命令重新推送失败任务:
php think queue:failed
php think queue:retry {id}
如果某些任务失败是因为第三方服务暂时不可用,重试机制能挽回相当一部分数据。不建议把重试次数设得过大,一般 3 到 5 次比较合适,否则队列很容易堆积。
八、监听队列事件,实现自己的监控
如果你需要知道每次任务执行的具体情况——成功没成功、耗时多少——不需要在任务类里到处写日志。框架在任务生命周期中会触发多个事件,我们可以通过事件监听器统一处理。
在 app/event.php 里注册监听:
<?php
return [
'bind' => [],
'listen' => [
'thinkeventQueueJobProcessing' => [],
'thinkeventQueueJobProcessed' => [],
'thinkeventQueueJobFailed' => ['applistenerQueueFailedLog'],
],
];
然后创建 app/listener/QueueFailedLog.php:
<?php
namespace applistener;
use thinkeventQueueJobFailed;
class QueueFailedLog
{
public function handle(QueueJobFailed $event): void
{
$job = $event->job;
$payload = $job->payload();
// 把失败信息写入自定义日志或发送警报
trace("队列任务失败: " . json_encode($payload, JSON_UNESCAPED_UNICODE), 'queue_error');
}
}
同理,你可以监听 QueueJobProcessed 来记录成功情况,方便统计处理量。这种方式完全无侵入,不会影响原有任务类的代码整洁。
九、开发时的调试技巧
在开发阶段,每次改完任务类都重启消费者很麻烦。除了用 queue:listen 替代 work 之外,还有一个技巧是减少单个队列的堆积风险:开发时使用数据库驱动,这样可以直接在数据库里看到所有待处理任务,清空也方便。切到数据库驱动的配置前面已经给出,只需要把 config/queue.php 里的 default 改为 database,并且确保 jobs 表已经创建。
查看队列里还有多少等待任务,可以用命令:
php think queue:status --queue=email
这会输出当前队列的任务数量。如果你发现任务数量持续增长,说明消费者处理不过来,需要增加 Supervisor 里的 numprocs 数值,或者优化单个任务的处理逻辑。
还有一个隐形问题:序列化。闭包无法序列化,所以任务类必须是一个独立的类,不能是匿名函数。所有传递的数据也必须是可序列化的。如果你不小心传了 PDO 对象或者 Resource 类型进去,任务会直接失败。排错时注意检查这部分。
十、总结
ThinkPHP 8 的队列组件从推送任务到消费守护,再到失败重试和监控,这条链路梳理下来并不复杂。归纳起来就是:装扩展、选驱动、写任务类、控制器分发、命令行消费、Supervisor 守护、失败表兜底。这套组合一旦跑通,项目里所有涉及邮件、短信、日志聚合、数据导出的场景都能用它解决,接口响应速度会得到肉眼可见的提升。
你不需要再手动写 while 循环轮询,也不用担心任务丢失。把耗时的脏活累活交给后台队列去干,控制器只负责快速响应用户,这本来就是现代 Web 应用最推崇的架构方式。文中的代码都是可直接运行的,唯一需要你额外配置的就是 Redis 连接和 Supervisor。动手走一遍,远比反复翻阅文档要来得深刻。

