前段时间给一个小型电商系统做性能优化,发现用户提交订单后的响应时间经常超过3秒。分析之后看到,订单控制器在完成数据库写入后又同步调用了「库存扣减通知」「短信发送」「数据统计更新」等好几个耗时操作。用户那边等得着急,服务器资源也被拖累。大家第一反应可能是把这些操作改成异步执行,但问题在于这些操作散落在控制器各处,直接硬改成异步会让代码更难维护。于是我们想到了用ThinkPHP 8内置的队列系统搭配事件机制,把订单创建和后续的异步任务彻底解耦,响应时间直接从3秒降到了300毫秒以内。下面就复盘一下这个改造过程。
一、为什么是队列加事件,而不是直接在控制器里dispatch
ThinkPHP 8的队列组件本身已经很好用,可以在控制器里直接调用Queue::push()把任务推入队列。但是这么做有一个缺点:订单控制器需要明确知道“创建订单后要做哪些异步任务”,这违反了单一职责原则。当后续新增一个异步任务(比如同步到第三方ERP)时,我们还得改订单控制器。
更好的方式是引入事件系统:在订单模型或服务层触发一个OrderCreated事件,然后多个监听器各自决定是否需要推送到队列。这样订单核心逻辑与扩展操作完全隔离,新增任务只需要添加一个监听器,无需改动订单创建代码。TP8的事件和队列结合得相当流畅,我们一步步来。
二、前期准备:队列配置与Redis驱动
首先确保安装了队列扩展,ThinkPHP 8使用topthink/think-queue,可以在项目根目录执行:
composer require topthink/think-queue
然后配置队列的驱动,这里使用Redis,因为它的性能和可扩展性足够应对中小型系统。在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' => env('redis.select', 0),
'timeout' => 0,
'persistent' => false,
],
],
];
注意这里的queue参数可以指定不同的任务队列名,方便区分优先级。确保Redis服务已启动,队列消费者稍后配置。
三、创建订单事件与监听器
按照TP8的规范,事件类放在app/event目录下。新建OrderCreated.php,目的是携带订单数据:
<?php
namespace appevent;
class OrderCreated
{
public function __construct(
public readonly int $orderId,
public readonly string $phone,
public readonly float $amount
) {}
}
接下来编写监听器。假设我们有两个异步任务:发送短信和更新统计报表。它们的监听器分别放在app/listener目录下。第一个监听器SendOrderSms.php:
<?php
namespace applistener;
use appeventOrderCreated;
use appjobSendSmsJob;
use thinkfacadeQueue;
class SendOrderSms
{
public function handle(OrderCreated $event): void
{
// 将任务推入队列,延迟5秒执行,给数据库同步留出时间
Queue::later(5, SendSmsJob::class, [
'phone' => $event->phone,
'orderId' => $event->orderId,
], 'sms');
}
}
第二个监听器UpdateStatistics.php,用于更新订单统计:
<?php
namespace applistener;
use appeventOrderCreated;
use appjobUpdateReportJob;
use thinkfacadeQueue;
class UpdateStatistics
{
public function handle(OrderCreated $event): void
{
Queue::push(UpdateReportJob::class, [
'orderId' => $event->orderId,
'amount' => $event->amount,
], 'reports');
}
}
然后在app/event.php中绑定事件与监听器:
<?php
return [
'bind' => [
appeventOrderCreated::class => [
applistenerSendOrderSms::class,
applistenerUpdateStatistics::class,
],
],
];
这样订单事件一触发,两个监听器就会各自把对应的任务推入不同的队列。
四、编写队列任务的具体逻辑
任务类必须实现thinkqueueJob接口,通常继承thinkqueueJob基类。我们在app/job目录下创建SendSmsJob.php:
<?php
namespace appjob;
use thinkfacadeLog;
use thinkqueueJob;
class SendSmsJob
{
public function fire(Job $job, array $data): void
{
$phone = $data['phone'] ?? '';
$orderId = $data['orderId'] ?? 0;
if (empty($phone) || $orderId delete();
return;
}
// 模拟短信发送逻辑
try {
$result = $this->sendSms($phone, "您的订单{$orderId}已创建成功");
if ($result) {
$job->delete(); // 成功则删除任务
Log::info("短信发送成功: orderId={$orderId}");
} else {
// 失败重试,最多尝试3次
if ($job->attempts() > 3) {
$job->delete();
Log::error("短信发送失败,已达最大重试次数: orderId={$orderId}");
} else {
$job->release(10); // 10秒后重试
}
}
} catch (Exception $e) {
Log::error("短信任务异常: " . $e->getMessage());
if ($job->attempts() > 3) {
$job->delete();
} else {
$job->release(10);
}
}
}
private function sendSms(string $phone, string $message): bool
{
// 实际对接短信服务商API
return true;
}
}
注意fire方法是队列消费者的入口,$data就是从监听器推入的任务数据。通过$job->attempts()获取重试次数,$job->release()重新放回队列。另一个统计任务UpdateReportJob.php类似:
<?php
namespace appjob;
use thinkfacadeDb;
use thinkfacadeLog;
use thinkqueueJob;
class UpdateReportJob
{
public function fire(Job $job, array $data): void
{
$orderId = $data['orderId'] ?? 0;
$amount = $data['amount'] ?? 0;
if ($orderId delete();
return;
}
try {
// 使用事务更新统计表,例如 orders_summary
Db::transaction(function () use ($amount) {
Db::table('orders_summary')
->where('date', date('Y-m-d'))
->inc('total_amount', $amount)
->inc('order_count', 1)
->update();
});
$job->delete();
} catch (Exception $e) {
Log::error("统计更新失败: " . $e->getMessage());
if ($job->attempts() > 3) {
$job->delete();
} else {
$job->release(30);
}
}
}
}
把耗时的短信发送和统计更新都放到后台队列处理,订单接口的负担就轻了许多。
五、在订单控制器中触发事件
订单创建成功后,我们只需要触发事件,而无需知道后续有多少个任务。控制器示例:
<?php
namespace appcontroller;
use appeventOrderCreated;
use appmodelOrder as OrderModel;
use thinkfacadeEvent;
class Order
{
public function create()
{
// 接收请求参数,省略校验
$phone = request()->param('phone');
$amount = request()->param('amount');
// 订单入库
$order = OrderModel::create([
'phone' => $phone,
'amount' => $amount,
'status' => 1,
]);
// 触发事件
Event::dispatch(new OrderCreated($order->id, $phone, (float)$amount));
return json(['code' => 200, 'msg' => '订单创建成功', 'order_id' => $order->id]);
}
}
这段代码纯粹聚焦订单本身的创建,异步任务通过事件扩展出去,后续要增加新的任务只需新增监听器并在event.php里绑定即可,订单控制器完全不用动。
六、启动队列消费者与守护进程
队列任务被推入Redis后需要消费者来处理。ThinkPHP提供了命令行工具:
php think queue:listen --queue=sms,reports --delay=5 --sleep=3 --tries=3
参数解释:--queue指定监听的队列名称,多个用逗号分隔;--delay任务失败后的延迟重试秒数;--sleep队列无任务时的休眠秒数;--tries最大尝试次数。开发环境可以直接运行,生产环境建议使用Supervisor或systemd把queue:work作为常驻进程守护,避免因为异常退出而停止消费。
一个简单的Supervisor配置示例(在服务器上):
[program:thinkphp-queue]
command=/usr/bin/php /path/to/your/project/think queue:work --queue=sms,reports --delay=5 --sleep=3 --tries=3
directory=/path/to/your/project
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/thinkphp-queue.log
七、实际性能对比与一些踩坑记录
改造前,订单创建接口的平均响应时间在2.8秒左右,压力测试下CPU和Redis的连接数较高。改造后,接口响应时间稳定在230毫秒左右,因为控制器只做了数据库写入和事件分发(事件内的队列推入操作非常快)。短信发送和统计更新的压力被分散到消费者进程,不再阻塞HTTP请求。
过程中也遇到几个问题:首先是Redis连接超时,原因是消费者长时间空闲后连接被断开,后来设置了心跳和persistent参数。其次是任务数据序列化问题,Queue::later传入的任务数据如果包含对象会序列化失败,确认所有数据都是标量和数组后解决。还有一点,多个队列消费者共享Redis时,务必给不同的业务使用不同的队列名,以免互相干扰。
另外,ThinkPHP 8的队列组件默认使用topthink/think-queue,底层支持Redis、Database等驱动。如果项目后期任务量暴增,可以考虑换成RabbitMQ驱动,但改配置即可,任务代码无需变化。

