三个月前,我们订单接口的P95耗时冲到1.2s。看日志,一个下单请求里要发短信、发邮件、调用ERP和CRM两个外部接口、写操作日志。这些事全在请求线程里同步做完,总耗时冲到430ms,外部接口一抖就上秒。这是典型的长响应链路问题。
我用Laravel事件+队列做的解耦,把单请求平均耗时从430ms压到26ms,接口QPS从85涨到320。下面把整个思考过程、代码实现、踩坑记录都写出来,按我的思路走,你可以少查两天文档。
一、问题拆解:事件和队列到底解决什么
事件(Event)解决的是代码耦合,队列(Queue)解决的是时间解耦。两个东西经常一起用,但你不能混为一谈。
| 场景 | 方案 | 原因 |
|---|---|---|
| 订单创建后需要把数据同步给所有监听者 | 同步事件 | 监听者逻辑简单、快,且后续操作依赖其执行结果 |
| 发通知、写日志、调外部API | Event + ShouldQueue(异步队列) | 这些操作不依赖返回结果,且外部服务延迟不可控 |
| 30分钟后未支付自动关单 | 延迟队列 | 定时任务扫表有延迟和脏数据问题,延迟队列精确到秒 |
| 第三方回调重试 | 队列 + 失败重试表 | 网络抖动是常态,需要repeal机制 |
这次场景,业务要求“订单创建成功必须立即返回订单号”,但后续要同步做五件事:
- 发送短信通知用户
- 发送邮件收据
- 调用ERP系统创建销售订单
- 调用CRM系统标记用户活跃
- 写入行为日志表
如果同步做,不管用不用事件,耗时都在那里。事件解决的是“代码怎么写更清爽”,队列解决的是“哪些事可以不在请求里等”。两个必须一起上。
二、同步事件 vs 事件+队列:两种方案实测
方案A:纯同步事件
监听器全部同步执行,请求内串行消耗。
<?php
// app/Providers/EventServiceProvider.php
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
OrderCreated::class => [
SendSmsNotification::class,
SendEmailReceipt::class,
SyncErpOrder::class,
SyncCrmActivity::class,
WriteBehaviorLog::class,
],
];
}
压测结果:ab -n 1000 -c 50,环境是PHP 8.3 + Laravel 11.0 + MySQL 8.0.35 + Redis 7.2。平均响应时间391ms,接口QPS 85,请求线程平均阻塞391ms。事件本身的分发开销只有0.5ms,其余全是业务逻辑。
方案B:事件+Redis队列
监听器实现ShouldQueue,文档标注版本为Laravel 11.x,Redis驱动为phpredis扩展。
<?php
// app/Listeners/SendSmsNotification.php
class SendSmsNotification implements ShouldQueue
{
public $queue = 'notifications';
public $tries = 3;
public $timeout = 30;
public function handle(OrderCreated $event)
{
// 这里是异步执行,不在请求线程内
SmsService::send($event->order->user->phone, '您的订单已创建');
}
}
同样的压测条件:平均响应时间26ms,接口QPS 320。请求内只做一件事——把事件对象放进Redis队列。耗时下降93.3%,QPS提升276%。代价是,短信、ERP这些动作变成“最终一致”。对订单场景,这个取舍没问题。
三、Laravel事件分发原理:它到底做了什么
不能只知道用法。看一遍源码,你才知道什么时候该用事件而不是直接调service。
事件入口:Event::dispatch(new OrderCreated($order))。
底层调用Illuminate\Events\Dispatcher::dispatch(),它的执行流程是这样:
- 根据事件类名
OrderCreated去EventServiceProvider::$listen里找所有注册的监听器 - 对每个监听器,通过容器解析出实例。如果监听器实现了
ShouldQueue,走dispatch方法里的createJob()分支,把事件对封装成CallQueuedListener任务扔进队列 - 如果监听器没实现
ShouldQueue,直接在当前进程内执行handle()
核心源码在Illuminate\Events\Dispatcher.php,直接看关键分支:
<?php
public function dispatch($event, $payload = [], $halt = false)
{
// 解析监听器
$responses = [];
foreach ($this->getListeners($eventName) as $listener) {
$response = $listener($event, $payload); // 监听器是一个闭包
if ($halt && ! is_null($response)) {
return $response;
}
}
return $halt ? null : $responses;
}
注意:$listener在Laravel里被包装成了闭包。当你注册SendSmsNotification::class时,容器会生成一个闭包,闭包内部会调用:
<?php
// Illuminate\Events\Dispatcher.php 内部 makeListener()
return function ($event, $payload) use ($listener) {
// 如果监听器需要队列
if ($listener instanceof ShouldQueue) {
return $this->createJob($listener, $event, $payload);
}
return $listener->handle($event, $payload);
};
对队列监听器,createJob()会把监听器的类名、事件对象序列化成CallQueuedListener任务对象,推送到队列。序列化用的是Laravel的SerializesModels特性,它会对模型进行特殊处理——只存模型类名和主键ID,不序列化整个模型对象。这是性能关键点:队列任务在Redis里很小,一个订单事件对象通常只有几百字节。
队列消费原理
php artisan queue:work redis启动后,进程订阅Redis队列。消费过程:
- 从Redis的list里用
BLPOP原子取出任务 - 反序列化任务对象,取到监听器类名和事件数据
- 从容器重新解析监听器实例,调用
handle() - 如果抛异常,判断重试次数。超过次数,任务进入
failed_jobs表
关键在于BLPOP是阻塞读,没有任务时worker进程不会空转。Laravel 11默认连接超时60秒,也就是说,一个worker进程在没有任务时会阻塞最多60秒,避免CPU空转。
四、完整代码实现:事件+队列+失败重试
下面的代码是我线上正在跑的版本。所有代码基于PHP 8.3 + Laravel 11.0 + Redis 7.2 + MySQL 8.0.35。
4.1 定义事件
<?php
// app/Events/OrderCreated.php
namespace App\Events;
use App\Models\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderCreated
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public Order $order;
public function __construct(Order $order)
{
$this->order = $order;
}
}
4.2 触发事件
<?php
// app/Services/OrderService.php
namespace App\Services;
use App\Events\OrderCreated;
use App\Models\Order;
class OrderService
{
public function createOrder(array $data): Order
{
// 业务逻辑...创建订单记录
$order = Order::create($data);
// 触发事件,所有监听器统一从这里解耦
OrderCreated::dispatch($order);
return $order;
}
}
4.3 注册监听器
<?php
// app/Providers/EventServiceProvider.php
namespace App\Providers;
use App\Events\OrderCreated;
use App\Listeners\SendSmsNotification;
use App\Listeners\SendEmailReceipt;
use App\Listeners\SyncErpOrder;
use App\Listeners\SyncCrmActivity;
use App\Listeners\WriteBehaviorLog;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
OrderCreated::class => [
SendSmsNotification::class,
SendEmailReceipt::class,
SyncErpOrder::class,
SyncCrmActivity::class,
WriteBehaviorLog::class,
],
];
}
4.4 监听器里实现分发到不同队列
用$queue属性把不同类型任务分到独立队列,避免签名邮件和日志I/O互相拖累。
<?php
// app/Listeners/SendSmsNotification.php
namespace App\Listeners;
use App\Events\OrderCreated;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendSmsNotification implements ShouldQueue
{
public $queue = 'notifications';
public $tries = 3;
public $timeout = 30;
public function handle(OrderCreated $event)
{
$phone = $event->order->user->phone;
SmsService::send($phone, '您的订单已创建,订单号:' . $event->order->order_no);
}
// 失败回调,Laravel 11可直接定义此方法
public function failed(OrderCreated $event, \Throwable $e): void
{
Log::error('短信发送失败', [
'order_no' => $event->order->order_no,
'error' => $e->getMessage(),
]);
}
}
<?php
// app/Listeners/SyncErpOrder.php
namespace App\Listeners;
use App\Events\OrderCreated;
use Illuminate\Contracts\Queue\ShouldQueue;
class SyncErpOrder implements ShouldQueue
{
public $queue = 'erp';
public $tries = 5;
public $timeout = 60;
public $backoff = [10, 30, 60, 120, 300]; // 指数退避
public function handle(OrderCreated $event)
{
$response = ErpClient::createOrder($event->order->toArray());
if ($response->failed()) {
throw new \RuntimeException('ERP接口返回失败: ' . $response->body());
}
}
public function failed(OrderCreated $event, \Throwable $e): void
{
Log::error('ERP同步失败,请人工处理', [
'order_no' => $event->order->order_no,
'error' => $e->getMessage(),
]);
// 推送企业微信告警
DingTalk::send('ERP同步失败,订单号: ' . $event->order->order_no);
}
}
4.5 队列驱动配置
Redis队列,Laravel 11的默认配置文件里queue.php已经提供了redis配置,我加了一个连接池和退避参数。
<?php
// config/queue.php
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => 5,
'after_commit' => true, // 事务提交后才入队,避免回滚后执行
],
],
after_commit = true 是关键配置。它的作用是:在数据库事务里触发的事件,不会在事务还没提交时就被队列worker消费。否则可能出现“排队了,但订单还在事务里没真正写进去”的竞态。Laravel 11默认是false,需要你手动开。
4.6 启动队列worker
生产环境用Supervisor守护,不要手动跑queue:work。
# /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=notifications,erp,crm,logs --tries=3 --timeout=60 --sleep=1
directory=/var/www/html
numprocs=8
autostart=true
autorestart=true
stopwaitsecs=3600
user=www-data
stdout_logfile=/var/www/html/storage/logs/worker.log
stderr_logfile=/var/www/html/storage/logs/worker-error.log
注意stopwaitsecs=3600,必须大于任务最长超时时间。否则Supervisor在重启队列时会强制kill掉正在执行的任务,任务直接丢失。
4.7 失败任务表
-- 生成迁移: php artisan queue:failed-table
-- 执行迁移: php artisan migrate
CREATE TABLE failed_jobs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid VARCHAR(255) NOT NULL UNIQUE,
connection TEXT NOT NULL,
queue TEXT NOT NULL,
payload LONGTEXT NOT NULL,
exception LONGTEXT NOT NULL,
failed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
查看失败任务:php artisan queue:failed,重试指定任务:php artisan queue:retry all。线上建议写个cron每5分钟重试一次,业务上允许延迟的时间窗口内。
五、效果数据:压测前后对比
压测工具:ApacheBench,ab -n 2000 -c 100 -p order.json -T application/json http://api.example.com/orders。业务机器:4C8G,PHP-FPM,OPcache开启。
| 指标 | 优化前(同步) | 优化后(事件+队列) | 提升幅度 |
|---|---|---|---|
| 接口平均响应时间 | 430ms | 26ms | ↓ 93.9% |
| P95响应时间 | 810ms | 41ms | ↓ 94.9% |
| 接口QPS | 85 | 320 | ↑ 276% |
| PHP-FPM进程占用CPU | 78% | 22% | ↓ 56% |
| Redis内存增量 | 0 | 8MB(任务积压峰值) | 可控 |
| 短信发送成功确认时间 | 同步的380ms内 | 异步,约1-2s后 | 最终一致 |
注意最后一行:短信从“请求内同步完成”变成“请求后1-2s完成”。用户体验上没区别,因为前端订单页的“提交成功”状态并不依赖短信是否发出。但如果你的产品要求“用户必须收到短信才算下单成功”,那不能这么做。
六、更多队列生产级配置
6.1 延迟任务
30分钟未支付关单,用dispatch()->delay(now()->addMinutes(30))。
<?php
use App\Jobs\CloseExpiredOrder;
$order = Order::find($orderId);
CloseExpiredOrder::dispatch($order)
->delay(now()->addMinutes(30))
->onQueue('orders');
注意:延迟队列在Redis中使用有序集合ZSET实现。Redis的ZSET在有大量延迟任务是性能OK。但你不能依赖延迟队列做秒级调度,底层是worker轮询zset里score到期才pop。
6.2 限流控制
外部接口有频控,用ShouldBeUnique约束同一订单不能重复入队。
<?php
class SyncErpOrder implements ShouldQueue, ShouldBeUnique
{
public $uniqueFor = 60;
public function uniqueId()
{
return $this->order->id;
}
}
七、避坑指南:我实际踩过的五个坑
坑1:Redis连接数被打满
上线第二天,Redis报了"Cannot assign requested address"。排查发现queue:work进程默认持有的是短期连接,每次任务执行都重新连接Redis,大量TIME_WAIT堆积。
解决:改用phpredis扩展的pconnect长连接。在config/database.php的redis连接配置里加'persistent' => true和'persistent_id' => 'queue'。同时检查Redis服务器的tcp-backlog参数,从511调到2048。
坑2:队列任务执行的数据库连接是旧的
PHP-FPM模式下每个请求结束会清理数据库连接,但queue:work是常驻进程,一个worker长连接可能一直持有。如果MySQL进行了主从切换或空闲超时杀掉连接(wait_timeout=28800),worker里的旧连接就失效,SQL执行直接报"MySQL server has gone away"。
解决:worker里每次通过容器解析数据库连接时,Laravel默认会检查连接状态。但如果你在监听器里用门面DB::select,它是静态的。更保底的做法:在handle()开头调用DB::reconnect()(如果当前连接断开)。Laravel 11里可以这样写:
<?php
public function handle(OrderCreated $event)
{
if (DB::getPdo() === null) {
DB::reconnect();
}
// 业务代码...
}
另一个省事方案:queue:work加--max-jobs=100,每处理100个任务重启一次worker,配合Supervisor的autorestart,能规避90%的内存问题和连接问题。
坑3:事件序列化时把密码哈希带进去了
有一种错误姿势:把整个$user模型塞进事件对象,而不是只塞order。模型的所有字段会被序列化到Redis。密码哈希、内部token全拷到队列里,Redis被入侵等于数据泄露。
解决:事件只存放必要的数据模型,让SerializesModels处理。它对模型序列化时只输出类名和主键,worker消费时重新从数据库查。不要在事件里塞请求报文、密码字段。涉及用户敏感字段的事件,用数组传递ID列表。
坑4:after_commit没开导致脏读
现象是:下单接口里走了一个数据库事务包裹,然后事务内触发事件,同步监听器立即查订单表,查不到——事务还没提交。Laravel 11的after_commit默认是false,这是设计如此,但很多人配上队列后忘了开启。
避坑方式:把queue.php里after_commit设为true,同时在触发事件的入口处不要有嵌套事务。Laravel的after_commit只认最外层事务提交,内层保存点不会触发。
坑5:延迟任务和定时任务混用导致重复执行
关闭订单用了延迟队列,又写了个cron每5分钟扫表。两个机制同时存在时,如果延迟队列任务release()(因为异常返回队列),cron又扫描到同一订单未关闭,就会重复关闭。
解决纪律:同一业务场景只能有一种调度策略。用延迟队列就不要再用cron扫同一张表。如果必须兜底,在关单逻辑里加条件:WHERE status = 'pending' AND updated_at < now()-30min,并且关单操作是UPDATE带条件触发而不是SELECT后再UPDATE。
八、事件+队列的适用边界
这套方案不是银弹。下面这些情况不适合:
- 需要同步返回结果的交互:比如登录后需要立即返回用户信息,不能用队列。
- 强一致业务:支付流水和账务明细必须同库同事务,拆出去就完蛋。
- 任务量极小且偶发:一天就几十个通知,直接同步发更好,不用引入队列运维成本。
适用信号有这几点:接口耗时主要花在外部I/O(HTTP调用)、短信、邮件;有“最终一致”语义能接受的业务;当前接口QPS已经撑不住需要降低单请求耗时。
九、监控与排查
日常运维必须关注三张表/三个数字:
redis-cli llen queues:notifications—— 队列堆积长度php artisan queue:failed—— 失败任务数redis-cli --stat—— 内存占用和连接数
如果队列堆积持续增长,优先看失败任务表。大多情况是外部接口返回错误导致重试耗尽。要在监听器failed()方法里记录详细上下文,别只记异常消息。
# 监控命令示例,每分钟记录一次队列深度
* * * * * /usr/bin/redis-cli llen queues:notifications >> /var/log/queue-depth.log 2>&1
* * * * * /usr/bin/php /var/www/html/artisan queue:failed --json | /usr/bin/python3 -c 'import json,sys; data=json.load(sys.stdin); print(len(data))' >> /var/log/failed-count.log 2>&1
十、总结
没有总结,该说的都在上面。用事件+队列做解耦,核心逻辑是:事件定义业务事实,监听器定义响应,队列定义执行时机。数据支撑:接口耗时430ms到26ms,QPS 85到320。把这些直接用到你的下单场景里,注意避坑部分列的那五点,足够了。