Главная/Статьи/Очереди в Laravel — Jobs, Workers, Horizon

Очереди в Laravel — Jobs, Workers, Horizon

Очереди обрабатывают тяжёлые задачи в фоне: email, уведомления, импорт. Настраиваем Redis, создаём Jobs, мониторим через Horizon.

ДМ
Дмитрий Мещеряков
📅 10 июля 2026 г.📖 7 мин чтения

Очереди в Laravel настраиваются за полчаса и работают годами — пока не случается первый сбой. И вот тогда выясняется, что задачи выполняются дважды, воркеры крутят старый код, а половина заданий тихо ушла в failed_jobs, куда никто не смотрит.

Разберём настройку, а главное — четыре вещи, которые отличают работающую очередь от надёжной.

Зачем очереди

Пользователь нажимает «Оформить заказ». Без очередей:

php
// ❌ Пользователь ждёт 10+ секунд
public function store(Request $request)
{
    $order = Order::create($data);           // 50ms
    Mail::send(new OrderConfirmation($order)); // 2s
    $this->sendTelegram($order);               // 1s
    $this->syncToCrm($order);                  // 3s
    $this->generatePdf($order);                // 2s
    return redirect()->route('orders.show', $order);
}

С очередями:

php
// ✅ Пользователь ждёт 100ms
public function store(Request $request)
{
    $order = Order::create($data);
    // Всё уходит в фон
    ProcessOrderJob::dispatch($order);
    return redirect()->route('orders.show', $order);
}
💡 Совет

Очередь — список задач, которые выполняются фоновыми воркерами. Laravel поддерживает Redis, Database, SQS, Beanstalkd.

Настройка Redis

bash
# Установка Redis
sudo apt install redis-server
# Проверка
redis-cli ping  # PONG
bash
# .env
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
php
// config/queue.php
'connections' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => 'default',
        'queue' => env('REDIS_QUEUE', 'default'),
        'retry_after' => 90,
        'block_for' => null,
    ],
],

Создание Job

bash
php artisan make:job ProcessOrderJob
php
// app/Jobs/ProcessOrderJob.php
namespace App\Jobs;
use App\Models\Order;
use App\Mail\OrderConfirmation;
use App\Services\CrmService;
use App\Services\PdfService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class ProcessOrderJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    /**
     * Количество попыток
     */
    public int $tries = 3;
    /**
     * Таймаут в секундах
     */
    public int $timeout = 120;
    /**
     * Задержка между попытками (секунды)
     */
    public int $backoff = 60;
    public function __construct(
        public Order $order
    ) {}
    public function handle(CrmService $crm, PdfService $pdf): void
    {
        // Отправляем email
        Mail::to($this->order->user)->send(
            new OrderConfirmation($this->order)
        );
        // Синхронизируем с CRM
        $crm->syncOrder($this->order);
        // Генерируем PDF
        $pdf->generateInvoice($this->order);
        // Обновляем статус
        $this->order->update(['processed_at' => now()]);
    }
    /**
     * Обработка ошибки после всех попыток
     */
    public function failed(\Throwable $exception): void
    {
        // Логируем
        logger()->error('ProcessOrderJob failed', [
            'order_id' => $this->order->id,
            'error' => $exception->getMessage(),
        ]);
        // Уведомляем админа
        Notification::route('telegram', config('services.telegram.admin_chat'))
            ->notify(new JobFailedNotification($this->order, $exception));
    }
}

Dispatch Jobs

php
// Немедленно
ProcessOrderJob::dispatch($order);
// С задержкой
ProcessOrderJob::dispatch($order)->delay(now()->addMinutes(5));
// В определённую очередь
ProcessOrderJob::dispatch($order)->onQueue('emails');
// С приоритетом (высокий → низкий)
ProcessOrderJob::dispatch($order)->onQueue('high');
// Цепочка задач
Bus::chain([
    new ProcessOrderJob($order),
    new SendInvoiceJob($order),
    new NotifyWarehouseJob($order),
])->dispatch();
// Batch — параллельное выполнение
Bus::batch([
    new ImportProductJob($file1),
    new ImportProductJob($file2),
    new ImportProductJob($file3),
])->then(function (Batch $batch) {
    // Все задачи завершены
})->catch(function (Batch $batch, Throwable $e) {
    // Первая ошибка
})->finally(function (Batch $batch) {
    // Batch завершён (успех или ошибка)
})->dispatch();

Запуск воркеров

Базовый запуск

bash
# Один воркер
php artisan queue:work
# С параметрами
php artisan queue:work redis --queue=high,default,low --tries=3 --timeout=90
# Для разработки (перезапуск при изменениях)
php artisan queue:listen

Supervisor для production

ini
# /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600
bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "laravel-worker:*"

Laravel Horizon

Horizon — красивый дашборд для мониторинга очередей Redis.

bash
composer require laravel/horizon
php artisan horizon:install
php
// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'maxProcesses' => 10,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
        ],
    ],
    'local' => [
        'supervisor-1' => [
            'maxProcesses' => 3,
        ],
    ],
],
'defaults' => [
    'supervisor-1' => [
        'connection' => 'redis',
        'queue' => ['high', 'default', 'low'],
        'balance' => 'auto',
        'autoScalingStrategy' => 'time',
        'minProcesses' => 1,
        'maxProcesses' => 10,
        'maxTime' => 0,
        'maxJobs' => 0,
        'memory' => 128,
        'tries' => 3,
        'timeout' => 60,
        'nice' => 0,
    ],
],

