Оптимизация очередей в Laravel: разбор инцидента с ростом задержки
Очередь на Redis росла до 40 тысяч задач, воркеры съедали память, а письма клиентам уходили с задержкой в час. Разбираем, что сломалось внутри и как чинили.

В пятницу вечером мониторинг показал: длина очереди default в Redis перевалила за 40 тысяч задач и продолжала расти со скоростью примерно 300 задач в минуту. Воркеры не справлялись, письма и уведомления клиентам уходили с задержкой до часа, а RSS одного из процессов queue:work дорос до 900 МБ и был убит OOM-killer. Проект — Laravel 11.x, драйвер очереди Redis (phpredis), четыре воркера под Supervisor, PHP 8.3. Ниже — что именно сломалось, как это связано с устройством воркеров Laravel и что мы поменяли, чтобы задержка вернулась к секундам.
Симптомы на графиках
К моменту, когда пришёл алерт, картина была такой:
- длина очереди
default— 40 000+ и растёт; - throughput воркеров упал с ~500 задач/мин до ~120 задач/мин без роста нагрузки на базу;
- память одного воркера — 900 МБ на момент убийства, при старте было 45 МБ;
- CPU воркеров — почти в полке, но полезной работы мало.
Первое подозрение — база данных или внешний API тормозят обработку. Оно не подтвердилось: p95 времени самого джоба был стабильным, около 80 мс. Проблема была не в том, что задачи медленно выполняются, а в том, что воркеры деградировали сами.
Как воркер Laravel держит задачу в памяти
Команда queue:work запускает долгоживущий PHP-процесс. Он загружает фреймворк один раз и в цикле тянет задачи из очереди. Это его сила — не платить за бутстрап на каждой задаче — и его слабость: всё, что осело в статических свойствах, синглтонах контейнера и кешах ORM, живёт между задачами.
Три механизма, которые накапливают состояние между задачами в одном процессе:
- Query log. Если где-то в бутстрапе включён
DB::enableQueryLog(), каждый SQL-запрос за всё время жизни воркера копится в массиве. За несколько часов это сотни тысяч строк. - Резолвнутые синглтоны и слушатели событий. Регистрация замыканий-слушателей внутри самого джоба на каждой итерации добавляет по слушателю, который не снимается.
- Ссылки на модели Eloquent. Коллекции, привязанные к статическим кешам или к сервисам-синглтонам, не собираются сборщиком мусора, пока живёт процесс.
В нашем случае корневых причин было две, и они наложились друг на друга.
Причина первая: включённый query log
В одном из сервис-провайдеров стояла отладочная строка DB::connection()->enableQueryLog(), попавшая в мастер полгода назад. В веб-запросах она безвредна: процесс живёт миллисекунды. В воркере процесс живёт часами, и каждый выполненный джоб добавлял в лог по 6–12 запросов. Через несколько часов массив логов весил сотни мегабайт — отсюда рост RSS.
Причина вторая: тяжёлый батч плюс отсутствие лимитов
Ночью запустился регулярный экспорт, поставивший в ту же очередь default около 35 000 задач разом. Воркеры были запущены без --max-jobs и --max-time, то есть процесс не перезапускался и не сбрасывал накопленную память. Экспортные задачи и пользовательские уведомления делили одну очередь, поэтому письма встали в общий хвост за экспортом.
Воркер очереди — это не скрипт, который отработал и умер. Это демон. Всё, что вы бы простили короткоживущему запросу, в демоне копится часами и однажды упирается в лимит памяти.
Что померили до и после
Замеры сняты на одной ноде: 4 vCPU, 8 ГБ RAM, Redis на том же хосте, PHP 8.3, Laravel 11.x. Нагрузка воспроизведена искусственно: 35 000 экспортных задач плюс постоянный поток ~500 уведомлений в минуту. Память воркера смотрели через memory_get_usage(true), залогированную в конце каждого джоба, длину очереди — через Redis::llen.
| Показатель | До | После |
|---|---|---|
| RSS воркера за 4 часа | 45 → 900 МБ (OOM) | стабильно 60–110 МБ |
| Throughput под батчем | ~120 задач/мин | ~480 задач/мин |
| Задержка уведомлений | до 60 мин | < 5 с |
| Перезапуски воркера | по OOM, стихийно | плановые по --max-jobs |
Главный вклад в рост throughput дало не железо, а то, что воркеры перестали работать с распухшим процессом и перестали делить очередь с батчем.
Что изменили
1. Убрали накопление памяти
Отладочный enableQueryLog() удалили из провайдера. Заодно добавили страховку — перезапуск воркера по числу задач и по времени, чтобы любая будущая утечка не жила дольше окна перезапуска:
# Laravel 11.x, конфиг Supervisor
# воркер перезапускается сам после 1000 задач или 3600 секунд
[program:laravel-worker]
command=php /var/www/app/artisan queue:work redis --queue=notifications,exports --max-jobs=1000 --max-time=3600 --tries=3 --backoff=10
numprocs=4
autostart=true
autorestart=true
stopwaitsecs=120
Флаг --max-jobs завершает процесс после обработки заданного числа задач, --max-time — после стольких секунд жизни. Supervisor поднимает свежий процесс, память сбрасывается. stopwaitsecs подняли до 120, чтобы длинный джоб успел доработать при рестарте, а не был убит на середине.
2. Развели очереди по приоритету
Уведомления и экспорт больше не в одной очереди. Экспортные задачи ставятся в exports, пользовательские — в notifications. В команде queue:work очереди перечислены через запятую в порядке приоритета: воркер разбирает notifications полностью и только потом берётся за exports.
<?php
// Постановка тяжёлой задачи в отдельную очередь
ExportUsersJob::dispatch($filter)->onQueue('exports');
// Уведомление — в приоритетную очередь
NotifyUserJob::dispatch($user)->onQueue('notifications');
Порядок --queue=notifications,exports важен: Laravel опрашивает очереди слева направо и берёт задачу из первой непустой. Если поставить наоборот, приоритет инвертируется.
3. Ограничили память процесса явно
Добавили флаг --memory, при превышении которого воркер корректно завершается между задачами (не в середине):
php artisan queue:work redis --queue=notifications,exports --memory=256 --max-jobs=1000 --max-time=3600 --tries=3
Значение --memory в мегабайтах. Проверка идёт после каждой задачи через memory_get_usage(true); если порог превышен — процесс выходит с кодом, по которому Supervisor его перезапускает. Это ловит утечку до того, как её поймает OOM-killer.
Где такой подход всё ещё ломается
Флаги перезапуска — страховка, а не лечение утечки. Несколько граничных случаев:
- Одна задача сама по себе жрёт память. Если джоб грузит в память коллекцию на миллион строк, никакой
--max-jobsне поможет — процесс умрёт на этой задаче. Лечитсяchunk()/lazy()иcursor()вместоget(). - Слишком частый перезапуск. При очень низком
--max-jobsворкер большую часть времени бутстрапит фреймворк, а не работает. На нашей ноде разумным оказалось 1000; на приложении с тяжёлым бутстрапом стоит поднять. - Долгие задачи и
--max-time. Если задача идёт дольше окна,--max-timeне оборвёт её на середине — проверка срабатывает между задачами. Ноretry_afterвconfig/queue.phpдолжен быть больше самого долгого джоба, иначе задача будет считаться зависшей и продублируется другим воркером. - Балансировка очередей. При строгом приоритете
notifications,exportsпри постоянном потоке уведомлений экспорт может не разбираться вовсе. Если это проблема — выделяйте подexportsотдельные воркеры или смотрите в сторону Horizon с настройкой балансировки.
Что мониторить в проде
После инцидента добавили на дашборд четыре показателя, чтобы такое не повторилось незамеченным:
- Длина каждой очереди отдельно —
Redis::llen('queues:notifications')и по остальным. Рост без роста нагрузки — первый сигнал. - RSS воркеров — тренд, а не мгновенное значение. Пила с плавным ростом между перезапусками означает утечку.
- Возраст самой старой задачи в очереди — прямой прокси задержки для клиента, важнее throughput.
- Число неудачных задач —
failed_jobs. Всплеск часто идёт рука об руку с проблемами воркеров.
Следующий разумный шаг — поставить Laravel Horizon (для драйвера Redis), он даёт метрики по времени ожидания, автобалансировку воркеров между очередями и удобную панель по failed-задачам из коробки. Инвалидация зависших задач и настройка retry_after — отдельная тема, здесь мы её не разворачивали.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Laravel Queues — официальная документация, Laravel Horizon — официальная документация
Частые вопросы
У меня Laravel 10, флаги --max-jobs и --max-time будут работать?
А если я не нашёл в коде enableQueryLog, но память всё равно растёт?
Почему просто не поднять memory_limit и не забыть?
Не сработало: после разделения очередей экспорт вообще перестал обрабатываться. Что делать?
Какой подводный камень с --max-time и долгими задачами в проде?
Стоит ли сразу ставить Horizon вместо ручной настройки Supervisor?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.