Загрузка
Все статьи

Laravel Horizon: мониторинг Redis-очередей и почему мы ушли от стандартного worker

Стандартный php artisan queue:work работает, но когда у тебя 15 очередей, 3 приоритета и периодические зависания jobs — ты слеп. Нет мониторинга, нет метрик, нет понимания, что происходит. Laravel Horizon добавляет дашборд, retry-логику и автоматический масштабирование worker'ов. Перешли и наконец увидели, что происходит с нашими очередями.

У нас 15 очередей, 3 уровня приоритета и около 200 000 джобов в день. Стандартный php artisan queue:work нас устраивал ровно до того момента, пока не начали появляться зависания. Jobs застревали в статусе reserved, worker молчал, а мы не знали об этом, пока клиент не жаловался на задержку. Нужно было что-то с мониторингом, и мы поставили Laravel Horizon.

Проблемы стандартного worker

До Horizon наш supervisord.conf выглядел как зоопарк:

# /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker-high]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=high --sleep=1 --tries=3
autostart=true
autorestart=true
numprocs=3
user=www-data

[program:laravel-worker-default]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=default --sleep=1 --tries=3
autostart=true
autorestart=true
numprocs=5

[program:laravel-worker-low]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=low --sleep=3 --tries=3
autostart=true
autorestart=true
numprocs=2

[program:laravel-worker-notifications]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=notifications --sleep=1 --tries=5
autostart=true
autorestart=true
numprocs=2

4 блока конфигурации, 12 процессов, и ни одного дашборда. Когда джоб зависал, мы узнавали об этом через failed_jobs таблицу через несколько часов. А если джоб не фейлился, а просто висел в reserved — мы вообще не узнавали. Кроме того, при пиковой нагрузке high-очередь могла заблокировать default, потому что worker'ы работали независимо и не балансировали нагрузку.

Установка и настройка Horizon

composer require laravel/horizon
php artisan horizon:install

// app/Providers/HorizonServiceProvider.php

namespace App\Providers;

use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\HorizonApplicationServiceProvider;

class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
    protected function gate(): void
    {
        Gate::define('viewHorizon', function ($user) {
            return in_array($user->email, [
                'admin@company.ru',
                'devops@company.ru',
            ]);
        });
    }
}

Конфигурация — вот где начинается магия. Horizon заменяет весь зоопарк supervisor-конфигов одним файлом:

// config/horizon.php

return [
    'environments' => [
        'production' => [
            'supervisor-1' => [
                'connection' => 'redis',
                'queue' => ['high'],
                'balance' => 'simple',
                'processes' => 4,
                'tries' => 3,
                'timeout' => 120,
            ],
            'supervisor-2' => [
                'connection' => 'redis',
                'queue' => ['default', 'notifications'],
                'balance' => 'auto',
                'minProcesses' => 2,
                'maxProcesses' => 10,
                'tries' => 3,
                'timeout' => 60,
            ],
            'supervisor-3' => [
                'connection' => 'redis',
                'queue' => ['low', 'reports'],
                'balance' => 'simple',
                'processes' => 2,
                'tries' => 2,
                'timeout' => 300,
            ],
        ],
        'local' => [
            'supervisor-1' => [
                'connection' => 'redis',
                'queue' => ['default'],
                'balance' => 'simple',
                'processes' => 1,
                'tries' => 3,
            ],
        ],
    ],

    'trim' => [
        'recent' => 60,
        'pending' => 60,
        'completed' => 60,
        'recent_failed' => 10080,
        'failed' => 10080,
        'monitored' => 10080,
    ],
];

Обратите внимание на balance => auto у второго supervisor'а. Это значит, что Horizon сам масштабирует количество worker-процессов от 2 до 10 в зависимости от нагрузки на очереди. Когда ночью нет нагрузки — работают 2 процесса. Когда днём прилетает пачка уведомлений — поднимается до 10. Это сэкономило нам ресурсы сервера примерно на 30%.

Запуск через Supervisor (теперь один блок)

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

Один блок. Один процесс horizon, который управляет всеми worker'ами внутри. Перезапуск — одна команда. Логирование — один файл.

Что нам дал дашборд

Первая вещь, которую мы увидели на дашборде — 47 джобов застряло в статусе reserved за последнюю неделю. Они висели от 2 до 18 часов. Стандартный worker об этом не говорил. Horizon показал всё.

Вторая вещь — мы увидели, что очередь reports постоянно растёт. Репорты генерировались по 30–40 секунд, и 2 worker'а не справлялись. Добавили timeout => 300 и maxProcesses => 5 — проблема решилась.

Третья — метрики. Throughput, время выполнения, количество фейлов — всё на одном экране. Мы даже подключили уведомления в Slack через Horizon'овский Horizon::routeMailNotificationsTo().

Обработка зависших джобов

// app/Console/Commands/CheckStaleJobs.php

class CheckStaleJobs extends Command
{
    protected $signature = 'queue:check-stale {--minutes=30}';
    protected $description = 'Найти зависшие reserved jobs';

    public function handle(): int
    {
        $minutes = $this->option('minutes');
        $staleCount = 0;

        Redis::connection('horizon')
            ->zrangebyscore(
                'horizon:reserved',
                0,
                now()->subMinutes($minutes)->getTimestamp()
            )
            ->each(function (string $jobPayload) use (&$staleCount) {
                $job = json_decode($jobPayload, true);

                Log::warning('Stale job detected', [
                    'id' => $job['id'] ?? 'unknown',
                    'queue' => $job['queue'] ?? 'unknown',
                    'command' => $job['displayName'] ?? 'unknown',
                ]);

                $staleCount++;
            });

        $this->info("Found $staleCount stale jobs");
        return $staleCount > 0 ? self::FAILURE : self::SUCCESS;
    }
}

// В scheduler:
// $schedule->command('queue:check-stale')->everyFiveMinutes();

Этот крон раз в 5 минут проверяет, нет ли джобов, которые висят в reserved дольше 30 минут, и пишет в лог. В сочетании с alerting мы теперь узнаём о проблемах за 5 минут, а не за 5 часов.

Когда Horizon — overkill

Если у вас одна очередь и 100 джобов в день — не ставьте Horizon. Это добавит зависимости (Redis, метрики, дашборд) без реальной пользы. Стандартный queue:work с supervisor отлично справится. Horizon нужен, когда у вас много очередей, высокий throughput или проблемы с зависаниями, которые вы не можете отловить стандартными средствами.

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

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Laravel Queue Workers: как я сломал Redis и что я понял о retry-логике

Запустил queue:work на production с --tries=0 — и все джобы начали падать в бесконечный цикл, забивая Redis на 100%. В этой статье разбираю, как работает retry-механизм в Laravel, чем отличаются backoff-стратегии, и почему supervisor не спасает от переполнения очередей.

6 мая 2026 7 мин

Сложности с разделами каталога в Битрикс24 через REST API: игра с деревом

Обновляете товар через API, передаете SECTION_ID, но он не появляется в нужной папке. В этой статье я объясняю разницу между простым SECTION_ID и сложным деревом инфоблоков, как правильно выстроить иерархию и почему метод crm.product.update не умеет создавать папки.

5 июля 2026 1 мин

CMake: модульная архитектура для большого C++ проекта — без ad-hoc include путей

Как мы реорганизовали CMake-структуру проекта на 15+ модулей, избавились от хаотичных include_directories и перешли на современный CMake с target-based зависимостями.

20 июня 2026 10 мин