ThinkPHP 8 实战:事件监听搭配队列,把订单通知改造成异步流水线

2026-11-01 0 655

上周接手了一个老项目,下单接口的控制器有一百多行,落库之后紧接着发短信、发邮件、加积分、更新销量统计,最后再写一条操作日志。平时跑着还行,一到促销时段响应时间直接飙到八百毫秒往上,前端那边超时提示一片。

把这几件事挪到队列里当然是最直接的做法,但我不太想在下单方法里硬塞四行 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,三分钟搞定。控制器的代码从一百多行变成了十来行,读起来也清爽不少。

事件加队列不是什么新东西,但用对了确实能把一坨散落在控制器里的副作用收拾干净。如果你手上也有这种下单之后一堆动作的接口,不妨按这个思路改一版,先改一个接口试试,效果出来了再铺开。

ThinkPHP 8 实战:事件监听搭配队列,把订单通知改造成异步流水线
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 实战:事件监听搭配队列,把订单通知改造成异步流水线 https://www.taomawang.com/server/thinkphp/2992.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务