Laravel事件与队列深度解析:订单解耦实战
发布日期: 2026/08/22 阅读总量: 0

三个月前,我们订单接口的P95耗时冲到1.2s。看日志,一个下单请求里要发短信、发邮件、调用ERP和CRM两个外部接口、写操作日志。这些事全在请求线程里同步做完,总耗时冲到430ms,外部接口一抖就上秒。这是典型的长响应链路问题

我用Laravel事件+队列做的解耦,把单请求平均耗时从430ms压到26ms,接口QPS从85涨到320。下面把整个思考过程、代码实现、踩坑记录都写出来,按我的思路走,你可以少查两天文档。

一、问题拆解:事件和队列到底解决什么

事件(Event)解决的是代码耦合,队列(Queue)解决的是时间解耦。两个东西经常一起用,但你不能混为一谈。

场景方案原因
订单创建后需要把数据同步给所有监听者同步事件监听者逻辑简单、快,且后续操作依赖其执行结果
发通知、写日志、调外部APIEvent + ShouldQueue(异步队列)这些操作不依赖返回结果,且外部服务延迟不可控
30分钟后未支付自动关单延迟队列定时任务扫表有延迟和脏数据问题,延迟队列精确到秒
第三方回调重试队列 + 失败重试表网络抖动是常态,需要repeal机制

这次场景,业务要求“订单创建成功必须立即返回订单号”,但后续要同步做五件事:

  1. 发送短信通知用户
  2. 发送邮件收据
  3. 调用ERP系统创建销售订单
  4. 调用CRM系统标记用户活跃
  5. 写入行为日志表

如果同步做,不管用不用事件,耗时都在那里。事件解决的是“代码怎么写更清爽”,队列解决的是“哪些事可以不在请求里等”。两个必须一起上。

二、同步事件 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(),它的执行流程是这样:

  1. 根据事件类名OrderCreatedEventServiceProvider::$listen里找所有注册的监听器
  2. 对每个监听器,通过容器解析出实例。如果监听器实现了ShouldQueue,走dispatch方法里的createJob()分支,把事件对封装成CallQueuedListener任务扔进队列
  3. 如果监听器没实现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队列。消费过程:

  1. 从Redis的list里用BLPOP原子取出任务
  2. 反序列化任务对象,取到监听器类名和事件数据
  3. 从容器重新解析监听器实例,调用handle()
  4. 如果抛异常,判断重试次数。超过次数,任务进入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开启。

指标优化前(同步)优化后(事件+队列)提升幅度
接口平均响应时间430ms26ms↓ 93.9%
P95响应时间810ms41ms↓ 94.9%
接口QPS85320↑ 276%
PHP-FPM进程占用CPU78%22%↓ 56%
Redis内存增量08MB(任务积压峰值)可控
短信发送成功确认时间同步的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.phpafter_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。把这些直接用到你的下单场景里,注意避坑部分列的那五点,足够了。