Очереди в Laravel настраиваются за полчаса и работают годами — пока не случается первый сбой. И вот тогда выясняется, что задачи выполняются дважды, воркеры крутят старый код, а половина заданий тихо ушла в failed_jobs, куда никто не смотрит.
Разберём настройку, а главное — четыре вещи, которые отличают работающую очередь от надёжной.
Зачем очереди
Пользователь нажимает «Оформить заказ». Без очередей:
// ❌ Пользователь ждёт 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);
}С очередями:
// ✅ Пользователь ждёт 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
# Установка Redis
sudo apt install redis-server
# Проверка
redis-cli ping # PONG# .env
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379// config/queue.php
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
],
],Создание Job
php artisan make:job ProcessOrderJob// 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
// Немедленно
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();Запуск воркеров
Базовый запуск
# Один воркер
php artisan queue:work
# С параметрами
php artisan queue:work redis --queue=high,default,low --tries=3 --timeout=90
# Для разработки (перезапуск при изменениях)
php artisan queue:listenSupervisor для production
# /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=3600sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "laravel-worker:*"Laravel Horizon
Horizon — красивый дашборд для мониторинга очередей Redis.
composer require laravel/horizon
php artisan horizon:install// 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
# Разработка
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.
Разные очереди
// Критичные задачи — высокий приоритет
SendPasswordResetJob::dispatch($user)->onQueue('high');
// Обычные
ProcessOrderJob::dispatch($order)->onQueue('default');
// Низкий приоритет
CleanupOldDataJob::dispatch()->onQueue('low');
// Отдельная очередь для email (можно ограничить rate)
SendNewsletterJob::dispatch($email)->onQueue('emails');# Воркер обрабатывает очереди в порядке приоритета
php artisan queue:work --queue=high,default,lowRate Limiting
// app/Jobs/SendEmailJob.php
use Illuminate\Queue\Middleware\RateLimited;
class SendEmailJob implements ShouldQueue
{
public function middleware(): array
{
// Максимум 10 задач в минуту
return [new RateLimited('emails')];
}
}// 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
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;
}После деплоя: Всегда перезапускайте воркеры! Они загружают код один раз при старте.
php artisan queue:restartЧетыре правила, без которых очередь не надёжна
Задача обязана быть идемпотентной. Не «желательно», а обязана. Воркер может упасть после выполнения работы, но до подтверждения задачи — и та вернётся в очередь. Задача, отправляющая письмо, отправит его дважды; задача, списывающая деньги, спишет дважды. Проверка «а не сделано ли уже» должна быть внутри самой задачи, а не рассчитывать на то, что очередь доставит ровно один раз. Ни одна очередь этого не гарантирует.
afterCommit при работе внутри транзакций. Задача, отправленная в очередь внутри DB::transaction(), может быть подхвачена воркером раньше, чем транзакция закоммитится, — и не найдёт запись, которую только что «создали». На локальном драйвере sync это не воспроизводится, на проде проявляется под нагрузкой.
Воркеры перезапускаются после каждого деплоя. Воркер — долгоживущий процесс: он держит в памяти код, загруженный при старте, и об изменениях в файлах не узнаёт. Без php artisan queue:restart в скрипте деплоя вы получите задачи, выполняемые старой версией кода, — и это худший вид расхождения, потому что сайт при этом уже новый.
У failed_jobs должен быть хозяин. Таблица неудачных задач по умолчанию наполняется молча. Мониторинг её размера и уведомление при появлении новых записей — обязательная часть настройки, а не улучшение. Метод failed() в критичных задачах — тем более: письмо администратору о том, что заказ не ушёл в CRM, лучше, чем обнаружить это через неделю.
Отладка
// Синхронное выполнение для отладки
// .env
QUEUE_CONNECTION=sync
// Или для конкретной задачи
ProcessOrderJob::dispatchSync($order);# Просмотр failed jobs
php artisan queue:failed
# Повторить все failed
php artisan queue:retry all
# Очистить failed
php artisan queue:flushИтоги
| Задача | Решение |
|---|---|
| Фоновые задачи | dispatch() в очередь |
| Мониторинг | Laravel Horizon |
| Повторы при ошибках | $tries, $backoff |
| Приоритеты | Разные очереди |
| Rate limiting | RateLimited middleware |
| Production | Supervisor |
Чек-лист перед запуском в продакшен:
- Драйвер — Redis, а не
database. База в роли очереди работает, но при десятках воркеров превращается в источник блокировок. - Supervisor следит за воркерами и поднимает их после падения. Воркер, запущенный руками в
screen, — не решение. --max-timeили--max-jobsу воркера: долгоживущий PHP-процесс накапливает память, и плановый перезапуск дешевле разбирательств с утечкой.queue:restartв скрипте деплоя — иначе задачи выполняет старый код.$triesи$backoffзаданы явно. Бесконечные повторы задачи, падающей из-за неверных данных, забивают очередь; отсутствие задержки между повторами добивает внешний сервис, который и так лежит.- Задачи идемпотентны.
- Мониторинг
failed_jobsи глубины очереди. Растущая очередь — ранний признак проблемы; заметить его до жалоб пользователей можно только по метрике. - Horizon — если очередь на Redis. Он даёт и мониторинг, и управление воркерами, и его стоит ставить сразу, а не когда начнутся вопросы.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.