Разработка · 7 мин чтения

Laravel Horizon: мониторинг очередей и разбор упавших задач

Laravel Horizon я ставлю почти на каждый проект, где через очереди идёт больше пары задач в день: обработка вебхуков от Т‑Банка, обновление статусов от СДЭК, рассылка писем, генерация отчётов и PDF. Без нормального мониторинга очередей о проблеме узнаёшь тогда, когда клиент пишет в поддержку, что письмо не пришло, а заказ завис в статусе «в обработке». Horizon даёт дашборд, метрики по каждой очереди и человеческий интерфейс для разбора упавших задач, вместо того чтобы лезть в базу и вручную читать сериализованный payload из таблицы jobs.

Что делает Horizon с очередями Laravel

Стандартный php artisan queue:work запускает воркер и молчит. Чтобы понять, сколько задач в очереди, сколько из них упало и почему, приходится либо смотреть логи, либо писать свой мониторинг поверх таблиц jobs и failed_jobs. Horizon закрывает это одним пакетом: конфигурируемый менеджер процессов поверх Redis-очередей плюс веб-дашборд с метриками в реальном времени.

Важный момент, о котором часто забывают на старте: Horizon работает только с очередями на Redis. Если проект сидит на database-драйвере очередей, сначала переключаете QUEUE_CONNECTION на redis, и только потом ставите Horizon.

Из коробки дашборд показывает пропускную способность каждой очереди (сколько задач обрабатывается в минуту), среднее время выполнения задачи, время ожидания в очереди до старта обработки и список последних задач с возможностью открыть конкретный job и посмотреть его payload и теги. Теги, кстати, вещь практичная: если джоб обрабатывает конкретный заказ, я навешиваю на него тег с ID заказа через метод tags() в классе задачи, и потом в дашборде можно найти все джобы по этому заказу за секунду, а не выгружать логи grep-ом.

Установка Horizon в проект на Laravel

Ставится пакет стандартно через composer, дальше публикуются конфиг и сервис-провайдер:

composer require laravel/horizon
php artisan horizon:install

Команда horizon:install публикует config/horizon.php, регистрирует App\Providers\HorizonServiceProvider и подкладывает статику дашборда в public/vendor/horizon. Своих миграций Horizon не приносит: данные по очередям он держит в Redis («Laravel Horizon provides a beautiful dashboard and code-driven configuration for your Laravel powered Redis queues»), а таблица failed_jobs, куда падают задачи, исчерпавшие лимит попыток, относится к базовой поставке Laravel и создаётся её собственными миграциями.

В .env проверяю две вещи: QUEUE_CONNECTION=redis и то, что REDIS_HOST и REDIS_PORT реально указывают на рабочий Redis, а не на дефолтные значения из шаблона окружения. После этого дашборд открывается по адресу /horizon, но в проде до него нужно доехать по VPN или закрыть его через Gate, об этом ниже.

Настройка supervisor-процессов и балансировки воркеров

Ключевая часть конфига - секция environments. В ней описываются «супервизоры» Horizon (не путать с системным supervisord) для каждого окружения: сколько воркеров держать, какие очереди слушать и как балансировать нагрузку между ними.

'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default', 'notifications'],
            'balance' => 'auto',
            'minProcesses' => 1,
            'maxProcesses' => 10,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
            'tries' => 3,
            'timeout' => 60,
        ],
    ],

    'local' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default'],
            'balance' => 'simple',
            'maxProcesses' => 3,
        ],
    ],
],

Балансировка бывает трёх видов. simple держит фиксированное число воркеров, поделённое поровну между очередями из списка. false вообще не балансирует и запускает столько процессов, сколько указано, без автоматики. auto - самый полезный режим в проде: Horizon сам добавляет и убирает воркеров в пределах minProcesses и maxProcesses, ориентируясь на то, сколько задач копится в очереди и сколько времени они там ждут.

На практике на одном проекте с вебхуками от Т‑Банка я держал отдельную очередь под их обработку с balance => auto: в обычные часы хватало одного воркера, а во время всплеска платежей Horizon сам поднимал их до восьми-десяти, не трогая при этом воркеров под письма и отчёты в других очередях. Раньше это же приходилось руками прописывать в конфиге supervisord и перезапускать процессы при каждом изменении нагрузки.

Бесплатный материал

🎁 Полезный скрипт в подарок

Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.

Без спама. Отписка в 1 клик.

Дашборд мониторинга очередей: что я проверяю каждый день

На главной странице Horizon пять вещей, на которые я смотрю в первую очередь:

  • Recent Jobs - последние выполненные и упавшие задачи, с фильтром по очереди и по статусу.
  • Throughput - сколько задач в минуту обрабатывает каждая очередь. Резкое падение обычно значит, что воркер завис или упал сам процесс Horizon.
  • Runtime - среднее время выполнения задачи. Если джоб, который раньше выполнялся за 200 мс, вдруг стал выполняться секундами, это почти всегда внешний API стал отвечать медленнее (тот же СДЭК под нагрузкой) или в код закралась N+1 выборка.
  • Wait Time - сколько задача ждёт в очереди до того, как её подхватит воркер. Это индикатор нехватки воркеров: если по очереди default время ожидания растёт, значит воркеров под неё мало либо часть из них зависла на долгих задачах.
  • Failed Jobs - отдельная вкладка со списком всех упавших задач и трейсом исключения по каждой.

Отдельно смотрю на вкладку supervisor-процессов: там видно, сколько воркеров реально живо на каждом сервере прямо сейчас. Это спасает, когда деплой прошёл, а воркеры Horizon не перезапустились и продолжают работать со старым кодом.

Разбор упавших задач: от ошибки до retry

