Очереди в Yii2: типичные ошибки и как их избежать
Джобы застревают, воркеры едят память, а после деплоя из очереди сыплются фатальные ошибки. Разбираем частые промахи при работе с yii2-queue и как их не допустить.

Джоба ушла в очередь, воркер её взял, и через минуту в логах — Class not found. Или воркер после недели работы съедает 900 МБ памяти и падает по OOM. Или письмо пользователю уходит трижды, потому что джоба «упала» уже после отправки, но до подтверждения. Все три сценария — не баги фреймворка, а типичные ошибки в том, как настроена работа с расширением yii2-queue. Разберём, где именно ломается, и как настроить так, чтобы не ловить это в проде.
Материал по Yii 2.0.x и расширению yiisoft/yii2-queue 2.3.x (актуальную версию сверяйте в Packagist). Драйверы упоминаются разные — Redis, DB, RabbitMQ (AMQP), — но большинство ошибок общие для всех.
Ошибка 1. Считать, что джоба выполнится ровно один раз
Самое частое заблуждение. yii2-queue гарантирует доставку не менее одного раза (at-least-once), а не ровно один раз. Если воркер взял джобу, выполнил её, но упал до того, как отправил брокеру подтверждение (ack), джоба вернётся в очередь и выполнится снова.
Практический вывод: любая джоба должна быть идемпотентной. Отправка письма, списание денег, создание заказа — всё, что нельзя выполнить дважды, требует защиты.
Не спрашивайте «как сделать так, чтобы джоба не повторилась». Спрашивайте «что произойдёт, если она повторится» — и проектируйте под повтор.
Простой приём — маркер выполнения. Перед действием проверяете, не сделано ли оно уже, и фиксируете факт в БД в той же транзакции, что и само действие.
<?php
// Yii 2.0.x, yii2-queue 2.3.x
class ChargeUserJob extends \yii\base\BaseObject implements \yii\queue\JobInterface
{
public $orderId;
public function execute($queue)
{
$transaction = \Yii::$app->db->beginTransaction();
try {
// Блокируем строку заказа, чтобы параллельный повтор ждал
$order = Order::find()
->where(['id' => $this->orderId])
->forUpdate()
->one();
if ($order->status === Order::STATUS_CHARGED) {
// Уже списано в прошлый раз — просто выходим
$transaction->commit();
return;
}
$this->chargePayment($order);
$order->status = Order::STATUS_CHARGED;
$order->save(false);
$transaction->commit();
} catch (\Throwable $e) {
$transaction->rollBack();
throw $e; // пусть очередь решит, повторять ли
}
}
}Как понять, что вы в зоне риска
Если хоть одна джоба делает что-то, что видно снаружи (письмо, платёж, вебхук, запись во внешнюю систему) и не проверяет, не сделано ли это уже — вы получите двойные срабатывания. Вопрос не «если», а «когда».
Ошибка 2. Не настроить лимит попыток и ttr
По умолчанию джоба без явного ttr (time to reserve — сколько секунд брокер держит джобу зарезервированной за воркером) и без ограничения попыток может уйти в бесконечный цикл: упала, вернулась, снова упала. Особенно больно с внешними API, которые временно недоступны.
Два параметра, которые надо задать осознанно для каждой тяжёлой джобы:
- ttr — время, за которое джоба обязана завершиться. Если воркер не успел, брокер считает джобу «зависшей» и возвращает её в очередь. Слишком маленький ttr на долгой джобе = та самая двойная отправка.
- attempts — сколько раз пытаться, прежде чем признать джобу проваленной.
<?php
class SlowReportJob extends \yii\base\BaseObject implements
\yii\queue\JobInterface,
\yii\queue\RetryableJobInterface
{
public function execute($queue) { /* ... */ }
// Даём джобе до 5 минут на выполнение
public function getTtr()
{
return 5 * 60;
}
// Пытаемся не больше 3 раз в течение 10 минут
public function canRetry($attempt, $error)
{
return ($attempt < 3)
&& ($error instanceof \yii\httpclient\Exception);
}
}Обратите внимание: в canRetry имеет смысл повторять только временные ошибки (сеть, таймаут внешнего API). Ошибку валидации данных повторять бессмысленно — она провалится и на десятой попытке, только зря нагрузит очередь.
Ошибка 3. Долгоживущий воркер и утечки памяти
Воркер, запущенный командой yii queue/listen, — это долгоживущий PHP-процесс. PHP не задумывался под такой режим: ORM держит идентити-мапу, логгер копит записи, статические кэши растут. За несколько тысяч джоб процесс распухает.
Классический симптом: воркер стартует с 60 МБ, через сутки занимает 800 МБ, потом падает по лимиту памяти или его убивает OOM-killer. Джоба, которую он держал в этот момент, повторяется (см. ошибку 1).
Что делать
- Ограничьте число джоб на процесс через
--isolate— каждая джоба выполняется в отдельном дочернем процессе, память освобождается после каждой. Ценой небольшого оверхеда на форк. - Или перезапускайте воркер по расписанию/по числу задач, если
--isolateвам дорог по производительности. - Держите воркер под супервизором (systemd или supervisord), который поднимет процесс после штатного выхода.
# systemd unit: перезапуск при выходе, изоляция каждой джобы
[Service]
User=www-data
ExecStart=/usr/bin/php /var/www/app/yii queue/listen --isolate=1 --verbose=1
Restart=always
RestartSec=3Флаг --isolate включён по умолчанию в актуальных версиях расширения — проверьте своё значение, если наблюдаете рост памяти при --isolate=0.
Ошибка 4. Забыть про воркеры при деплое
Вы выкатили новый код, а воркер продолжает крутить старый — он же долгоживущий процесс, загруженные классы в память уже прочитаны. В результате свежая джоба из очереди попадает в старый обработчик. Или наоборот: старая джоба, сериализованная до деплоя, десериализуется новым кодом, у которого поменялась структура класса — и вы ловите ошибки десериализации.
| Ситуация | Что происходит без перезапуска | Решение |
|---|---|---|
| Обновили логику джобы | Воркер выполняет старый код | Перезапустить воркеры после деплоя |
| Изменили свойства класса джобы | Ошибка десериализации старых джоб | Версионировать джобы, не удалять свойства резко |
| Долгий деплой с миграцией БД | Джоба обращается к ещё не созданной колонке | Приостановить воркеры на время миграции |
В пайплайн деплоя добавьте перезапуск воркеров последним шагом — после того как код и миграции применены. Через systemd это systemctl restart для юнита воркера.
Ошибка 5. Одна очередь на всё
Когда рассылка на 50 000 писем и «отправить пуш пользователю прямо сейчас» лежат в одной очереди, срочная джоба ждёт, пока разгребётся рассылка. Пользователь видит задержку в минуты вместо секунд.
Разделяйте очереди по приоритету и характеру нагрузки. В config можно объявить несколько компонентов очереди с разными именами:
queue— быстрые интерактивные задачи (уведомления, инвалидация кэша);queueBulk— массовые фоновые задачи (рассылки, отчёты, импорты).
Под каждую очередь запускается свой пул воркеров. Тогда медленная рассылка не блокирует срочные джобы, а масштабировать пулы можно независимо.
Ошибка 6. Игнорировать провалившиеся джобы
Джоба исчерпала попытки и провалилась. Куда она делась? Если не подписаться на событие Queue::EVENT_AFTER_ERROR, вы об этом просто не узнаете — джоба тихо пропадёт (в зависимости от драйвера), а задача останется невыполненной.
<?php
// config/console.php или web.php
'queue' => [
'class' => \yii\queue\redis\Queue::class,
'on afterError' => function (\yii\queue\ExecEvent $event) {
\Yii::error([
'job' => get_class($event->job),
'error' => $event->error->getMessage(),
'attempt' => $event->attempt,
], 'queue');
// здесь же — алерт в Sentry/телеграм
},
],Минимум — логировать провалы в отдельную категорию и слать алерт. Для критичных задач держите «мёртвую» очередь (dead letter) — брокер вроде RabbitMQ поддерживает это на уровне обмена сообщениями.
Что настроить дальше
Когда очереди работают стабильно, займитесь наблюдаемостью. Смотрите на три показателя в проде:
- глубина очереди — сколько джоб ждёт обработки; растёт постоянно — воркеров не хватает;
- возраст самой старой джобы — задержка от постановки до выполнения;
- доля провалов — процент джоб, ушедших в afterError.
Драйвер Redis умеет отдавать размер очереди через Queue::getStatus() по ID джобы; для агрегированных метрик проще снимать длину списка Redis напрямую. Инвалидация и приоритизация внутри одной очереди — отдельная тема, здесь мы её не разворачивали. Следующий шаг после стабилизации — прогнать нагрузочный тест с реальным профилем джоб и посмотреть, где раньше упрётся: в воркеры, в БД или в брокер.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: Yii2 Queue Extension — официальная документация (GitHub), yiisoft/yii2-queue на Packagist
Частые вопросы
У меня yii2-queue версии 2.1, а не 2.3 — приёмы подойдут?
Как гарантировать, что джоба выполнится ровно один раз?
Джоба падает с Class not found, хотя класс есть — в чём дело?
Что будет, если воркер упадёт посреди выполнения джобы?
Воркер медленно, но верно растёт по памяти — это нормально?
Стоит ли запускать queue/run или queue/listen?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.