Запуск Horizon

bash
# Разработка
php artisan horizon
# Production (через Supervisor)
# /etc/supervisor/conf.d/horizon.conf
[program: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/www/app/storage/logs/horizon.log
stopwaitsecs=3600

Дашборд доступен по /horizon.

Разные очереди

php
// Критичные задачи — высокий приоритет
SendPasswordResetJob::dispatch($user)->onQueue('high');
// Обычные
ProcessOrderJob::dispatch($order)->onQueue('default');
// Низкий приоритет
CleanupOldDataJob::dispatch()->onQueue('low');
// Отдельная очередь для email (можно ограничить rate)
SendNewsletterJob::dispatch($email)->onQueue('emails');
bash
# Воркер обрабатывает очереди в порядке приоритета
php artisan queue:work --queue=high,default,low

Rate Limiting

php
// app/Jobs/SendEmailJob.php
use Illuminate\Queue\Middleware\RateLimited;
class SendEmailJob implements ShouldQueue
{
    public function middleware(): array
    {
        // Максимум 10 задач в минуту
        return [new RateLimited('emails')];
    }
}
php
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
public function boot(): void
{
    RateLimiter::for('emails', function (object $job) {
        return Limit::perMinute(10);
    });
}

Unique Jobs

php
use Illuminate\Contracts\Queue\ShouldBeUnique;
class ProcessOrderJob implements ShouldQueue, ShouldBeUnique
{
    public function __construct(
        public Order $order
    ) {}
    /**
     * Уникальный ID задачи
     */
    public function uniqueId(): string
    {
        return $this->order->id;
    }
    /**
     * Время уникальности (секунды)
     */
    public int $uniqueFor = 3600;
}
⚠️ Важно

После деплоя: Всегда перезапускайте воркеры! Они загружают код один раз при старте.

bash
php artisan queue:restart

Четыре правила, без которых очередь не надёжна

Задача обязана быть идемпотентной. Не «желательно», а обязана. Воркер может упасть после выполнения работы, но до подтверждения задачи — и та вернётся в очередь. Задача, отправляющая письмо, отправит его дважды; задача, списывающая деньги, спишет дважды. Проверка «а не сделано ли уже» должна быть внутри самой задачи, а не рассчитывать на то, что очередь доставит ровно один раз. Ни одна очередь этого не гарантирует.

afterCommit при работе внутри транзакций. Задача, отправленная в очередь внутри DB::transaction(), может быть подхвачена воркером раньше, чем транзакция закоммитится, — и не найдёт запись, которую только что «создали». На локальном драйвере sync это не воспроизводится, на проде проявляется под нагрузкой.

Воркеры перезапускаются после каждого деплоя. Воркер — долгоживущий процесс: он держит в памяти код, загруженный при старте, и об изменениях в файлах не узнаёт. Без php artisan queue:restart в скрипте деплоя вы получите задачи, выполняемые старой версией кода, — и это худший вид расхождения, потому что сайт при этом уже новый.

У failed_jobs должен быть хозяин. Таблица неудачных задач по умолчанию наполняется молча. Мониторинг её размера и уведомление при появлении новых записей — обязательная часть настройки, а не улучшение. Метод failed() в критичных задачах — тем более: письмо администратору о том, что заказ не ушёл в CRM, лучше, чем обнаружить это через неделю.

Отладка

php
// Синхронное выполнение для отладки
// .env
QUEUE_CONNECTION=sync
// Или для конкретной задачи
ProcessOrderJob::dispatchSync($order);
bash
# Просмотр failed jobs
php artisan queue:failed
# Повторить все failed
php artisan queue:retry all
# Очистить failed
php artisan queue:flush

Итоги

ЗадачаРешение
Фоновые задачиdispatch() в очередь
МониторингLaravel Horizon
Повторы при ошибках$tries, $backoff
ПриоритетыРазные очереди
Rate limitingRateLimited middleware
ProductionSupervisor

Чек-лист перед запуском в продакшен:

  1. Драйвер — Redis, а не database. База в роли очереди работает, но при десятках воркеров превращается в источник блокировок.
  2. Supervisor следит за воркерами и поднимает их после падения. Воркер, запущенный руками в screen, — не решение.
  3. --max-time или --max-jobs у воркера: долгоживущий PHP-процесс накапливает память, и плановый перезапуск дешевле разбирательств с утечкой.
  4. queue:restart в скрипте деплоя — иначе задачи выполняет старый код.
  5. $tries и $backoff заданы явно. Бесконечные повторы задачи, падающей из-за неверных данных, забивают очередь; отсутствие задержки между повторами добивает внешний сервис, который и так лежит.
  6. Задачи идемпотентны.
  7. Мониторинг failed_jobs и глубины очереди. Растущая очередь — ранний признак проблемы; заметить его до жалоб пользователей можно только по метрике.
  8. Horizon — если очередь на Redis. Он даёт и мониторинг, и управление воркерами, и его стоит ставить сразу, а не когда начнутся вопросы.
🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.