Когда задача исчерпывает число попыток из tries, она попадает в таблицу failed_jobs вместе с полным стек-трейсом исключения. В дашборде вкладка Failed показывает этот трейс прямо в браузере, не нужно лезть в БД.

Из консоли те же данные и действия доступны так:

php artisan queue:failed
php artisan queue:retry 5
php artisan queue:retry all
php artisan queue:forget 5
php artisan queue:flush

queue:retry ставит задачу обратно в очередь с тем же payload, queue:forget удаляет конкретную запись из failed_jobs без повторного запуска, queue:flush чистит всю таблицу разом, это я использую только после того, как разобрался с причиной массового падения и убедился, что старые записи больше не нужны.

Причины падений, с которыми сталкиваюсь чаще всего:

  • Исключение внутри самого джоба - невалидные данные во входящем вебхуке, таймаут при обращении к внешнему API вроде СДЭК или платёжного шлюза.
  • Ошибка десериализации после деплоя - джоб был поставлен в очередь до выкладки новой версии кода, а модель внутри него (например, заказ) к моменту обработки уже удалена, и Laravel кидает ModelNotFoundException при попытке восстановить модель из очереди.
  • Превышение memory_limit на тяжёлых джобах, которые обрабатывают большие выгрузки или генерируют объёмные отчёты.
  • Таймаут воркера короче, чем реально нужно задаче - если timeout в конфиге супервизора стоит 60 секунд, а внешний сервис в моменте отвечает дольше, воркер убьёт процесс раньше, чем задача успеет завершиться.

Отдельно слежу за идемпотентностью: если задача обрабатывает платёж или списывает остаток на складе, повторный retry не должен продублировать действие. Для этого либо делаю проверку состояния в начале джоба (платёж уже проведён - выходим), либо вешаю middleware WithoutOverlapping, чтобы одна и та же задача не выполнялась параллельно в нескольких воркерах.

Уведомления и мониторинг Horizon в продакшене

Смотреть в дашборд руками каждые полчаса нереально, поэтому настраиваю уведомления через HorizonServiceProvider:

public function boot(): void
{
    parent::boot();

    Horizon::routeSlackNotificationsTo(
        config('services.slack.horizon_webhook'),
        '#alerts'
    );

    Horizon::routeMailNotificationsTo('you@example.com');
}

Уведомление уходит, когда очередь долго ждёт воркера дольше порога, заданного в config('horizon.waits'), например ['redis:default' => 60] секунд. Это ловит именно ту ситуацию, когда воркеры упали, а джобы продолжают копиться молча.

Сам процесс Horizon в проде запускаю не через php artisan horizon в терминале, а как демон под supervisord (или аналогом на хостинге):

[program:horizon]
process_name=%(program_name)s
command=php /var/www/project/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/project/storage/logs/horizon.log
stopwaitsecs=3600

Большой stopwaitsecs нужен, чтобы при остановке процесса Horizon успел корректно завершить уже начатые задачи, а не оборвать их посреди выполнения. При деплое вместо перезапуска процесса руками вызываю php artisan horizon:terminate: Horizon дожидается завершения текущих задач и сам останавливается, а supervisord поднимает его заново уже с новым кодом. Ещё пара полезных команд для ручной эксплуатации - horizon:pause и horizon:continue, чтобы временно остановить обработку без остановки процесса, например перед миграцией с блокировкой таблиц.

Доступ к дашборду /horizon в проде обязательно ограничиваю через Gate в том же сервис-провайдере, иначе список задач и их payload увидит кто угодно, кто угадает урл. Дальше такую конфигурацию, алерты и разбор упавших задач на живом проекте я обычно веду на регулярной основе, это удобнее закрыть через техподдержку Laravel-проекта, чем каждый раз разбираться заново после инцидента.

Laravel регулярно обновляется, поэтому перед внедрением я сверяюсь с актуальной документацией.

Частые вопросы

Чем Horizon отличается от обычного queue:work?

queue:work запускает один процесс-воркер и ничего не показывает, кроме вывода в консоль. Horizon поверх тех же очередей добавляет управление количеством воркеров через конфиг, автобалансировку между очередями, дашборд с метриками и удобный разбор упавших задач без ручного похода в базу.

Нужен ли Redis для работы Horizon?

Да, Horizon работает только с Redis-драйвером очередей. Если очереди сейчас на database-соединении, перед установкой Horizon переключите QUEUE_CONNECTION на redis и убедитесь, что Redis-сервер доступен из приложения.

Как перезапустить Horizon после деплоя без потери задач?

Вызываю php artisan horizon:terminate в конце деплой-скрипта. Команда даёт текущим задачам доехать до конца, после чего мастер-процесс останавливается, а supervisord поднимает Horizon заново уже с обновлённым кодом. Резко убивать процесс кодом деплоя не стоит - можно оборвать задачу посередине.

Что делать, если задача падает с одной и той же ошибкой снова и снова?

Открываю трейс во вкладке Failed Jobs и смотрю, где именно падает - внутри самого джоба, во внешнем API или на десериализации модели. Если причина во внешнем сервисе, добавляю там таймаут и retry с бэкоффом внутри самого джоба, а не полагаюсь только на tries в конфиге супервизора. Если модель к моменту обработки уже удалена, проверяю существование записи в начале handle-метода, прежде чем что-то с ней делать.

Есть задача?

Обсудим в мессенджере

Расскажите, что нужно сделать — отвечу в течение 4 часов в рабочее время. Первая консультация бесплатно.

Продолжая пользование настоящим сайтом Вы выражаете своё согласие на обработку Ваших персональных данных (файлов куки) с использованием Yandex.Metrika.
Понятно