Очереди в Laravel, Horizon, Supervisor - mcodex

Очереди в Laravel, Horizon, Supervisor

Как работают очереди в 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:

  1. пытается забрать задачу из очереди;
  2. если очередь пуста — спит --sleep=3 секунды;
  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_afterconfig/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-воркерами, которая даёт:

  1. Красивый дашборд. Видно, сколько задач выполнено, сколько упало, сколько задач в очереди, throughput в реальном времени, время выполнения job’ов.
  2. Управление несколькими очередями и воркерами из одного конфига (config/horizon.php). Можно описать, сколько процессов держать на очереди high, сколько на default, с авто-масштабированием ('balance' => 'auto').
  3. Единую точку запуска. Вместо того чтобы вручную поднимать десяток 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. Собираем всю картину воедино

  1. Ваш Laravel-код вызывает Job::dispatch() → задача сериализуется и пушится в Redis-список (или в sorted set, если задан delay).
  2. php artisan horizon — единственный процесс, за которым следит Linux Supervisor (autorestart, живёт вечно).
  3. Внутри себя Horizon поднимает по процессу horizon:supervisor на каждую supervisor-группу из config/horizon.php, а те, в свою очередь, — N процессов-воркеров (сколько на какую очередь, авто-баланс).
  4. Каждый воркер опрашивает Redis (или блокирующе ждёт, если задан block_for), забирает задачу и выполняет. Horizon управляет worker-процессами и собирает метрики; данные Horizon тоже хранятся в Redis.
  5. Если отдельный worker завершается, его дальнейшей судьбой управляет сам Horizon. Если завершается процесс Horizon, Linux Supervisor запускает его заново.
  6. Дашборд 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 умеет подчищать осиротевшие группы, но не мгновенно — какое-то время на сервере будет двойной набор процессов.