Laravel Horizon 的进程内存持续增长直到触及 PHP 的 memory_limit 导致进程被强制终止,这个问题几乎都出在队列任务的数据载荷上。排查时不要先去怀疑 Horizon 本身有 bug,99% 的情况是业务代码在处理大数据集时没有及时释放内存,或者任务类内部持有的大对象在整个生命周期内都没有被回收。

先确认是哪个进程在泄漏

Horizon 启动的是多个独立 PHP 进程,内存泄漏通常只发生在某几个 worker 上。用 Horizon 自带的监控面板直接观察每个 worker 的 RSS 内存曲线。如果某个 supervisor 下的 worker 内存呈阶梯式上升,每处理一个任务就涨一点,处理完也不回落,那基本可以锁定是那个队列的任务代码有问题。命令行也能直接看:

ps aux | grep horizon

找到内存占用异常的那个进程 PID,然后看它具体在执行什么任务。Horizon 面板里可以看到当前正在处理的任务类名和 payload,这是最直接的定位手段。

任务数据载荷过大是头号元凶

很多人习惯把整个 Eloquent Model 实例直接塞进队列任务,这是最常见的泄漏源头。当你调用 dispatch(new ProcessOrder($order)) 时,Laravel 会把这个 Model 序列化后存到 Redis。worker 取到任务后反序列化,这个 Model 实例就会完整加载到内存中。如果 Model 关联了大量数据,或者你在任务里又去懒加载了关联关系,内存占用会瞬间飙升。更关键的是,PHP 的垃圾回收在任务执行完毕后并不会立即释放所有内存,这些内存碎片会累积。

正确的做法是只传递主键 ID,让 worker 自己去数据库里查:

// 错误做法
dispatch(new ProcessOrder($order));

// 正确做法
dispatch(new ProcessOrder($order->id));

任务类的构造函数里只接收 ID,handle 方法里再去 findOrFail。这样 Model 实例的生命周期被严格限制在 handle 方法内,方法结束后就能被回收。

循环内的大量数据库查询

处理批量数据的任务最容易出问题。比如给所有用户发通知,代码写成这样:

public function handle()
{
    $users = User::all();
    foreach ($users as $user) {
        $user->notify(new WelcomeNotification());
    }
}

User::all() 会把所有用户数据一次性加载到内存,如果用户表有十万条记录,这个 worker 进程的内存直接爆炸。必须改用 chunk 或者 cursor 来分批处理:

public function handle()
{
    User::chunk(500, function ($users) {
        foreach ($users as $user) {
            $user->notify(new WelcomeNotification());
        }
    });
}

chunk 方法每次只加载 500 条记录到内存,处理完后这 500 条记录的内存会被释放,再加载下一批。但这里有个坑:chunk 内部使用 offset 分页,数据量大的时候会越来越慢。更优的方案是用 cursor,它底层用 PDOStatement 的逐行游标,内存占用极低:

public function handle()
{
    foreach (User::cursor() as $user) {
        $user->notify(new WelcomeNotification());
    }
}

cursor 的缺点是只能用于简单的顺序遍历,不能配合 eager loading。如果你的逻辑需要关联数据,老老实实用 chunk 并在循环里手动 unset 变量,或者直接在循环末尾调用 gc_collect_cycles() 强制回收循环引用。

静态属性和单例模式的陷阱

PHP 进程是常驻的,静态属性一旦赋值就不会自动销毁,除非手动置 null 或者进程重启。很多开发者喜欢在 Service 类里用静态属性缓存数据,这在 FPM 模式下没问题,因为请求结束进程就死了。但在 Horizon 的常驻进程里,静态属性会一直保留,每次任务往里面追加数据,内存只增不减。

排查的时候重点看代码里有没有类似 private static $cache = [] 这样的写法,或者在 Service Provider 里用 singleton 绑定了一个会累积数据的对象。如果确实需要缓存,必须设定缓存上限或者使用 LRU 淘汰策略,不能无限追加。

忘记关闭文件句柄和网络连接

如果任务里操作了文件、调用了外部 API、使用了 Redis 连接池之外的独立连接,用完没有显式关闭,这些资源会一直占用内存。Guzzle HTTP Client 的实例如果没有被回收,底层的 cURL 句柄也会残留。确保在任务里用 try-finally 包裹,finally 块里关闭所有资源:

public function handle()
{
    $client = new GuzzleHttp\Client();
    try {
        $response = $client->get('https://api.example.com/large-data');
        // 处理响应
    } finally {
        $client = null;
    }
}
使用 Telescope 或第三方包造成的内存泄漏

