Очереди на PHP: чек-лист перед выкатом в продакшен
Воркер молча падает, задачи дублируются после деплоя, а Redis пухнет на глазах. Разбираем, что проверить в очередях на PHP до того, как это случится на проде.

Очередь на PHP выглядит просто, пока не попадёт под нагрузку. Задача улетает в Redis или базу, воркер её забирает, всё работает на стейдже. А потом в проде воркер падает от нехватки памяти после двадцати тысяч задач, письмо клиенту уходит трижды, а после деплоя половина воркеров держит старый код и ломает данные. Это не редкие случаи — это то, что происходит с очередями по умолчанию, если не настроить их специально.
Ниже — чек-лист для тех, кто уже написал воркеры и собирается выкатывать. Речь про Laravel Queue (10.x/11.x), Symfony Messenger (6.x/7.x) и связку с Redis, базой или Beanstalkd. Формат — разбор проблем, а не пошаговая установка: предполагаю, что job у вас уже отправляется и обрабатывается локально.
Идемпотентность: задача выполнится больше одного раза
Главное допущение, которое ломает продакшен: «задача выполнится ровно один раз». Не выполнится. Любая очередь с гарантией at-least-once (а это почти все дефолтные конфигурации) может доставить задачу повторно — если воркер упал после обработки, но до подтверждения (ack), если сработал retry по таймауту, если задачу вручную перезапустили.
Практическое следствие: обработчик, который списывает деньги, отправляет письмо или создаёт заказ, должен быть идемпотентным. То есть повторный запуск с теми же данными не должен приводить к двойному эффекту.
Очередь не гарантирует «один раз». Она гарантирует «хотя бы один раз». Разница между этими двумя фразами — это ваши двойные списания и тройные письма.
Простой рабочий приём — ключ идемпотентности. Перед побочным эффектом проверяете и фиксируете уникальный идентификатор операции:
<?php
// Laravel 11.x, обработчик внутри job
public function handle(): void
{
// $this->operationId генерируется при создании задачи, а не внутри handle
$lockKey = 'processed:' . $this->operationId;
// add() атомарно ставит ключ, только если его ещё нет
$isFirstRun = Cache::add($lockKey, 1, now()->addHours(24));
if (! $isFirstRun) {
// задача уже была обработана — тихо выходим
return;
}
// здесь безопасно делать побочный эффект
$this->chargeCustomer();
}Важно: operationId должен приходить извне и быть постоянным для одной логической операции. Если сгенерировать его внутри handle, при повторе он будет новым и защита не сработает.
Утечка памяти: воркер — это долгоживущий процесс
PHP-скрипт из веб-запроса живёт миллисекунды и умирает, забирая с собой всю память. Воркер очереди живёт часами и обрабатывает тысячи задач в одном процессе. Всё, что вы привыкли не чистить, потому что «скрипт всё равно завершится», здесь накапливается.
Типичные источники роста памяти в воркере:
- статические кеши моделей и справочников, которые растут с каждой задачей;
- подключённые, но не закрытые соединения к внешним сервисам;
- в Laravel — накопление событий и загруженных связей, если не сбрасывать состояние.
Лечение — не бороться с утечками, а перезапускать воркер по лимиту. У Laravel это флаг --max-jobs и --memory, у Symfony Messenger — --limit и --memory-limit. Процесс сам завершается, а супервизор поднимает новый с чистой памятью.
# Laravel 11.x — воркер завершится после 1000 задач
# или при достижении 128 МБ памяти
php artisan queue:work redis --max-jobs=1000 --memory=128
# Symfony Messenger 7.x — аналогично
php bin/console messenger:consume async --limit=1000 --memory-limit=128MСмысл не в «правильном» числе, а в том, что процесс регулярно перерождается. Точную цифру подбирайте по замерам: снимите потребление памяти воркером через 100, 1000, 5000 задач и поставьте лимит с запасом ниже реального обвала.
Деплой: воркер держит старый код
Эта проблема бьёт по всем и всегда — потому что она невидима. Воркер загружает код в память при старте. Вы выкатили новую версию, файлы на диске обновились, но запущенный процесс продолжает крутить старую логику. Задача, поставленная новым кодом, может обрабатываться старым обработчиком — со всеми несовпадениями сигнатур и полей.
Правило: после каждого деплоя воркеры нужно перезапускать. В Laravel есть аккуратный способ — php artisan queue:restart. Команда не убивает процессы мгновенно: она ставит флаг, и воркеры сами завершаются после текущей задачи, а супервизор их поднимает. В Symfony Messenger аналог — messenger:stop-workers.
Порядок в скрипте деплоя
- Выложить новый код.
- Прогнать миграции базы.
- Дать сигнал воркерам на graceful-перезапуск (
queue:restart). - Убедиться, что супервизор поднял новые процессы.
Если пропустить шаг 3 — воркеры будут работать со старым кодом до следующего ручного рестарта или падения. Проверить, что перезапуск случился, можно по времени старта процессов в supervisorctl status.
Кто следит за воркером
Запустить queue:work в терминале и закрыть SSH — не деплой. Процесс умрёт вместе с сессией. Воркер должен работать под супервизором, который поднимает его после падения и завершения по лимиту.
| Инструмент | Когда подходит | Чем платишь |
|---|---|---|
| Supervisor | Классика для PHP-воркеров, много процессов на одну команду через numprocs | Отдельный конфиг, следить за логами вручную |
| systemd | Уже есть на сервере, знаком администратору | Масштабирование числа процессов менее удобно, чем у Supervisor |
| Docker + оркестратор | Контейнерная инфраструктура, автоскейл воркеров | Сложнее graceful-shutdown, нужен правильный обработчик SIGTERM |
Минимальный конфиг Supervisor для Laravel выглядит так:
[program:app-worker]
command=php /var/www/app/artisan queue:work redis --max-jobs=1000 --memory=128 --tries=3
numprocs=4
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
stopwaitsecs=60
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/app-worker.logПараметр stopwaitsecs критичен: он даёт воркеру доделать текущую задачу перед убийством. Если поставить его меньше, чем самая долгая задача, Supervisor прибьёт процесс на полпути — и вы получите ту самую полуобработанную операцию.
Отказавшие задачи: куда они деваются
Задача упала три раза подряд — что дальше? Если не настроить хранилище провалившихся задач (failed jobs), она либо потеряется молча, либо будет вечно крутиться в очереди, забивая воркеры. Ни то, ни другое вам не нужно.
В Laravel провалившиеся задачи по умолчанию пишутся в таблицу failed_jobs — убедитесь, что миграция для неё выполнена. Пересмотреть и перезапустить их можно вручную: php artisan queue:failed покажет список, queue:retry вернёт в работу. Но список нужно кому-то читать — поставьте алерт на рост числа failed jobs, иначе узнаете о проблеме от клиента.
Отдельно проверьте --tries и --backoff. Задача, которая падает из-за недоступного внешнего API, при retry без задержки просто выжжет попытки за секунду. Backoff с растущей паузой даёт сервису шанс восстановиться.
Долгие задачи и таймауты
У воркера есть таймаут на задачу (--timeout, по умолчанию 60 секунд в Laravel). Если задача идёт дольше, воркер её прервёт. Но если таймаут воркера больше, чем время видимости сообщения в брокере (visibility timeout), брокер решит, что задача потеряна, и выдаст её второму воркеру. Получите параллельную обработку одной задачи.
Правило: таймаут воркера должен быть меньше времени, через которое брокер возвращает невзятую задачу в очередь. Для тяжёлых задач — дробите их на мелкие или выносите в отдельную очередь с отдельными воркерами и своими лимитами, чтобы долгие задачи не блокировали быстрые.
Что проверить перед выкатом — сводный список
- Обработчики с побочными эффектами идемпотентны, ключ идемпотентности приходит извне.
- Воркеры перезапускаются по лимиту задач и памяти.
- В скрипте деплоя есть graceful-перезапуск воркеров после миграций.
- Воркеры работают под супервизором с autorestart и достаточным stopwaitsecs.
- Настроено хранилище failed jobs и алерт на его рост.
- Настроены tries и backoff с растущей паузой.
- Таймаут воркера меньше visibility timeout брокера.
- Тяжёлые задачи вынесены в отдельную очередь.
Что делать дальше
После выката смотрите на три показателя в проде: глубину очереди (сколько задач ждёт обработки — растёт значит воркеров мало), число failed jobs за период и время жизни воркер-процессов (частые рестарты раньше лимита — признак падений). Для Laravel эти метрики удобно снимать через Horizon, который отдаёт их из коробки и даёт дашборд по очередям. Инвалидация и приоритеты очередей — отдельная тема, здесь мы разобрали только надёжность доставки и живучесть воркеров.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Laravel Queues — официальная документация, Symfony Messenger Component
Частые вопросы
У меня Laravel 10, а не 11 — чек-лист подойдёт?
Воркер запустился, но задачи не берёт — что проверить?
Обязательно ли делать все задачи идемпотентными?
Что будет, если я забуду перезапускать воркеры при деплое?
Насколько велика опасность двойной обработки на практике?
Redis или база данных в роли брокера очереди?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.