Очереди в PHP: разбор на живом проекте с Redis и Laravel
Отправка письма внутри HTTP-запроса растянула ответ до трёх секунд. Разбираем, как очередь на Redis снимает нагрузку с веб-процессов, где она ломается и что мерить в проде.

Регистрация пользователя в интернет-магазине на Laravel 11 отдавала ответ за 2,8 секунды. Профилирование показало: 2,3 секунды из них уходило на отправку приветственного письма через SMTP внешнего провайдера. Пользователь ждал, пока PHP-процесс дозвонится до почтового сервера, получит ответ и только потом вернёт 302. При тридцати одновременных регистрациях пул php-fpm упирался в лимит воркеров, и остальные запросы вставали в очередь ОС — с 502 на выходе.
Синхронная отправка письма — классический случай для выноса в очередь. Разберём на этом проекте, как задача переезжает из HTTP-запроса в фоновый воркер, во что это обходится по инфраструктуре и где начинаются проблемы, о которых не пишут в туториалах.
Что не так с синхронной обработкой
PHP по своей модели исполнения обрабатывает один HTTP-запрос в одном процессе от начала до конца. Пока внутри запроса вы дёргаете внешний SMTP, платёжный шлюз или генерируете PDF, процесс php-fpm занят и никого больше не обслуживает. Число одновременных запросов ограничено директивой pm.max_children — и каждая медленная операция внутри запроса выедает этот лимит.
Очередь разрывает связь «запрос — тяжёлая работа». Веб-процесс кладёт в хранилище описание задачи и сразу отвечает клиенту. Отдельный процесс-воркер, живущий постоянно, забирает задачи и выполняет их вне HTTP-цикла.
Очередь не ускоряет отправку письма. Она убирает ожидание из пути пользователя: письмо всё так же летит 2,3 секунды, но эти секунды тратит воркер, а не тот, кто нажал «Зарегистрироваться».
Из чего собрать очередь в PHP
В Laravel очередь — это абстракция поверх драйвера-хранилища. Драйвер задаётся в config/queue.php и переменной окружения QUEUE_CONNECTION. Три реальных варианта под разные задачи:
| Драйвер | Что хранит задачи | Когда брать |
|---|---|---|
database | Таблица в вашей БД | Малый поток, не хочется отдельного сервиса, важна наглядность через SQL |
redis | Redis (список + sorted set для отложенных) | Средний и высокий поток, нужны атомарность и скорость |
sqs | Amazon SQS | Инфраструктура уже в AWS, нужна управляемая очередь без своего Redis |
На проекте из примера поток регистраций был средним, Redis уже стоял под кеш и сессии, поэтому выбрали redis. Драйвер sync из коробки не считается — он выполняет задачу тут же, синхронно, и годится только для локальной отладки.
Настройка драйвера
Для Redis-драйвера нужен пакет predis/predis или расширение phpredis. В .env:
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# необязательно: имя очереди по умолчанию
# задаётся в config/queue.php, секция connections.redis.queueВыносим отправку письма в задачу
Класс задачи в Laravel 11 генерируется командой php artisan make:job SendWelcomeEmail. Ключевой момент — интерфейс ShouldQueue: без него объект выполнится синхронно.
<?php
namespace App\Jobs;
use App\Models\User;
use App\Mail\WelcomeMail;
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 SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// число повторов и пауза между ними
public int $tries = 3;
public int $backoff = 30; // секунд перед повтором
public function __construct(public User $user)
{
}
public function handle(): void
{
Mail::to($this->user->email)->send(new WelcomeMail($this->user));
}
}Диспатч из контроллера регистрации:
use App\Jobs\SendWelcomeEmail;
// внутри метода после создания пользователя
SendWelcomeEmail::dispatch($user);
// вернуть ответ клиенту сразу, письмо уйдёт в фоне
return redirect()->route('dashboard');После этого изменения ответ на регистрацию упал с 2,8 секунды до 90–120 миллисекунд — время осталось на запись в БД и сериализацию задачи в Redis. Замер сделан локально на PHP 8.3, Redis 7, при последовательных запросах; в проде цифра будет иной, но порядок сохраняется.
Запуск воркера
Задача в очереди не выполнится сама. Нужен постоянно работающий процесс:
php artisan queue:work redis --queue=default --tries=3 --timeout=60Важное про timeout: он должен быть меньше значения retry_after из config/queue.php. Иначе очередь сочтёт задачу зависшей и выдаст её второму воркеру, пока первый ещё работает, — и письмо уйдёт дважды.
Почему queue:work нельзя запускать руками в проде
Процесс воркера может упасть: из-за фатальной ошибки в задаче, нехватки памяти, рестарта сервера. Его надо держать под супервизором. Для Linux это Supervisor или systemd. Минимальный конфиг Supervisor:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --tries=3 --timeout=60
autostart=true
autorestart=true
numprocs=2
user=www-data
stopwaitsecs=70Параметр stopwaitsecs должен быть больше timeout задачи — иначе супервизор убьёт воркер посреди отправки письма при деплое.
Где это ломается
Туториал заканчивается на «письмо ушло в фоне». Реальные проблемы начинаются дальше.
- Старый код в воркере. Воркер загружает код один раз при старте и держит его в памяти. После деплоя он продолжает исполнять старую версию класса задачи, пока его не перезапустить. Команда
php artisan queue:restartв скрипте деплоя посылает воркерам сигнал завершиться после текущей задачи — иначе неделями крутится код прошлого релиза. - Сериализация модели. Трейт
SerializesModelsсохраняет в очередь не объект, а идентификатор, и при выполнении подтягивает свежую запись из БД. Если пользователь удалён между диспатчем и обработкой, задача упадёт сModelNotFoundException. Это ожидаемо, но новичка удивляет: «я же передал объект». - Двойная отправка. Очередь гарантирует доставку «хотя бы один раз», а не «ровно один раз». При таймауте, падении воркера после отправки, но до подтверждения, задача выполнится повторно. Операции внутри
handle()стоит делать идемпотентными или защищать флагом в БД. - Утечка памяти. Долгоживущий процесс накапливает память. Ограничьте флагом
--memory=128и--max-jobs=1000: воркер сам завершится, супервизор поднимет новый с чистой памятью. - Провал в никуда. После
$triesнеудач задача уходит в failed. Без настроенной таблицыfailed_jobs(миграцияphp artisan queue:failed-table) вы просто теряете письма и не узнаете об этом.
Что мерить в проде
Очередь — это буфер, а у буфера есть глубина. Три показателя, за которыми стоит следить:
- Длина очереди. Для Redis —
LLEN queues:default. Растёт и не спадает — воркеры не справляются, добавьтеnumprocsили ускорьте задачу. - Возраст самой старой задачи. Насколько письмо отстаёт от регистрации. Секунды — норма, минуты — сигнал.
- Число записей в failed_jobs. Ненулевое и растущее — внешний сервис недоступен или в задаче баг.
Дальше по этому проекту: развести задачи по разным очередям (emails, reports) с отдельными воркерами, чтобы медленная генерация отчёта не блокировала быстрые письма. Отложенные задачи через dispatch()->delay() и повторяющиеся через планировщик — отдельная тема, здесь мы её не разворачивали. Начните с failed_jobs и queue:restart в деплое — без этих двух вещей очередь в проде превращается в тихую потерю данных.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Laravel 11 — Queues (официальная документация), Laravel — Deploying with Supervisor
Частые вопросы
А если у меня Laravel 10, а не 11?
Письмо иногда приходит дважды — что не так?
Нужен ли Redis, или можно обойтись базой данных?
Что будет, если я забуду запустить воркер?
Задачи не выполняются после деплоя новой версии — почему?
Как не потерять задачи, которые упали с ошибкой?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.