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

Симптом почти всегда один: очередь, которая полгода работала на сотне задач в минуту, начинает захлёбываться на трёх тысячах. Воркеры отваливаются с Allowed memory size exhausted, часть писем уходит дважды, а redis-cli info memory показывает, что used_memory вырос до нескольких гигабайт и не падает. Ниже — что искать в коде и конфигурации, когда очередь Laravel (версии 10.x и 11.x, различия отмечены отдельно) перестаёт справляться с потоком.
Первое, что смотреть: сколько памяти живёт между задачами
Воркер Laravel — это долгоживущий PHP-процесс. Он загружает фреймворк один раз и потом обрабатывает задачи в цикле. Всё, что задача оставляет в памяти после себя, накапливается: статические свойства, синглтоны в контейнере, кешированные Eloquent-модели, разросшийся лог запросов.
Классический источник утечки — включённый лог SQL-запросов в продакшене. Если где-то в bootstrap вызван DB::enableQueryLog(), каждый запрос воркера оседает в массиве, который никто не чистит. На тысячах задач это гарантированный OOM.
Второй источник — обработка большой выборки без чанков. Такой код в handle джоба — красный флаг на ревью:
// Плохо: вся таблица заказов грузится в память разом
public function handle(): void
{
$orders = Order::where('status', 'pending')->get();
foreach ($orders as $order) {
$this->process($order);
}
}
При сотнях тысяч строк get() материализует все модели сразу. Замена — курсор или чанк, которые держат в памяти фиксированное окно:
// Лучше: обрабатываем порциями по 500, память не растёт
public function handle(): void
{
Order::where('status', 'pending')
->chunkById(500, function ($orders) {
foreach ($orders as $order) {
$this->process($order);
}
});
}
Замечание в ревью формулируйте конкретно: не «оптимизируйте выборку», а «здесь get() на потенциально неограниченной таблице внутри воркера — замените на chunkById или lazyById, иначе воркер упрётся в memory_limit».
Как это мерить
Запустите воркер с ограничением памяти и следите, растёт ли потребление между задачами. Флаг --memory задаёт порог в мегабайтах, после которого воркер сам завершается (менеджер процессов поднимет новый):
php artisan queue:work redis --memory=256 --tries=3 --timeout=90
Если воркер стабильно доживает до --memory за десятки задач, а не за тысячи — утечка есть, и --memory её маскирует, а не лечит. Ищите, что накапливается.
Идемпотентность: почему задачи выполняются дважды
At-least-once — базовая гарантия почти любого драйвера очередей, включая Redis-драйвер Laravel. Задача может выполниться повторно: воркер упал после работы, но до подтверждения; сработал retry_after раньше, чем задача завершилась; произошёл деплой в момент обработки.
На тысячах задач в минуту повторы не редкость, а норма. Если джоб списывает деньги, шлёт письмо или инкрементит счётчик — двойное выполнение это баг, который проявится под нагрузкой и не воспроизведётся в тестах.
Спрашивайте на ревью один вопрос про каждый джоб: что произойдёт, если этот handle выполнится дважды подряд с теми же аргументами. Если ответ «плохое» — нужна защита от повторов, а не «у нас же tries=1».
Самая частая ошибка — надеяться на tries. Он ограничивает число попыток после явного провала, но не спасает от двойной доставки одной и той же попытки. Защита — на уровне бизнес-логики: уникальный ключ операции, проверка состояния перед действием, updateOrCreate вместо слепого insert.
Отдельный механизм — ShouldBeUnique: он не даёт поставить в очередь второй экземпляр джоба с тем же ключом, пока первый не отработал. Это защита от дублей на входе, а не от повторного выполнения одной задачи — их часто путают.
Разбиение потока по очередям
Типичная антипаттерн-конфигурация: всё валится в очередь default, и один воркер разгребает и отправку писем, и генерацию отчётов на минуту каждый. Тяжёлые задачи блокируют лёгкие, письма уходят с задержкой в минуты.
Разведите потоки по очередям и назначьте им разное число воркеров:
// В джобе задаём очередь явно
public function __construct()
{
$this->onQueue('emails');
}
# Воркер только для быстрых задач, много процессов
php artisan queue:work redis --queue=emails --sleep=0
# Отдельный воркер для тяжёлых, меньше процессов, больше таймаут
php artisan queue:work redis --queue=reports --timeout=600
Порядок в --queue=high,default,low задаёт приоритет: воркер сначала выгребает high и только потом берётся за остальные. Это работает, только пока у вас есть отдельные воркеры под приоритетные очереди, иначе низкоприоритетные задачи будут голодать.
Что ревьюить в самом джобе
- Размер payload. Джоб сериализуется в очередь целиком. Если в конструктор передана Eloquent-модель, Laravel по умолчанию сериализует только её ключ и перезагрузит из базы при выполнении (трейт
SerializesModels). Но если передан большой массив или коллекция — весь объём улетает в Redis на каждую задачу. Передавайте ID, а не объекты. - Таймаут против retry_after.
--timeoutдолжен быть меньше, чемretry_afterв конфиге очереди. Иначе воркер убьёт задачу по таймауту уже после того, как её повторно выдали другому воркеру — получите гарантированный дубль. - Обработка провала. Метод
failed(Throwable $e)в джобе — куда попадёт задача после исчерпания попыток. Без него провалившаяся оплата просто исчезнет изfailed_jobsбез компенсирующего действия. - backoff. Свойство
$backoffзадаёт паузу перед повтором. Без него провал по недоступности внешнего API превращается в шторм повторов, который добивает и без того лежащий сервис. - Внешние вызовы без таймаута. HTTP-клиент без явного таймаута внутри джоба под нагрузкой связывает воркер на минуты. Ставьте
Http::timeout(...)явно.
Где Redis-драйвер ломается первым
Redis для очередей — это списки и sorted set. Отложенные и зарезервированные задачи лежат в ZSET, ожидающие — в списке. Под нагрузкой отказывает обычно одно из трёх.
Раздувание памяти. Если воркеров не хватает и задачи копятся быстрее, чем разбираются, used_memory растёт линейно. При maxmemory с политикой вытеснения Redis может начать удалять ключи очереди — и задачи молча пропадают. Для инстанса под очередь политика вытеснения должна быть noeviction, иначе потеря задач под пиком гарантирована.
Зависшие reserved-задачи. Если воркер убит жёстко (OOM-killer, kill -9), задача остаётся в reserved-наборе до истечения retry_after. Слишком большой retry_after — задачи застревают на минуты. Слишком маленький — дубли. Значение подбирается по реальному максимальному времени выполнения самой долгой задачи в этой очереди.
Один Redis на кеш и очередь. Частая ошибка: очередь и кеш приложения на одном инстансе. Всплеск задач вытесняет горячий кеш, растёт нагрузка на базу, всё падает каскадом. Разнесите по разным инстансам или как минимум по разным логическим базам с отдельным maxmemory.
Что мерить в проде
| Показатель | Где смотреть | О чём говорит |
|---|---|---|
| Длина очереди | Queue::size('emails') или LLEN в Redis | Растёт монотонно — воркеров не хватает |
| used_memory | redis-cli info memory | Растёт вместе с очередью — риск вытеснения |
| failed_jobs | таблица failed_jobs | Всплеск — сломался внешний сервис или логика |
| Время в очереди | разница между временем постановки и старта | Секунды — норма, минуты — деградация |
Горизонтальное масштабирование воркеров
Когда один сервер с воркерами упирается в потолок, добавляют процессы и машины. Laravel Horizon (для Redis-драйвера) даёт автоскейлинг числа воркеров под длину очереди и дашборд с метриками. Стратегия balance=auto перераспределяет процессы между очередями по нагрузке — это снимает ручную возню с числом воркеров на очередь.
Важный нюанс: увеличение числа воркеров упирается в базу. Двести воркеров, каждый со своим пулом соединений к PostgreSQL, могут исчерпать max_connections быстрее, чем разгрузят очередь. Масштабирование воркеров без пулера соединений (PgBouncer или аналог) переносит узкое место с очереди на базу.
Что сделать после ревью
Порядок действий, если очередь уже деградирует под нагрузкой:
- Выведите длину каждой очереди и
used_memoryRedis на мониторинг — без цифр вы гадаете. - Проверьте, что
--timeoutстрого меньшеretry_afterпо всем очередям. - Найдите джобы, которые небезопасны при повторе, и добавьте идемпотентность через уникальный ключ операции.
- Разведите тяжёлые и лёгкие задачи по разным очередям с разным числом воркеров.
- Убедитесь, что политика памяти Redis под очередь —
noeviction. - Если воркеров нужно больше десятков — ставьте пулер соединений к базе до того, как упрётесь в
max_connections.
Тонкая настройка backoff-стратегий и работа с батчами (Bus::batch) — отдельная тема, здесь мы разбирали только устойчивость одиночных джобов под потоком. Точные имена методов и доступность опций сверяйте с документацией вашей версии Laravel: между 10.x и 11.x часть значений по умолчанию и синтаксис конфигурации менялись.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Laravel Documentation — Queues, Laravel Horizon
Частые вопросы
У меня Laravel 9, а не 10 или 11 — приёмы подойдут?
Я добавил chunkById, но воркер всё равно падает по памяти — что не так?
Как понять, что задачи выполняются дважды, а не мне кажется?
Грозит ли потеря задач, если Redis перезапустится?
ShouldBeUnique защищает от повторного выполнения задачи?
Стоит ли просто увеличить число воркеров, чтобы разгрести очередь?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.