У нас 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=24 блока конфигурации, 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.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье