上个月把一个跑了三年的订单系统从 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 之间。
三个监听器分别投到 notify、erp、stat 三个队列,这样做的好处是: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 调用,就该动手了。第三方接口的延迟你控制不了,能控制的只有自己等不等它。

