Консольные команды в Yii2: разбор на живом проекте
Консольная команда, которая тихо падала на проде каждую ночь, показала три типичные ошибки в работе с Yii2 console: незакрытые транзакции, утечку памяти в цикле и неверные коды возврата.

Ночной cron-скрипт на Yii2 пересчитывал остатки товаров и рассылал уведомления поставщикам. Раз в неделю он падал: процесс висел, память росла до лимита PHP, supervisor его убивал, а в логах оставалась одна строка про Allowed memory size exhausted. При этом ручной запуск на 100 позициях отрабатывал за секунду. Проблема вылезала только на реальном объёме — 40 тысяч строк за проход. Разберём эту команду по частям: где именно она ломалась, почему консольный контекст Yii ведёт себя не так, как контроллер в вебе, и что в подобном коде смотреть на ревью в первую очередь.
Речь про Yii2 (2.0.x). В Yii1 консоль устроена иначе — там CConsoleCommand вместо контроллеров, и почти ничего из сказанного ниже к нему не относится.
Как консольная команда живёт в Yii2
Консольная команда в Yii2 — это обычный контроллер, унаследованный от yii\console\Controller, а действие — публичный метод с префиксом action. Аргументы метода становятся аргументами командной строки, а публичные свойства контроллера, объявленные в options(), — опциями через двойной дефис.
<?php
namespace app\commands;
use yii\console\Controller;
use yii\console\ExitCode;
class StockController extends Controller
{
// товар с остатком ниже этого порога считается дефицитным
public $threshold = 5;
public function options($actionID)
{
return ['threshold'];
}
// вызов: php yii stock/recount --threshold=10
public function actionRecount($storeId)
{
// ...
return ExitCode::OK;
}
}
Вызывается это как php yii stock/recount 42 --threshold=10, где 42 — обязательный аргумент $storeId, а --threshold — опция. Ключевой момент, который часто упускают: команда должна возвращать код завершения. Не строку, не echo, а число — константу из yii\console\ExitCode или сам int. В нашей падавшей команде метод не возвращал ничего, и об этом ниже.
Ошибка первая: код возврата, который никто не проверял
Метод заканчивался так:
public function actionRecount($storeId)
{
// расчёт...
$this->stdout("Done\n");
// return нет
}
PHP-метод без return отдаёт null, Yii приводит его к коду выхода 0. Для операционной системы это успех. А значит, cron и supervisor считали упавший процесс успешно завершённым и не поднимали тревогу — падение всплыло только когда стали расти остатки на складе.
Консольная команда без явного кода возврата — это команда, которая врёт мониторингу. Ноль означает «всё хорошо», и его нельзя отдавать по умолчанию.
Правильно — возвращать ExitCode::OK при успехе и любой ненулевой код при ошибке. Стандартные значения лежат в yii\console\ExitCode: OK (0), UNSPECIFIED_ERROR (1) и коды по соглашению BSD sysexits. Тогда if php yii stock/recount 42; then ... в bash-обёртке начинает работать.
Ошибка вторая: память, которая не отдавалась
Основной цикл выбирал все товары разом и складывал их в массив:
$products = Product::find()->where(['store_id' => $storeId])->all();
foreach ($products as $product) {
$product->stock = $this->calc($product);
$product->save();
}
На 100 записях это незаметно, на 40 тысячах — все объекты ActiveRecord висят в памяти одновременно, плюс каждый save() тянет валидацию и события. Отсюда исчерпание лимита.
Для консольных пакетных операций в Yii2 есть batch() и each() — они читают данные из БД порциями, а не одним запросом.
// читаем и обрабатываем по 500 строк за раз
foreach (Product::find()
->where(['store_id' => $storeId])
->each(500) as $product) {
$product->stock = $this->calc($product);
$product->save(false); // false — пропустить валидацию, данные уже наши
}
each() под капотом использует серверный курсор через batch() и отдаёт объекты по одному, подгружая следующую пачку по мере необходимости. Память перестаёт линейно расти вместе с числом строк.
Но этого мало
Даже с each() есть второй пожиратель памяти — лог и буфер запросов. Если в приложении включён enableProfiling или подробный лог SQL, компонент базы данных копит записи в памяти до момента flush. В долгой команде это растёт бесконтрольно. В консольной конфигурации лог профилирования обычно стоит отключать, а Query Cache и накопленные сообщения периодически сбрасывать. Проверьте, что Yii::$app->db не пишет query log в память на весь проход.
Ошибка третья: транзакция на весь цикл
Автор обернул весь пересчёт в одну транзакцию — «чтобы данные были консистентны»:
$transaction = Yii::$app->db->beginTransaction();
foreach (Product::find()->where(...)->each(500) as $product) {
// ... save ...
}
$transaction->commit();
Здесь два подвоха. Первый: транзакция на 40 тысяч UPDATE держит блокировки минуты и раздувает журнал БД — на MySQL/InnoDB это распухший undo log, на PostgreSQL — раздутый WAL и удерживаемый снапшот, мешающий autovacuum. Второй: each() использует незакрытый серверный курсор в той же транзакции, и если внутри цикла происходит commit, поведение курсора становится непредсказуемым между СУБД.
Для пакетной команды разумнее коммитить порциями: обрабатываем чанк, коммитим, начинаем следующий. Тогда упавший процесс не откатывает всю работу за ночь, а блокировки короткие.
Что выбрать: одна транзакция, чанки или без транзакции
| Подход | Плюс | Цена | Когда |
|---|---|---|---|
| Одна транзакция на весь проход | Полная атомарность | Долгие блокировки, распухание журнала, риск с курсором each() | Малые объёмы, критична атомарность |
| Коммит по чанкам | Короткие блокировки, частичный прогресс переживает падение | Нужна идемпотентность: повтор не должен ломать данные | Пакетная обработка десятков тысяч строк |
| Без транзакции, save по одному | Просто | Нет атомарности вообще, каждый save — отдельный round-trip | Независимые записи, порядок не важен |
Для ночного пересчёта склада выбрали коммит по чанкам с идемпотентным расчётом: calc() вычисляет остаток от исходных движений, а не прибавляет дельту, поэтому повторный запуск после падения даёт тот же результат.
Что смотреть на ревью консольной команды в Yii2
- Есть ли
return ExitCode::*на всех путях выхода — включая ветку с пойманным исключением. Без этого мониторинг слеп. - Читается ли выборка порциями —
each()/batch()вместоall()там, где строк может быть много. «Сейчас их сто» — не аргумент, объём растёт. - Границы транзакции — не обёрнут ли весь цикл в одну, не смешан ли commit с открытым курсором.
- Обработка исключений — падение внутри чанка должно логироваться и отдавать ненулевой код, а не молча глотаться
try/catchс пустым телом. - Прогресс и логирование — на долгой команде нужен вывод прогресса (
Console::updateProgress) или хотя бы периодическийstdout, иначе непонятно, работает она или зависла.
Как это ловить заранее
Падение под объёмом не воспроизводится на dev-данных — в этом вся коварность. Три практики, которые сняли повторение проблемы:
- Запуск команды на копии продакшн-объёма перед выкаткой, с жёстким
memory_limit, равным продовскому, чтобы утечка вылезала на тесте, а не ночью. - Обёртка в supervisor/systemd, которая реагирует на ненулевой код возврата и шлёт алерт — это работает только если команда действительно этот код отдаёт.
- Логирование пикового
memory_get_peak_usage()в конце прогона, чтобы видеть тренд роста заранее, до упора в лимит.
Следующий шаг — вынести тяжёлый пересчёт из синхронной консольной команды в очередь через yii2-queue, где каждый чанк становится отдельным заданием с ретраями. Но это уже про другую архитектуру и отдельный разбор — здесь мы чинили саму команду.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: Yii2 Guide — Console Applications, Yii2 Guide — Query Builder, batch() и each()
Частые вопросы
У меня Yii 1.1, эти советы подойдут?
each() всё равно ест память, что не так?
Обязательно ли возвращать ExitCode, если команда просто выводит текст?
Что будет, если команда упадёт посреди коммита по чанкам?
Почему нельзя обернуть весь цикл в одну транзакцию для надёжности?
Как понять, что проблема именно в памяти, а не в чём-то ещё?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.