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

Задача типовая: пользователь жмёт «Оформить заказ», а вы в том же обработчике генерируете PDF, шлёте письмо через SMTP и дёргаете внешний API доставки. SMTP отвечает за 4 секунды, API доставки — ещё за 3. Пользователь смотрит на крутящийся спиннер 7 секунд и половина таких уходит. Очевидное решение — «сделать всё в фоне» — легко ломается: задачи теряются при рестарте, воркер падает на первом же исключении, письмо уходит дважды. Разберём, как этого избежать в PHP-проекте.
Речь про Laravel Queue, Symfony Messenger и связку с Redis или брокером. Материал рассчитан на разработчика, который уже писал синхронный код и упёрся в его пределы. Мониторинг очередей и sharding по нескольким брокерам — отдельная тема, здесь только базовые рабочие приёмы вынесения задач в фон.
Почему прямой вызов в обработчике — не вариант
PHP по своей природе синхронен: пока не отработает mail() или HTTP-клиент, ответ клиенту не уйдёт. Долгие операции в цикле запрос-ответ дают три конкретные проблемы:
- Время ответа. Каждая внешняя интеграция добавляет свои секунды напрямую в TTFB.
- Хрупкость. Упал SMTP — упал весь заказ, хотя товар уже списан.
- Нет ретраев. Временная ошибка API превращается в потерянное действие без шанса повторить.
Фоновая задача разрывает эту связку: HTTP-запрос кладёт джоб в очередь и сразу отвечает, а отдельный процесс-воркер разбирает очередь в своём темпе.
Три способа сделать фон в PHP
1. Быстрый и грязный: register_shutdown_function и fastcgi_finish_request
Если у вас PHP-FPM, есть функция fastcgi_finish_request() — она завершает отдачу ответа клиенту, но процесс продолжает работать. Дальше можно выполнить долгую задачу «после ответа».
<?php
// PHP 8.x + PHP-FPM
// Отдаём ответ клиенту прямо сейчас
echo json_encode(['status' => 'accepted']);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// Клиент уже получил ответ, а мы продолжаем работу
sendOrderConfirmationEmail($orderId);
Плюс — ноль инфраструктуры. Минусы жёсткие: задача выполняется в том же процессе FPM (занимает воркер пула), нет ретраев, при рестарте FPM или таймауте max_execution_time задача теряется. Годится для «отправить одно письмо и забыть», не годится ни для чего, что нельзя терять.
2. Laravel Queue
Стандарт для Laravel-проектов. Джоб — это класс, драйвер очереди — Redis, база или Amazon SQS. Диспетч кладёт задачу, воркер queue:work её разбирает.
<?php
// Laravel 11.x
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendOrderConfirmation implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// Сколько раз пытаться перед провалом
public int $tries = 3;
// Таймаут выполнения одного джоба, секунды
public int $timeout = 30;
public function __construct(public int $orderId) {}
public function handle(): void
{
$order = \App\Models\Order::findOrFail($this->orderId);
// отправка письма, генерация PDF и т.д.
}
}
Диспетч в контроллере: SendOrderConfirmation::dispatch($order->id);. Обратите внимание — в конструктор передаём id, а не объект модели. Почему — ниже, это одна из главных ловушек.
3. Symfony Messenger
Для Symfony-проектов и фреймворко-независимого кода. Сообщение — простой DTO, обработчик отдельно, транспорт настраивается в конфиге. Разбирает очередь команда messenger:consume.
<?php
// Symfony 7.x, Messenger component
namespace App\Message;
final class SendOrderConfirmation
{
public function __construct(
public readonly int $orderId,
) {}
}
<?php
namespace App\MessageHandler;
use App\Message\SendOrderConfirmation;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class SendOrderConfirmationHandler
{
public function __invoke(SendOrderConfirmation $message): void
{
// достаём заказ по $message->orderId и обрабатываем
}
}
Отправка: инжектите MessageBusInterface и вызываете $bus->dispatch(new SendOrderConfirmation($orderId));.
Сравнение подходов
| Критерий | fastcgi_finish_request | Laravel Queue | Symfony Messenger |
|---|---|---|---|
| Инфраструктура | Не нужна (только FPM) | Redis/БД/SQS + воркер | Брокер + consumer |
| Ретраи | Нет | Есть ($tries, backoff) | Есть (retry_strategy) |
| Переживает рестарт | Нет | Да (в Redis/БД) | Да (в брокере) |
| Отложенный запуск | Нет | Есть (delay) | Есть (DelayStamp) |
| Порог входа | Минимальный | Низкий в Laravel | Средний |
Когда что выбирать
- fastcgi_finish_request — единичные некритичные задачи «после ответа», когда разворачивать очередь избыточно и потеря задачи не страшна.
- Laravel Queue — вы уже на Laravel. Ничего лучше искать не нужно, всё встроено.
- Symfony Messenger — Symfony или каркасно-независимый код, нужен контроль над транспортами и middleware.
Грабли, на которые наступают все
Передача объекта модели вместо id
В Laravel трейт SerializesModels сериализует модель в очередь. Когда воркер возьмёт джоб через 10 минут, он восстановит модель из БД — но если строку уже удалили, будет ModelNotFoundException. Передавайте id и подгружайте свежую запись в handle(). Если строки нет — это нормальная ситуация, обработайте её, а не роняйте джоб.
Дубликаты при ретраях
Воркер упал после отправки письма, но до пометки джоба выполненным. При ретрае письмо уйдёт второй раз. Единственная надёжная защита — идемпотентность1: перед действием проверяйте, не сделано ли оно уже. Например, отметка confirmation_sent_at в заказе внутри транзакции.
Очередь гарантирует «хотя бы одну доставку», а не «ровно одну». Считайте, что любой джоб может выполниться дважды, и стройте обработчик так, чтобы повтор не навредил.
Воркер не видит новый код
Воркер — долгоживущий процесс, он держит код в памяти. После деплоя старый воркер продолжает крутить старую версию джобов. После каждого деплоя перезапускайте воркеры: в Laravel — php artisan queue:restart (он мягко останавливает воркеры после текущего джоба), в Symfony — messenger:stop-workers.
Воркер как cron вместо демона
Запускать queue:work руками — путь к простою: процесс упадёт и очередь встанет. Держите воркер под супервизором (Supervisor или systemd), чтобы он перезапускался автоматически. Пример юнита systemd:
# /etc/systemd/system/laravel-worker.service
[Unit]
Description=Laravel queue worker
After=network.target
[Service]
User=www-data
Restart=always
ExecStart=/usr/bin/php /var/www/app/artisan queue:work --sleep=3 --tries=3 --max-time=3600
[Install]
WantedBy=multi-user.target
Флаг --max-time=3600 заставляет воркер завершиться через час, чтобы systemd поднял свежий процесс — это лечит утечки памяти в долгоживущих PHP-процессах.
Что делать дальше
После того как базовая очередь заработала, займитесь тремя вещами. Настройте failed jobs: в Laravel провалившиеся джобы уходят в таблицу failed_jobs, их видно через queue:failed и можно повторить командой queue:retry. Разведите задачи по именованным очередям с приоритетами, чтобы отправка письма не блокировала генерацию тяжёлого отчёта. И повесьте мониторинг на длину очереди и возраст самого старого джоба — растущая очередь значит, что воркеров не хватает. Точные имена команд и опции сверяйте с документацией вашей версии фреймворка, они менялись между мажорными релизами.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Laravel Queues — официальная документация, Symfony Messenger Component
Частые вопросы
А если у меня не PHP-FPM, а PHP через CLI или mod_php?
Письмо ушло дважды после того, как воркер перезапустился. Почему?
Джоб выполняется на старой версии кода после деплоя.
Стоит ли передавать в джоб объект модели Eloquent?
Redis или база данных в качестве драйвера очереди?
Что произойдёт с джобами, если воркер упадёт и не поднимется?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.