Что такое очередь задач

Очередь задач — это способ вынести работу из основного HTTP-запроса в отдельный процесс. Пользователь нажал «оплатить», «экспортировать CSV» или «отправить письмо»; web-приложение быстро сохраняет намерение и кладёт job в очередь, а worker забирает её позже и выполняет.

Это не то же самое, что async HTTP из Асинхронный PHP и event loop. Event loop помогает одному процессу не простаивать на I/O. Очередь меняет архитектуру: работа становится отложенной, повторяемой и наблюдаемой. Пользовательский request уже завершился, а job может выполниться через секунду, через минуту или после ручного retry.

Типовая схема выглядит так:

flowchart LR A[HTTP request] --> B[Приложение сохраняет состояние] B --> C[Dispatch job] C --> D[(Queue backend)] D --> E[Worker] E --> F{Успешно?} F -- да --> G[Ack / удалить из очереди] F -- временная ошибка --> H[Retry с backoff] H --> D F -- попытки исчерпаны --> I[(Dead-letter / failed jobs)] J[Supervisor] -. следит и перезапускает .-> E
flowchart LR
    A[HTTP request] --> B[Приложение сохраняет состояние]
    B --> C[Dispatch job]
    C --> D[(Queue backend)]
    D --> E[Worker]
    E --> F{Успешно?}
    F -- да --> G[Ack / удалить из очереди]
    F -- временная ошибка --> H[Retry с backoff]
    H --> D
    F -- попытки исчерпаны --> I[(Dead-letter / failed jobs)]
    J[Supervisor] -. следит и перезапускает .-> E
Жизненный цикл фоновой job: dispatch, обработка worker-процессом, retry, backoff и dead-letter queue.

В PHP такую модель дают фреймворки и библиотеки: очереди в Laravel, Symfony Messenger, отдельные consumer-процессы на Redis, RabbitMQ, Beanstalkd, SQS или базе данных. Важна не конкретная технология, а контракт: producer создаёт сообщение, backend хранит его, worker обрабатывает, а supervisor следит, чтобы worker не умер навсегда.

Быстрое повторение
Почему очередь задач не то же самое, что async HTTP или event loop в PHP?

Job, message и worker

В разговорной PHP-практике часто говорят «job», «message» и «task» почти взаимозаменяемо. Но полезно различать:

  • Job — единица работы: «сгенерировать PDF для заказа 4821».
  • Message — данные, которые передаются через очередь: order_id, тип операции, metadata.
  • Handler — код, который умеет обработать message.
  • Worker / consumer — долгоживущий процесс, который читает очередь и вызывает handler.

Пример job лучше делать маленьким и ссылочным: передавать идентификаторы, а не целые ORM-объекты, request, user session или загруженный файл в памяти.

<?php

final class SendOrderReceipt
{
    public function __construct(
        public readonly int $orderId,
        public readonly string $idempotencyKey,
    ) {}
}

final class SendOrderReceiptHandler
{
    public function __construct(
        private OrderRepository $orders,
        private Mailer $mailer,
        private ProcessedJobRepository $processedJobs,
    ) {}

    public function __invoke(SendOrderReceipt $job): void
    {
        if ($this->processedJobs->exists($job->idempotencyKey)) {
            return;
        }

        $order = $this->orders->get($job->orderId);
        $this->mailer->sendReceipt($order->customerEmail, $order);
        $this->processedJobs->markDone($job->idempotencyKey);
    }
}

Ключевая деталь — idempotencyKey. Worker может упасть после отправки письма, но до подтверждения queue backend. Тогда job может прийти повторно. Большинство практических очередей надо проектировать как at-least-once delivery: сообщение не потеряется легко, но может быть обработано больше одного раза. Поэтому handler должен спокойно переживать повтор.

Быстрое повторение
Почему в message для job обычно передают `order_id`, а не целый ORM-объект, request или user session?

Retry, backoff и ошибки

Retry нужен для временных сбоев: SMTP недоступен, API платежей вернул 503, база на секунду ушла в failover. Но retry опасен, если повторять всё подряд без различения причин.

Связь с Ошибки, исключения и Throwable здесь прямая: не каждое исключение означает «попробуй ещё раз». Ошибка валидации, отсутствующий заказ или нарушенный invariant обычно не исправятся через пять секунд. Сетевой таймаут, rate limit или временная ошибка внешнего сервиса — хорошие кандидаты на retry.

Пример политики:

<?php

