Как работают очереди в Laravel: Redis + Horizon + Supervisor
Разберём эту систему по слоям: от концепции очередей до того, как Linux следит, чтобы всё это не падало.
1. Зачем вообще нужны очереди
Когда пользователь делает запрос (например, регистрируется на сайте), некоторые действия долгие: отправить email, сгенерировать PDF, обработать видео, дёрнуть внешний API. Если делать это синхронно — пользователь будет ждать несколько секунд, а то и упадёт по таймауту.
Решение: вместо того чтобы выполнять тяжёлую работу прямо в HTTP-запросе, Laravel кладёт задачу («job») в очередь и сразу отвечает пользователю. Кто-то другой (worker) потом эту задачу заберёт и выполнит в фоне.
ProcessPodcast::dispatch($podcast); // мгновенно, не блокирует ответ
2. Redis как хранилище очереди
Очереди нужно где-то хранить. Laravel поддерживает разные драйверы (database, sqs, redis и т.д.). Redis — самый популярный выбор для очередей, потому что:
- он in-memory и очень быстрый;
- имеет встроенные структуры данных, удобные для очередей (списки, sorted sets);
- поддерживает атомарные операции. Важно, чтобы два worker’а не схватили одну и ту же задачу. Laravel реализует эту атомарность через Lua-скрипты, выполняемые Redis целиком, без возможности вклиниться между шагами.
Технически, когда вы вызываете dispatch(), Laravel сериализует job (класс, его свойства) и помещает её в Redis-очередь (список queues:default).
Подробнее про сериализацию задачи.
Сериализуется не класс, а объект класса Job.
Что реально уходит в Redis
В Redis-список кладётся JSON-строка — конверт с метаданными. А сам объект job лежит внутри неё отдельным полем, сериализованный нативным PHP serialize():
// Illuminate\Queue\Queue::createObjectPayload()
'data' => [
'commandName' => get_class($job), // 'App\Jobs\ProcessPodcast'
'command' => serialize(clone $job), // ← вот здесь сам инстанс
],
Итоговый payload примерно такой:
{
"uuid": "9a1c...",
"displayName": "App\\Jobs\\ProcessPodcast",
"job": "Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries": 3,
"timeout": 60,
"retryUntil": null,
"attempts": 0,
"data": {
"commandName": "App\\Jobs\\ProcessPodcast",
"command": "O:24:\"App\\Jobs\\ProcessPodcast\":1:{s:7:\"podcast\";..."
}
}
Метаданные (maxTries, timeout, retryUntil, attempts) вынесены наружу специально: воркеру нужно прочитать их до того, как он развернёт объект, например, чтобы решить, не пора ли отправить задачу в failed_jobs. На стороне воркера CallQueuedHandler@call делает unserialize($data['command']) и вызывает handle() через контейнер (поэтому в handle() работает method injection).
clone перед serialize() нужен, чтобы магия __serialize()/__sleep() не покалечила исходный объект в текущем процессе — он ведь может использоваться дальше.
Практические следствия
Сериализуются все свойства: public, protected, private.
Значит, в job нельзя класть то, что PHP сериализовать не умеет:
PDO-соединения, файловые дескрипторы, ресурсы, замыкания.
Для очередей из замыканий (dispatch(function () { ... })) Laravel подключает отдельный пакет laravel/serializable-closure.
Трейт SerializesModels меняет правила для Eloquent. Он перехватывает __serialize() и подменяет модель на ModelIdentifier — класс, id, имя соединения и список загруженных связей.
При десериализации модель заново достаётся из БД.
Два следствия:
- воркер видит актуальное состояние записи, а не снимок на момент dispatch — обычно это то, что нужно;
- если запись успели удалить,
unserializeброситModelNotFoundException. По умолчанию (deleteWhenMissingModels) такая задача тихо удаляется, не попадая вfailed_jobs.
Для отложенных задач (->delay()) и для задач, уже взятых воркером в работу («reserved»), Laravel использует sorted set: задачи там отсортированы по timestamp’у, когда они должны стать доступными снова.
Как воркер узнаёт о новой задаче
Здесь важная деталь, которую часто описывают неверно. По умолчанию воркер не висит в блокирующем ожидании, он работает в режиме polling:
- пытается забрать задачу из очереди;
- если очередь пуста — спит
--sleep=3секунды; - повторяет.
Блокирующее ожидание (BLPOP по служебному ключу queues:default:notify) включается, только если в config/queue.php для redis-подключения явно задан block_for:
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => 5, // по умолчанию null → polling
],
Блокирующий режим снижает задержку подхвата задачи и нагрузку на Redis, но занимает соединение на всё время ожидания.
retry_after и timeout — источники дублей
Два параметра, которые обязательно надо согласовать:
retry_after(вconfig/queue.php). Через сколько секунд Laravel считает задачу «зависшей» и возвращает её в очередь;timeoutворкера (в Horizon — ключtimeoutвconfig/horizon.php). Через сколько секунд принудительно убить процесс, выполняющий job.
retry_after должен быть строго больше самого долгого timeout. Иначе задача будет отдана второму воркеру, пока первый её ещё выполняет и вы получите дублирующиеся отправки писем и списания.
3. Кто выполняет задачи: воркеры
Сама по себе задача в Redis просто лежит и ждёт. Её должен кто-то забрать и выполнить. Это делает команда:
php artisan queue:work redis
Это долгоживущий процесс: обычно он запускается один раз и работает непрерывно (в цикле: взять задачу → выполнить → взять следующую). При этом worker может завершиться по различным причинам, например, из-за заданных ограничений времени/количества jobs (--max-time, --max-jobs), превышения лимита памяти (--memory), ошибки или получения сигнала. Именно поэтому его обычно запускают под управлением отдельного менеджера процессов или Horizon.
Проблема: что если worker упадёт (ошибка, сервер перезагрузился, память закончилась)? Кто его перезапустит? Тут в игру вступают Horizon и Supervisor.
Задачи, исчерпавшие попытки, попадают в failed jobs, по умолчанию в таблицу failed_jobs (драйвер database). Horizon дополнительно хранит их копию в Redis, чтобы показывать в дашборде и давать кнопку retry; срок хранения этих данных задаётся в config/horizon.php (trim / failed в секции хранения).
4. Что такое Laravel Horizon
Horizon — это надстройка над обычными Redis-воркерами, которая даёт:
- Красивый дашборд. Видно, сколько задач выполнено, сколько упало, сколько задач в очереди, throughput в реальном времени, время выполнения job’ов.
- Управление несколькими очередями и воркерами из одного конфига (
config/horizon.php). Можно описать, сколько процессов держать на очередиhigh, сколько наdefault, с авто-масштабированием ('balance' => 'auto'). - Единую точку запуска. Вместо того чтобы вручную поднимать десяток
queue:workпроцессов, вы запускаете один процесс:
php artisan horizon
Horizon сам, внутри себя, запускает и следит за нужным числом дочерних worker-процессов согласно конфигу (supervisors в терминологии Horizon — не путать с Linux Supervisor, это разные вещи с одинаковым названием!).
Два важных нюанса:
- Horizon работает только с Redis-драйвером очередей. Это не универсальный менеджер для любых очередей.
- Нужны PHP-расширения
pcntlиposix. Без них не работает обработка сигналов, а значит — ниhorizon:terminate, ни корректный graceful shutdown, ни контроль дочерних процессов.
5. Два разных «Supervisor», не запутайтесь.
Тут стоит остановиться отдельно, потому что термин «supervisor» используется на двух уровнях:
- Horizon Supervisor. Это логическая сущность внутри
config/horizon.php, группа воркеров, обслуживающих определённые очереди (например,supervisor-1держит 10 процессов на очередиemails). - Linux Supervisor (supervisord). Это отдельная системная программа, вообще не знающая ничего про Laravel, чья задача — следить, чтобы один конкретный процесс (в нашем случае
php artisan horizon) был постоянно запущен.
Важно: Horizon не порождает воркеров напрямую из мастер-процесса. Иерархия трёхуровневая — на каждую supervisor-группу из конфига поднимается свой процесс-посредник:
supervisord (Linux)
└── следит за процессом: php artisan horizon ← MasterSupervisor
└── php artisan horizon:supervisor ... ← по процессу на каждую
│ supervisor-группу из конфига
└── php artisan horizon:work ... ← собственно воркеры
└── каждый воркер читает задачи из Redis
Именно поэтому в ps aux | grep horizon процессов больше, чем сумма maxProcesses по конфигу: к воркерам добавляются мастер и по одному процессу на группу.
6. Зачем нужен Linux Supervisor, если есть Horizon
Сам php artisan horizon — это тоже просто процесс. Если сервер перезагрузится, если процесс упадёт по ошибке PHP, если кто-то случайно его убьёт — задачи просто перестанут обрабатываться, и никто не заметит, пока очередь не разрастётся до небес.
Supervisord решает эту проблему на уровне ОС: он запускает процесс, следит за его PID, и если процесс завершается — перезапускает автоматически. Конфиг обычно выглядит так:
[program:horizon]
process_name=%(program_name)s
command=/usr/bin/php8.3 /var/www/project/artisan horizon
autostart=true
autorestart=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/www/project/storage/logs/horizon.log
stopwaitsecs=3600
Разберём ключевые директивы:
- command — указывайте абсолютный путь к бинарнику PHP: у supervisord свой
PATH, и простоphpможет не разрешиться (или разрешиться не в ту версию). - autostart — запускать при старте supervisord (обычно при загрузке сервера).
- autorestart — если процесс завершился, запустить заново.
- numprocs=1 — явно фиксируем: Horizon должен быть в единственном экземпляре. Два параллельных мастер-процесса — это двойной набор воркеров и гонки.
- user — от чьего имени работает процесс (обычно тот же, что и у веб-сервера, ради прав доступа к файлам).
- stopwaitsecs=3600 — сколько supervisord ждёт после отправки
SIGTERM, прежде чем добить процесс черезSIGKILL. Это применяется приsupervisorctl stop horizon,supervisorctl restart horizonи при выключении/перезагрузке сервера. Значение должно быть не меньше максимально возможного времени выполнения job — иначе supervisord прибьёт Horizon посреди работы и задача оборвётся.
Обратите внимание: stopwaitsecs не участвует в сценарии php artisan horizon:terminate — там Horizon завершается сам, и supervisord просто фиксирует выход процесса. Подробнее — в следующем разделе.
7. Как это выглядит при деплое
Правильный паттерн деплоя с Horizon+Supervisor такой:
php artisan horizon:terminate
Это не отключает конфигурацию Supervisor. Команда через Redis отправляет сигнал текущему процессу Horizon: тот инициирует graceful shutdown — текущие jobs получают возможность завершиться, новые не берутся, после чего воркеры и сам мастер-процесс выходят.
Supervisord, увидев, что процесс завершился (а autorestart=true), запускает новый процесс php artisan horizon — уже с новым кодом. Так выполняется graceful restart Horizon после деплоя.
Здесь supervisord ничего не ждёт и stopwaitsecs не применяется: он реагирует на уже случившийся выход процесса, а не сам его останавливает.
8. Собираем всю картину воедино
- Ваш Laravel-код вызывает
Job::dispatch()→ задача сериализуется и пушится в Redis-список (или в sorted set, если заданdelay). php artisan horizon— единственный процесс, за которым следит Linux Supervisor (autorestart, живёт вечно).- Внутри себя Horizon поднимает по процессу
horizon:supervisorна каждую supervisor-группу изconfig/horizon.php, а те, в свою очередь, — N процессов-воркеров (сколько на какую очередь, авто-баланс). - Каждый воркер опрашивает Redis (или блокирующе ждёт, если задан
block_for), забирает задачу и выполняет. Horizon управляет worker-процессами и собирает метрики; данные Horizon тоже хранятся в Redis. - Если отдельный worker завершается, его дальнейшей судьбой управляет сам Horizon. Если завершается процесс Horizon, Linux Supervisor запускает его заново.
- Дашборд Horizon (
/horizonв браузере) использует данные Horizon из Redis и показывает состояние очередей и worker-процессов.
Что стоит посмотреть своими руками
Чтобы это закрепилось не только в теории, полезно:
- Открыть
config/horizon.phpи посмотреть на секциюenvironments— там видно, как задаются supervisor-группы (supervisor-1) и сколько процессов на какую очередь. - Выполнить
ps aux | grep horizonи увидеть все три уровня: мастер,horizon:supervisor,horizon:work. - Зайти в Redis через
redis-cliи посмотреть, сколько задач реально лежит в очереди. Учтите, что Laravel добавляет к ключам префикс изconfig/database.php(обычноlaravel_database_), поэтому голыйLLEN queues:defaultвернёт 0. Сначала найдите реальный ключ:redis-cli --scan --pattern '*queues*' redis-cli LLEN laravel_database_queues:defaultЭто хорошо снимает «магию» с процесса. - Посмотреть
supervisorctl statusна сервере — увидеть, что Horizon значится там просто как один managed-процесс. - Намеренно уронить Horizon и посмотреть, как быстро supervisord его поднимет обратно. Используйте
kill <pid>(SIGTERM) илиsupervisorctl stop horizon, а неkill -9: SIGKILL нельзя перехватить, мастер умрёт мгновенно, а дочерниеhorizon:supervisorиhorizon:workостанутся висеть сиротами. Horizon умеет подчищать осиротевшие группы, но не мгновенно — какое-то время на сервере будет двойной набор процессов.