ThinkPHP 8 事件与队列实战:下单接口从 2.9 秒优化到 200 毫秒

2026-09-18 0 450

上个月把一个跑了三年的订单系统从 ThinkPHP 5.1 升到 8.0,功能回归全绿,结果前端同事第一时间发来消息:点「去支付」要转圈三秒多才跳走。

第一反应是数据库。打开 SQL 日志,一次下单请求里最慢的一条 18ms,所有查询加起来不到 60ms。那剩下的 2.8 秒去哪儿了?

在服务层每个关键节点前后埋 microtime,线上采样 200 次,得到这么一张表:

执行步骤 平均耗时 能否异步
更新订单状态、写支付流水 12ms
扣减本地库存表 28ms
调用短信网关 420ms
推送到 ERP 系统 1100ms
小程序订阅消息推送 300ms
发放优惠券(调营销服务) 700ms
更新每日销售统计表 160ms

2.7 秒里,第三方调用占了 2.5 秒。这跟框架没关系,跟数据库没关系,纯粹是一个 HTTP 请求里串着五个外部服务。

先划清边界:哪些必须同步,哪些可以延后

最省事的做法是把这些都塞进队列,但支付回调这个场景有个硬约束——微信支付的回调要求 5 秒内返回成功,同时订单状态必须已经落库,不然对方重试时会对不上账。所以下面这两步必须留在同步链路里:

  • 订单状态、支付流水写入(否则重复回调会重复处理)
  • 本地库存扣减(超卖是原则问题)

剩下的短信、ERP、订阅消息、优惠券、统计,全都是「通知类」动作。它们失败一次不会有资金风险,最多是运营那边晚几分钟看到数据。

改造思路:事件负责「发生了什么」,队列负责「慢慢做」

ThinkPHP 8 的事件系统在这里刚好合适。订单支付成功这件事只发一次,谁关心谁去监听,业务代码里再也不用写一长串 service 调用。

目录结构大致是这样:

app/
├── event/
│   └── OrderPaid.php
├── listener/
│   └── order/
│       ├── SendSmsNotify.php
│       ├── PushToErp.php
│       └── UpdateDailyStat.php
├── job/
│   ├── SendSmsJob.php
│   ├── PushErpJob.php
│   └── UpdateStatJob.php
└── event.php

事件类:只带数据,不带逻辑

事件对象就是个数据容器,别在里面写方法,更别在里面操作数据库。把订单对象和 trace_id 带上就够了。

<?php
declare(strict_types=1);

namespace appevent;

use appmodelOrder;

class OrderPaid
{
    public function __construct(
        public Order $order,
        public string $traceId,
    ) {
    }
}

构造函数属性提升是 PHP 8 的特性,ThinkPHP 8 要求 PHP 8.0 起步,用起来没问题。

触发点:一定要放在事务外面

<?php

namespace appservice;

use appeventOrderPaid;
use appmodelOrder;
use appmodelOrderPayment;
use appcommonTrace;
use thinkfacadeDb;
use thinkfacadeEvent;

class OrderService
{
    public function pay(int $orderId, string $payNo): void
    {
        $order = Order::findOrFail($orderId);

        Db::transaction(function () use ($order, $payNo) {
            $order->status  = Order::STATUS_PAID;
            $order->pay_no  = $payNo;
            $order->paid_at = time();
            $order->save();

            OrderPayment::create([
                'order_id' => $order->id,
                'pay_no'   => $payNo,
                'amount'   => $order->pay_amount,
                'pay_time' => time(),
            ]);
        });

        // 事务已经提交,这里才发事件
        Event::trigger(new OrderPaid($order, Trace::id()));
    }
}

注意 Event::trigger 写在 Db::transaction 闭包外面。下面会解释为什么这一点比其它所有细节都重要。

事件配置:键必须是完整类名

<?php

use appeventOrderPaid;

return [
    'bind'   => [],
    'listen' => [
        OrderPaid::class => [
            applistenerorderSendSmsNotify::class,
            applistenerorderPushToErp::class,
            applistenerorderUpdateDailyStat::class,
        ],
    ],
    'subscribe' => [],
];

'OrderPaid' 这种短名不会报错,但监听器永远不会被触发。这个坑我在测试环境浪费了二十分钟,日志里干干净净,什么都看不出来。

监听器:只做一件事,投递

<?php

namespace applistenerorder;

use appeventOrderPaid;
use appjobPushErpJob;
use thinkfacadeQueue;

class PushToErp
{
    public function handle(OrderPaid $event): void
    {
        Queue::push(PushErpJob::class, [
            'order_id' => $event->order->id,
            'trace_id' => $event->traceId,
        ], 'erp');
    }
}

监听器里不要写业务逻辑。一旦在里面调第三方、查数据库,你就把同步链路又拼回去了,只是换了个地方慢而已。监听器只负责把任务丢到对应的队列,耗时在 1~3ms 之间。

三个监听器分别投到 notifyerpstat 三个队列,这样做的好处是:ERP 接口挂了,不会把短信队列一起堵住。

Job 类:幂等、重试、异常,一个都不能少

<?php
declare(strict_types=1);

namespace appjob;

use appmodelOrder;
use appserviceSmsService;
use thinkfacadeCache;
use thinkfacadeLog;
use thinkqueueJob;