final class RetryPolicy
{
    public function shouldRetry(Throwable $e): bool
    {
        return $e instanceof TimeoutException
            || $e instanceof TemporaryProviderException
            || $e instanceof RateLimitException;
    }

    public function delaySeconds(int $attempt): int
    {
        return match ($attempt) {
            1 => 10,
            2 => 60,
            3 => 300,
            default => 900,
        };
    }
}

Backoff — это задержка перед следующей попыткой. Без backoff упавший внешний API можно добить лавиной повторов. В production часто используют exponential backoff и jitter: задержка растёт, а небольшая случайность разводит повторные попытки по времени.

Быстрое повторение
Какие ошибки в job стоит ретраить, а какие обычно не исправятся повтором через несколько секунд?

Dead-letter queue: место для честного отказа

Если job падает после всех попыток, её нельзя просто забыть. Dead-letter queue, failure transport или failed jobs table — это место, куда попадают сообщения, которые система не смогла обработать автоматически.

DLQ нужна для трёх задач: сохранить payload для расследования, дать оператору возможность retry после фикса бага, поднять alert. В Laravel для этого есть failed jobs и команды retry; в Symfony Messenger — failure transport; в RabbitMQ — dead letter exchange, куда сообщения могут попадать после reject, истечения TTL, превышения лимита длины очереди или delivery limit.

Важно: DLQ — не мусорка. Если failed queue растёт молча, это уже инцидент. Для неё нужны метрики, алерты и понятный runbook: кто смотрит, как отличить временную проблему от бага, когда можно повторять, а когда надо писать миграцию данных.

Delayed jobs и планирование

Delayed job — задача, которую нельзя выполнять сразу. Например: «отправить напоминание через 24 часа», «повторить webhook через 10 минут», «удалить временный файл завтра ночью».

Delayed job не заменяет cron полностью. Cron хорошо запускает регулярную работу: «каждый день в 03:00 пересчитать отчёты». Очередь хороша для множества индивидуальных задач с разным временем выполнения. Часто они работают вместе: cron находит кандидатов, dispatch-ит jobs, а workers параллельно обрабатывают их.

Нельзя полагаться на delayed job как на точный таймер до миллисекунды. Queue backend, нагрузка, число workers, visibility timeout и deploy могут сдвинуть выполнение. Для бизнес-логики формулировка должна быть «не раньше такого-то времени», а не «ровно в 12:00:00».

Supervisor и жизненный цикл воркеров

Worker — это обычный долгоживущий процесс. Он может упасть из-за fatal error, утечки памяти, OOM killer, деплоя или проблем с сетью. Поэтому его запускают под process supervisor: Supervisor, systemd, Kubernetes, Docker restart policy или встроенный менеджер платформы.

Минимальная идея конфигурации такая:

[program:php-worker]
command=php /app/bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
numprocs=4
autostart=true
autorestart=true
stopwaitsecs=30
stdout_logfile=/var/log/app/worker.log
stderr_logfile=/var/log/app/worker-error.log

Лимиты здесь не декоративные. Worker надо периодически перезапускать по времени, памяти или числу jobs, особенно если приложение использует ORM, большие массивы, кеши в статических свойствах или интеграции со сторонними SDK. Это тот же класс риска, что и в PHP-FPM, RoadRunner и долгоживущие воркеры: состояние процесса живёт дольше одной операции.

После деплоя workers тоже нужно перезапускать. Иначе HTTP-код уже обновился, а старый worker продолжает выполнять старую версию handler-класса или держит старый контейнер приложения.

Отличие фоновых задач от async HTTP

Фоновая задача не делает код «быстрее» сама по себе. Она переносит ожидание из пользовательского request в worker pool. Если отправка письма занимает две секунды, пользовательский ответ станет быстрее, но письмо всё равно займёт две секунды где-то в системе.

Async HTTP нужен, когда один request должен параллельно ждать несколько I/O-операций и вернуть результат сейчас. Очередь нужна, когда результат можно получить позже, повторить безопасно и обработать отдельно от пользовательского соединения. Realtime через WebSocket, SSE и realtime в PHP может быть третьей частью схемы: request ставит job, worker выполняет, приложение пушит статус в браузер.

Хороший вопрос перед добавлением очереди: «Что увидит пользователь, если job выполнится через минуту или упадёт после всех retry?» Если ответа нет, очередь только прячет неопределённость. Нужны статусы, идемпотентность, retry policy, DLQ и наблюдаемость.

См. также

Источники

  1. Laravel Queues documentation
  2. Symfony Messenger documentation
  3. RabbitMQ Dead Letter Exchanges documentation
  4. Supervisor documentation