Что такое очередь задач
Очередь задач — это способ вынести работу из основного 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В PHP такую модель дают фреймворки и библиотеки: очереди в Laravel, Symfony Messenger, отдельные consumer-процессы на Redis, RabbitMQ, Beanstalkd, SQS или базе данных. Важна не конкретная технология, а контракт: producer создаёт сообщение, backend хранит его, worker обрабатывает, а supervisor следит, чтобы worker не умер навсегда.
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 должен спокойно переживать повтор.
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: задержка растёт, а небольшая случайность разводит повторные попытки по времени.
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 и наблюдаемость.
См. также
- Ошибки, исключения и Throwable — как отличать исключения, которые можно повторять, от программных ошибок.
- Laravel — практическая реализация queues, failed jobs, retries и worker-команд.
- Асинхронный PHP и event loop — отличие event loop от фоновой обработки через queue backend.
- PHP-FPM, RoadRunner и долгоживущие воркеры — lifecycle, stale state, memory limits и graceful reload.
- Swoole и OpenSwoole — долгоживущий runtime, где фоновые workers и coroutine-подход часто соседствуют.
- Транзакции и режимы ошибок PDO — полезно для outbox-паттерна и атомарной записи бизнес-событий перед dispatch job.
- Наблюдаемость и эксплуатация PHP-сервисов — метрики queue depth, latency, failed jobs, worker restarts и slow jobs.