上周接手了一个老项目,下单接口的控制器有一百多行,落库之后紧接着发短信、发邮件、加积分、更新销量统计,最后再写一条操作日志。平时跑着还行,一到促销时段响应时间直接飙到八百毫秒往上,前端那边超时提示一片。
把这几件事挪到队列里当然是最直接的做法,但我不太想在下单方法里硬塞四行 Queue::push,那样以后每加一个通知渠道就得改一次控制器。这次索性用事件监听重写一遍,借 ThinkPHP 8 现成的机制把耦合拆干净,顺手把踩到的几个坑记下来。
改造前的样子
为了说明问题,先把原来的逻辑压缩一下,大致是这么个结构:
public function save()
{
$data = $this->request->post();
$order = OrderModel::create($data);
$this->sendSms($order);
$this->sendMail($order);
$this->addPoints($order);
$this->updateStat($order);
OperationLog::create([...]);
return json(['code' => 0, 'order_no' => $order->order_no]);
}
问题不在于代码能不能跑,而在于每一行都可能失败,每一行都在拖响应时间,而且短信服务一抖动,整个下单接口就跟着遭殃。积分和统计这两件事更是完全没必要卡在用户等待的链路上。
拆解思路
我想要的边界是这样的:控制器只负责参数校验和落库,落库之后抛出一个“订单已创建”的信号,谁关心谁自己去接。短信、邮件这类必须尽快触达的走队列异步执行,积分和统计同样异步,审计日志则交给中间件在请求结束时统一处理。
落到 ThinkPHP 8 上,其实就是四块东西配合:事件类描述“发生了什么”,监听器描述“谁关心”,think-queue 负责把监听器推到后台跑,中间件负责记录请求维度的事情。下面逐个来。
第一步:装依赖
项目基于 ThinkPHP 8,PHP 版本至少要 8.0。队列用官方扩展:
composer require topthink/think-queue
如果队列驱动选 Redis,还得确保 phpredis 扩展装好了,或者退而求其次装 predis/predis。装完之后在 config/queue.php 里配置连接,默认那份配置基本能直接用,把 host 和 password 改掉即可:
return [
'default' => 'redis',
'connections' => [
'redis' => [
'type' => 'redis',
'queue' => 'default',
'host' => '127.0.0.1',
'port' => 6379,
'password' => '',
'select' => 0,
'timeout' => 0,
],
],
];
第二步:定义事件对象
这里有个细节必须提前说清楚,否则后面队列一跑就报错。事件对象会被序列化后塞进队列,所以里面绝对不能放模型实例。模型的基类里挂着数据库连接,而 PDO 对象是禁止序列化的,一旦照搬 new OrderPlaced($order) 这种写法,队列那边就会抛出 Serialization of 'PDO' is not allowed。
正确做法是只传标量,监听器需要更多字段时自己去查库。事件类放在 app/event/OrderPlaced.php:
<?php
namespace appevent;
class OrderPlaced
{
public function __construct(
public int $orderId,
public string $orderNo,
public int $userId,
public array $items,
public string $placedAt,
) {
}
}
构造属性提升是 PHP 8.0 就有的语法,写起来比较省事。items 里只存 [['sku_id' => 1, 'qty' => 2], ...] 这种纯数组,不要塞任何对象进去。
第三步:写监听器
监听器统一放在 app/listener 下。think-queue 装好之后,任何实现了 thinkqueueShouldQueue 接口的监听器都会自动被推送到队列执行,不需要我们手动调 Queue::push,这一点挺省心。
<?php
namespace applistener;
use appeventOrderPlaced;
use thinkfacadeLog;
use thinkqueueShouldQueue;
class SendOrderSms implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
// 这里只是示意,实际接短信服务商的 SDK
Log::info('发送下单短信', [
'order_no' => $event->orderNo,
'user_id' => $event->userId,
]);
// 如果服务商返回失败,直接抛异常,交给队列重试
}
}
再写一个同步的,比如积分,业务上要求立刻能查到:
<?php
namespace applistener;
use appeventOrderPlaced;
use appmodelUserPoints;
class AddUserPoints
{
public function handle(OrderPlaced $event): void
{
$points = 0;
foreach ($event->items as $item) {
$points += (int) $item['qty'] * 10;
}
UserPoints::create([
'user_id' => $event->userId,
'order_no' => $event->orderNo,
'points' => $points,
]);
}
}
注意这个类没有实现 ShouldQueue,所以它是同步执行的,跟在控制器里直接调用效果一样,但好处是控制器不用再知道积分这回事。
第四步:把事件和监听器绑起来
打开 app/event.php,在 bind 下面加一条。ThinkPHP 8 支持直接用类名作为事件标识,触发时传对象就行:
<?php
return [
'bind' => [
appeventOrderPlaced::class => [
applistenerSendOrderSms::class,
applistenerSendOrderMail::class,
applistenerAddUserPoints::class,
applistenerUpdateSalesStat::class,
],
],
];
以后要加微信模板消息,只需要写一个新的监听器类,往这个数组里追加一行,控制器一个字都不用动。这也是我选事件而不是直接 push 队列的主要原因。
第五步:简化控制器
改造后的下单方法大概是这样,服务层负责具体的落库逻辑,控制器只做参数收集和事件触发:
<?php
namespace appcontroller;
use appeventOrderPlaced;
use appserviceOrderService;
use thinkfacadeEvent;
use thinkResponse;
class Order
{
public function save(OrderService $service): Response
{
$data = $this->request->only(['user_id', 'items', 'remark']);
$order = $service->create($data);
Event::trigger(new OrderPlaced(
orderId: $order->id,
orderNo: $order->order_no,
userId: $order->user_id,
items: $data['items'],
placedAt: $order->create_time,
));
return json(['code' => 0, 'data' => ['order_no' => $order->order_no]]);
}
}
方法参数里的 OrderService 会被容器自动注入,这是 ThinkPHP 6 之后就支持的特性,不需要在控制器构造函数里手动 new。
第六步:审计日志交给中间件
操作日志这类东西,跟在业务代码后面写很容易漏,尤其是后面加了新的接口。用中间件统一处理更稳妥。新建 app/middleware/RequestAudit.php:
<?php
namespace appmiddleware;
use Closure;
use thinkfacadeLog;
use thinkRequest;
use thinkResponse;
class RequestAudit
{
public function handle(Request $request, Closure $next): Response
{
$start = microtime(true);
$response = $next($request);
$cost = round((microtime(true) - $start) * 1000, 2);
Log::info('request', [
'uri' => $request->url(),
'method' => $request->method(),
'ip' => $request->ip(),
'cost' => $cost,
'status' => $response->getCode(),
]);
return $response;
}
}
然后在 app/middleware.php 中注册成全局中间件:
return [
appmiddlewareRequestAudit::class,
];
我这里写的是日志而不是直接落库,原因是中间件执行在请求链路上,写库会额外增加响应时间,量大了还会锁表。真要做持久化的操作日志,建议还是通过事件转到队列里慢慢写。
第七步:把 worker 跑起来
前面的配置都到位之后,开两个终端,一个跑 Web 服务,一个跑队列消费:
php think queue:work --queue default
测试的时候建议加上 --once 参数只消费一个任务,方便看输出。queue:work 是常驻进程,改完监听器代码记得重启一下,它不会自动热加载。
三个实际踩到的坑
事件对象里别放模型
前面已经强调过一次,但值得再说一遍,因为报错信息非常不直观。很多人是在监听器里加上 Log::error 之后才发现,异常根本不在监听器的业务逻辑里,而是发生在入队序列化的那一刻。如果确实需要模型上的大量字段,就在事件里多传几个标量,或者在监听器里根据 orderId 重新查一次。后者虽然多一次查询,但能保证队列里消费到的是最新数据,反而更稳。
事件不要在事务里触发
这是第二个坑,危害比第一个大。如果服务层的 create 方法包了 Db::transaction,而事件触发写在事务内部,那么监听器可能是异步的,队列消费的时候事务还没提交,查不到这条订单;同步的监听器更糟,一旦抛异常会直接把事务回滚,连订单都创建失败。
稳妥的写法是让事件在事务提交之后再触发:
$order = Db::transaction(function () use ($data) {
return OrderModel::create($data);
});
// 事务外面再触发
Event::trigger(new OrderPlaced(...));
如果业务上确实需要“事务成功才发事件”,可以借助 Db::transaction 之后的代码位置,或者用 thinkfacadeDb::afterCommit 这类方式做延迟触发,别图省事直接塞在 transaction 的闭包里。
队列 worker 的超时和内存
queue:work 默认没有执行次数上限,进程会一直挂着。这在开发阶段很舒服,线上就得注意了:PHP 进程跑久了内存会涨,尤其监听器里有大数组的时候。
所以启动参数最好加上 --max-tries=3 --timeout=60,再配合 supervisord 之类的进程管理工具自动拉起。--max-tries 控制失败重试次数,--timeout 控制单个任务的最长执行时间,这两项不设的话,一个卡死的任务能把整个队列堵住。
验证一下效果
改造完成之后,下单接口的响应时间从八百多毫秒降到了九十毫秒左右,剩下的时间基本花在数据库写入和参数校验上。短信和邮件的发送挪到后台之后,服务商偶尔超时也不会让用户看到报错。
验证方式很简单:把 Redis 停掉,下单接口应该依然能正常返回,只是队列任务堆积;再启动 worker,堆积的任务会依次被消费,日志里能看到短信和邮件的发送记录。这个测试能同时验证事件解耦和队列异步是否真的生效。
收个尾
这套结构搭起来之后,后面加一个「下单成功后推送企业微信」的需求,我做的事情就是新建一个监听器类、在 event.php 里加一行、重启 worker,三分钟搞定。控制器的代码从一百多行变成了十来行,读起来也清爽不少。
事件加队列不是什么新东西,但用对了确实能把一坨散落在控制器里的副作用收拾干净。如果你手上也有这种下单之后一堆动作的接口,不妨按这个思路改一版,先改一个接口试试,效果出来了再铺开。

