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-метода, прежде чем что-то с ней делать.