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

Продовый сценарий: интернет-магазин на Laravel 11, драйвер очереди Redis, восемь воркеров queue:work. В обычный день — сотни задач в минуту, всё зелёное. В день распродажи входящий поток вырос до примерно 4000 джобов в минуту, и посыпалось: часть писем ушла дважды, часть — не ушла вовсе, а Horizon показал очередь длиной в десятки тысяч без признаков разгребания. При этом CPU воркеров был загружен на 20 процентов. Проблема не в мощности — в том, как устроен цикл обработки и где в нём узкие места.
Разбор для тех, кто уже держит очередь в проде на Laravel 10–11 и упёрся в потолок. Как именно воркер тянет задачу из Redis, почему растёт задержка, откуда берутся дубликаты и что ломается первым при масштабировании. Настройку с нуля и выбор драйвера не рассматриваем — здесь только поведение под нагрузкой.
Как воркер на самом деле берёт задачу
Команда queue:work — это бесконечный цикл. На каждой итерации воркер делает одно: просит у драйвера следующую задачу. Для Redis это не просто чтение из списка. Laravel использует связку структур: сам список задач, отдельный sorted set для отложенных и зарезервированных джобов, и Lua-скрипт, который атомарно перекладывает задачу из очереди в набор зарезервированных.
Атомарность здесь ключевая. Скрипт за одну операцию достаёт джоб из списка и кладёт его в reserved-набор со временем истечения резервации. Пока задача в reserved, другой воркер её не увидит. Если воркер отработал — задача удаляется. Если упал, не сняв резервацию, — по истечении времени retry_after задача возвращается в очередь и её подхватывает следующий воркер.
Retry_after — это не про повторные попытки при ошибке. Это тайм-аут, после которого фреймворк считает задачу зависшей и возвращает в очередь. Именно отсюда берётся большинство дубликатов.
Вот механика дубля. У вас retry_after = 90 секунд (значение из config/queue.php), а задача — отправка отчёта — реально выполняется 120 секунд. На 90-й секунде фреймворк решает, что воркер завис, и возвращает задачу в очередь. Второй воркер её берёт и начинает выполнять параллельно с первым, который ещё работает. Отчёт уходит дважды.
Правило про два тайм-аута
Есть два независимых ограничения, и их путают:
retry_afterв конфиге очереди — на стороне фреймворка, когда задачу считать брошенной;--timeoutуqueue:work— на стороне воркера, через сколько принудительно убить процесс, если джоб не завершился.
Железное правило: --timeout должен быть меньше retry_after. Иначе воркер ещё выполняет задачу, а фреймворк её уже переотправил. Разница должна покрывать самую долгую вашу задачу. Если у вас есть джобы на 5 минут, retry_after ставьте с запасом, а не оставляйте дефолт.
Где растёт задержка при тысячах задач
Пропускная способность одного воркера упирается не в очередь, а в то, что задача делает внутри. Разложим типичный цикл обработки одной джобы.
| Этап итерации | Что происходит | Цена под нагрузкой |
|---|---|---|
| Забор задачи из Redis | Lua-скрипт, один round-trip | доли миллисекунды |
| Десериализация | распаковка payload, восстановление модели | растёт с размером payload |
| Загрузка связей | SerializesModels тянет модель заново по ID | запрос в БД на каждую задачу |
| Полезная работа | отправка письма, вызов внешнего API | сотни мс — секунды |
| Удаление из reserved | ещё один round-trip в Redis | доли миллисекунды |
Узкое место почти всегда в двух средних строках. Трейт SerializesModels при сериализации сохраняет не саму модель, а её класс и ключ. При обработке модель загружается из БД заново — это защищает от устаревших данных, но означает SELECT на каждую задачу. Тысяча задач в минуту с моделью внутри — это тысяча дополнительных запросов к базе плюс основная работа джоба.
Отсюда первый вывод: если очередь длинная, а воркеры простаивают по CPU, смотрите не на Redis, а на БД и внешние API, к которым ходят задачи. Redis отдаёт задачи быстрее, чем воркеры успевают их отрабатывать, и очередь копится.
Что ломается первым
1. База данных под попапом задач
Если джоб делает несколько запросов, а воркеров восемь и все работают параллельно, вы получаете умножение нагрузки на БД. При 4000 задач в минуту и трёх запросах на задачу — это 12000 запросов в минуту сверх фонового трафика приложения. Пул соединений заканчивается, задачи начинают падать с SQLSTATE too many connections, попадают в failed_jobs, а очередь при этом продолжает расти.
2. Дубликаты при переотправке
Разобрали выше. Проявляется именно на длинных задачах в момент пиковой нагрузки, когда всё замедляется и реальное время выполнения подползает к retry_after. Лечится тремя способами:
- увеличить
retry_afterс запасом относительно самой долгой задачи; - сделать джоб идемпотентным — проверять в начале, не выполнена ли работа уже (флаг в БД, ключ в Redis);
- для критичных задач использовать
ShouldBeUnique, который держит блокировку на время выполнения по ключу задачи.
3. Redis как единая точка
Reserved-набор и sorted set отложенных задач живут в Redis. При десятках тысяч задач эти структуры растут, а команда ZADD/ZRANGEBYSCORE по большому набору перестаёт быть бесплатной. Плюс Horizon хранит метрики и снапшоты там же. Если Redis один и на нём же сессии и кеш приложения — при попапе очереди начинает тормозить всё сразу.
4. Долгоживущий процесс и утечки памяти
Воркер — это долгоживущий PHP-процесс. Он не перезагружается между задачами, как обычный HTTP-запрос. Состояние копится: закешированные экземпляры, статические свойства, соединения. Через тысячи задач процесс раздувается по памяти и падает по --memory лимиту. Это нормально — Laravel рассчитывает на перезапуск воркера супервизором. Опасно другое: код, который в HTTP-запросе безобиден, потому что процесс живёт секунды, в воркере течёт часами.
Что и как мерить
Гадать бессмысленно, узкое место у всех разное. Снимайте эти показатели до любой оптимизации:
- Длина очереди во времени — растёт, стоит или разгребается. Horizon показывает это на дашборде, либо
LLENпо ключу очереди в Redis. - Время выполнения задачи по типам — Horizon даёт runtime по классам джобов. Ищите задачи с длинным хвостом: медианная 200 мс, а 95-й перцентиль — 30 секунд.
- Количество запросов к БД на задачу — Laravel Telescope в стейджинге или логирование через
DB::listenна одном воркере. - Число активных соединений с БД под пиковой нагрузкой против лимита
max_connections.
Только после замеров выбирайте направление. Если очередь копится, а runtime задач мал — не хватает воркеров или они блокируются на внешнем API. Если runtime большой — оптимизируйте саму задачу, а не добавляйте воркеров, иначе просто быстрее убьёте БД.
Как масштабировать осознанно
Больше воркеров — не всегда решение. Если бутылочное горлышко — БД или сторонний API с лимитом запросов, добавление воркеров ускоряет только момент, когда вы упрётесь в лимит. Работающие приёмы под тысячи задач в минуту:
- Разделяйте очереди по типам задач. Быстрые уведомления и тяжёлые отчёты в одной очереди — отчёты забивают воркеров, уведомления ждут. Разнесите на разные очереди с отдельными пулами воркеров и приоритетами.
- Батчинг. Вместо тысячи джобов «отправь одно письмо» — джобы «отправь пачку из 100». Снижаете накладные расходы на забор задачи и десериализацию в 100 раз.
- Отдельный Redis под очередь. Отделите инстанс очереди от кеша и сессий, чтобы попап очереди не ронял остальное приложение.
- lazy-загрузка связей вместо переливания в payload. Не пихайте в задачу всю модель — только ID, и загружайте нужное точечно, чтобы не тащить лишние SELECT.
Что настроить дальше
Начните с замеров runtime по типам задач в Horizon — это покажет, где на самом деле теряется время. Дальше — проверьте соотношение --timeout и retry_after в проде, это самый частый источник тихих дубликатов. Разнесите тяжёлые и лёгкие задачи по разным очередям с отдельными воркерами. И заложите мониторинг длины очереди с алертом: очередь, которая перестала разгребаться, обычно замечают по жалобам пользователей, а не по графику — а это уже поздно.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Laravel Queues — официальная документация, Laravel Horizon — официальная документация
Частые вопросы
У меня не Redis, а драйвер database. Разбор применим?
Как понять, что дубликаты именно из-за retry_after, а не из-за багов в коде?
Добавил воркеров, а очередь всё равно копится. Почему?
Обязательно ли перезапускать воркеры после деплоя?
ShouldBeUnique гарантирует, что задача не выполнится дважды?
Есть ли разница в поведении между Laravel 10 и 11?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.