class SendSmsJob
{
    public function fire(Job $job, array $data): void
    {
        $order = Order::find($data['order_id']);

        // 订单不存在,直接丢弃,重试多少次都没意义
        if (!$order) {
            Log::warning('sms job skipped, order missing', $data);
            $job->delete();
            return;
        }

        // 幂等:同一个订单 10 分钟内只发一次
        $redis = Cache::store('redis')->handler();
        $key   = 'job:order_sms:' . $order->id;
        if (!$redis->set($key, 1, ['nx', 'ex' => 600])) {
            $job->delete();
            return;
        }

        try {
            app(SmsService::class)->sendPaidNotice($order, $data['trace_id']);
            $job->delete();
        } catch (Throwable $e) {
            Log::error('sms job failed: ' . $e->getMessage(), $data);

            if ($job->attempts() >= 3) {
                // 三次都失败,放弃重试,留日志人工处理
                $job->delete();
                Log::error('sms job gave up', $data);
                return;
            }

            // 10 秒后重试
            $job->release(10);
        }
    }
}

三个关键点:

  • $job->delete() 必须显式调用。任务正常执行完不 delete,它会被重新投回队列,用户就会收到两条一模一样的短信。
  • $job->attempts() 是当前已经尝试过的次数,用它来收口重试上限。
  • 别在外层 catch 里吞掉异常然后默默 return,那样任务会被当成执行成功,出问题的时候你连日志都找不到。

队列配置与启动

<?php
// config/queue.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,
        ],
    ],
    'failed' => [
        'type'  => 'database',
        'table' => 'failed_jobs',
    ],
];

失败任务表用 php think queue:table 生成迁移,再执行 php think migrate:run 就行。

生产环境用 supervisor 拉起三个进程,分别盯三个队列:

php think queue:work --queue erp --tries=3 --sleep=1
php think queue:work --queue notify --tries=3 --sleep=1
php think queue:work --queue stat --tries=3 --sleep=1

开发环境我一般用 queue:listen,因为每次任务都会重新加载代码,改完 Job 不用手动重启进程。生产环境别用 listen,开销太大。

我踩过的六个坑

坑一:事件写在事务里,消费者查不到订单

这是最要命的一个。第一版我把 Event::trigger 写在了 Db::transaction 闭包里面,本地测完全正常,压测时开始零星出现「订单不存在」的日志。

原因也很简单:事务还没提交,事件就已经发出去了,队列 worker 是独立进程,它在自己的连接里查不到还没提交的数据。本地因为数据量小、Redis 和 MySQL 在同一台机器上,时序刚好错开,所以没暴露。

解决办法就是把 trigger 移到事务提交之后。如果你实在需要在事务内触发,那就把队列任务加上几秒延迟,但这属于打补丁,不推荐。

坑二:传了 Model 对象进队列

// 错误示范
Queue::push(PushErpJob::class, ['order' => $order]);

think-queue 会把 payload 序列化。Model 对象里带着数据库连接、查询构建器这些东西,序列化之后反序列化出来的是一个残缺的对象,而且体积巨大。老老实实传 order_id,Job 里重新查一次,多一次查询换来的确定性非常值。

坑三:常驻进程里的静态数据不重置

我写了个 Trace 类来存 trace_id,请求进来时 Trace::init($id)。同步链路里没问题,但队列 worker 是常驻的,如果在 Job 里调用 Trace::id(),拿到的是上一个任务甚至上一次进程启动时留下的值。

所以 trace_id 必须通过 payload 传递,Job 里只读 $data['trace_id'],不要去碰全局状态。就这么小一个细节,排查线上问题是真好用——日志里一 grep 那个随机串,从请求入口到队列消费的整条链路全都串起来了。

坑四:改了 Job 代码但行为没变

queue:work 是常驻进程,代码不会热加载。改完 Job 之后一定要执行:

php think queue:restart

它会让 worker 在跑完当前任务后平滑退出,supervisor 会重新拉起来,不丢任务。

坑五:所有任务挤在同一个队列

一开始我图省事全用 default 队列。结果有次 ERP 那边接口超时,每个任务卡 30 秒,前面排了四百多个任务,短信通知跟着一起延迟了十几分钟,客服电话直接被打爆。

拆队列之后就好多了:业务重要性不同的任务分开,一个通道堵了不影响其他通道。

坑六:以为投递成功就等于执行成功

改造完成后接口是快了,但前端页面上显示的「已发送通知」状态其实是假的——那时候任务还在队列里排着队。后来我在订单表加了一个 notify_status 字段,Job 执行成功后再回写,前端读这个字段展示,语义才对得上。

效果与上线节奏

改造完成后线上观察一周:

指标 改造前 改造后
下单接口平均耗时 2.9s 210ms
P99 耗时 4.6s 380ms
第三方超时导致的失败率 1.7% 0.02%
队列平均积压 小于 5 条

上线顺序上我分了三步走,没敢一次切干净:

  • 第一步只加事件和队列,监听器里同时保留原来的同步调用,只是把执行结果打到日志里做对比,跑三天比对两边数据是否一致。
  • 第二步注释掉同步调用,灰度 10% 的流量走异步。
  • 第三步全量切换,同时把监控加上:队列长度超过 500 报警,失败任务表新增记录报警。

最后说两句

事件加队列这套东西不是银弹。如果下单接口本来就只花 200ms,为了「架构好看」硬拆成异步,你得到的是更长的链路、更难查的 bug 和一堆需要运维的进程。

但只要同步链路里出现了第二个第三方 HTTP 调用,就该动手了。第三方接口的延迟你控制不了,能控制的只有自己等不等它。

ThinkPHP 8 事件与队列实战:下单接口从 2.9 秒优化到 200 毫秒
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP 8 事件与队列实战:下单接口从 2.9 秒优化到 200 毫秒 https://www.taomawang.com/server/thinkphp/2769.html

常见问题

相关文章

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

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