有些调试工具和监控包会在内部维护一个事件列表或者查询日志。比如 Laravel Telescope 默认会记录所有队列任务执行的查询、邮件、通知等,这些记录都保存在内存中,任务结束前才写入数据库。如果任务里执行了上千次查询,Telescope 的记录数组就会占用大量内存。排查时可以先在 config/telescope.php 里暂时关闭队列任务的监听,观察内存是否恢复正常:

'watchers' => [
    Watchers\QueryWatcher::class => [
        'enabled' => env('TELESCOPE_QUERY_WATCHER', false),
    ],
],

同样,Sentry、Bugsnag 等错误追踪 SDK 在记录异常时会捕获大量上下文数据,如果任务里频繁抛出异常并被捕获上报,SDK 内部的数据结构也会膨胀。检查这些包的配置,限制它们捕获的数据深度和广度。

PHP 内存管理机制的盲区

PHP 的垃圾回收主要处理循环引用,对于普通的变量引用计数归零后内存会释放,但释放的内存并不一定立即归还给操作系统。进程的 RSS 内存看起来很高,但内部可能已经有大量空闲内存碎片。这就是为什么用 memory_get_usage() 看到的数字和 ps 命令看到的 RSS 对不上。

可以在任务执行前后分别打印内存使用量,定位是哪个步骤导致内存飙升:

public function handle()
{
    Log::info('Start memory: ' . memory_get_usage() / 1024 / 1024 . 'MB');
    
    // 业务逻辑
    
    Log::info('End memory: ' . memory_get_usage() / 1024 / 1024 . 'MB');
    Log::info('Peak memory: ' . memory_get_peak_usage() / 1024 / 1024 . 'MB');
}

如果 memory_get_usage 显示正常但 RSS 居高不下,说明是内存碎片问题。这种情况下,与其花时间深究 PHP 内部的内存分配,不如直接设置 Horizon 的 maxJobs 和 maxTime 参数,让 worker 处理一定数量任务后自动重启,这是最稳妥的兜底方案。

配置 Horizon 的进程回收策略

在 config/horizon.php 里,每个 supervisor 都可以配置:

'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default', 'high'],
            'balance' => 'auto',
            'maxJobs' => 1000,
            'maxTime' => 3600,
            'memory' => 128,
            'tries' => 3,
            'timeout' => 60,
        ],
    ],
],

memory 参数单位是 MB,当 worker 的内存占用超过这个值,Horizon 会优雅地停止这个 worker 并启动新进程。maxJobs 是处理多少个任务后重启,maxTime 是运行多少秒后重启。这三个参数组合使用,可以确保即使有轻微的内存泄漏,worker 也能在影响扩大前被回收。

但不要把这些参数设得太激进。memory 设 128 或 256 比较合理,设太小会导致 worker 频繁重启,增加 Redis 连接开销和任务重试概率。maxJobs 建议根据实际任务的内存增长曲线来调,比如你的任务平均每个增长 0.5MB,那就设成 200,让 worker 在 100MB 左右就重启。

深入排查:使用 Xdebug 和 php-meminfo

如果上述方法都试过了还是找不到泄漏点,就需要上工具了。php-meminfo 扩展可以在运行时分析内存中的变量分布:

// 在任务中间位置输出内存快照
meminfo_dump(fopen('/tmp/meminfo.json', 'w'));

这个 JSON 文件会列出当前进程里所有变量占用的内存大小,按大小排序后一眼就能看到哪个变量是罪魁祸首。Xdebug 的函数跟踪功能也能记录每次函数调用的内存变化,但生产环境开启会有性能损耗,建议在 staging 环境复现问题后使用。

Redis 连接和 Horizon 内部队列的潜在问题

Horizon 使用 Redis 存储待处理任务和元数据,如果 Redis 内存不足或者网络延迟高,worker 会长时间阻塞在等待任务上,这期间 PHP 进程持有的内存不会被释放。检查 Redis 的 maxmemory 配置和淘汰策略,确保 Redis 本身没有内存压力。另外,Horizon 的 auto-balancing 策略在队列积压严重时会频繁创建和销毁 worker,这个过程中如果有 worker 卡在某个长任务上,新启动的 worker 会叠加内存占用。监控 Horizon 面板里的 “Jobs Per Minute” 和 “Processes” 曲线,如果出现锯齿状的剧烈波动,说明 balance 策略需要调整,可以改成 simple 或 false 来手动控制 worker 数量。

排查内存泄漏的核心思路就是缩小范围:先确认是哪个队列、哪个任务类,再到任务内部定位具体的代码块,最后用工具验证。绝大多数情况都是数据加载方式不当或者静态缓存累积造成的,真正由 PHP 扩展或 Horizon 自身 bug 导致的内存泄漏极为罕见。设置合理的进程回收参数作为兜底,再结合代码层面的优化,就能彻底解决这个问题。