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

Задача, которая ломает наивный подход
Вы написали консольную команду в Yii2, которая раз в час импортирует товары из внешнего API. На тестовых 500 записях всё работает. В проде на 200 000 записях процесс упирается в лимит памяти PHP и падает с Allowed memory size exhausted, а cron при этом считает задачу успешной, потому что код завершения потерялся. Логи молчат: вывод echo ушёл в никуда.
Это три разные проблемы, и все три встречаются в одной команде: неправильная работа с выборкой, потеря кода возврата и вывод мимо логов. Разберём приёмы, которые снимают каждую. Статья для тех, кто уже пишет команды в Yii2 (2.0.x) и упёрся в поведение под реальной нагрузкой, а не в первый запуск.
Где живут консольные команды
Консольный контроллер наследуется от yii\console\Controller и лежит в каталоге commands/ (по умолчанию, задаётся параметром controllerNamespace в конфиге консоли config/console.php). Действие actionIndex вызывается как ./yii controller-name, действие actionImport — как ./yii controller-name/import.
Здесь разбираем только приёмы внутри самих команд: выборки, коды возврата, вывод, параметры. Настройка расписания cron и деплой — отдельная тема, не трогаем.
Приём 1. Всегда возвращайте код завершения
Самая частая ошибка — команда ничего не возвращает из действия. Yii2 в этом случае отдаёт код 0, то есть «успех», даже если внутри что-то пошло не так. Cron, systemd и любой мониторинг, который смотрит на exit code, будут уверены, что всё в порядке.
В yii\console\Controller есть константы для кодов. Возвращайте их явно.
<?php
// commands/ImportController.php — Yii2 2.0.x
namespace app\commands;
use yii\console\Controller;
use yii\console\ExitCode;
class ImportController extends Controller
{
public function actionRun()
{
try {
$count = $this->importProducts();
} catch (\Throwable $e) {
// stderr, а не stdout — важно для парсинга в мониторинге
$this->stderr($e->getMessage() . "\n");
\Yii::error($e, __METHOD__);
return ExitCode::UNSPECIFIED_ERROR; // код 1
}
$this->stdout("Импортировано: {$count}\n");
return ExitCode::OK; // код 0
}
}ExitCode содержит осмысленные константы (OK, UNSPECIFIED_ERROR, DATAERR и другие по соглашению sysexits). Проверить результат в шелле можно так: после запуска команды выполните echo $? — увидите фактический код.
Команда, которая падает молча с кодом 0, хуже команды, которая падает громко с кодом 1. Первую вы заметите через неделю по кривым данным, вторую — сразу по алерту.
Приём 2. Вывод через stdout/stderr, а не echo
Метод echo пишет напрямую в поток и игнорирует настройки цвета и подавления вывода. Методы контроллера stdout() и stderr() уважают флаг --color и разделяют потоки. Ошибки — в stderr, обычный прогресс — в stdout. Тогда в cron вы сможете перенаправить только ошибки на почту, а лог прогресса — в файл.
Для форматированного вывода используйте Console::ansiFormat:
<?php
use yii\helpers\Console;
$this->stdout(Console::ansiFormat("Готово\n", [Console::FG_GREEN]));
$this->stderr(Console::ansiFormat("Сбой\n", [Console::FG_RED]));Приём 3. Не грузите всю выборку в память
Вот что чаще всего роняет команду на больших данных. Метод all() у ActiveQuery материализует всю выборку сразу — на 200 000 строк это гигабайты объектов ActiveRecord.
Для построчной обработки есть два инструмента, и разница между ними принципиальна:
each()— итерирует по одной записи, но под капотом читает пачками (по умолчанию 100) через один курсор. Каждая запись — полноценный объект AR.batch()— отдаёт массив записей пачкой. Удобно, когда вы хотите обработать группу за раз (например, вставить пачку в другую таблицу).
<?php
use app\models\Product;
// each() — по одной записи, но выборка из БД идёт пачками по 500
foreach (Product::find()->each(500) as $product) {
$this->processOne($product);
}
// batch() — пачками, если обработка групповая
foreach (Product::find()->batch(500) as $products) {
$this->processBatch($products); // $products — массив
}Подводный камень each() и batch()
Оба метода при работе с обычным драйвером PDO MySQL по умолчанию буферизуют результат на стороне PHP — то есть память может не освободиться так, как вы ждёте, потому что весь результат курсора уже прочитан клиентом. Для действительно потоковой выборки на MySQL нужен небуферизованный запрос: PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false. Но тогда внутри итерации нельзя делать другие запросы через то же соединение. На практике для большинства задач хватает each() с явным освобождением ссылок и периодическим gc_collect_cycles(). Точное поведение буферизации проверяйте под свою версию PDO и MySQL — оно зависит от драйвера, а не от Yii.
Приём 4. Параметры и опции — через свойства и options()
Не парсите $argv руками. Yii2 связывает аргументы действия с его параметрами, а именованные опции — с публичными свойствами контроллера, перечисленными в options().
<?php
namespace app\commands;
use yii\console\Controller;
use yii\console\ExitCode;
class ImportController extends Controller
{
public $limit = 0; // --limit
public $dryRun = false; // --dryRun
public function options($actionID)
{
return ['limit', 'dryRun'];
}
// $source — позиционный аргумент
public function actionRun($source)
{
if ($this->dryRun) {
$this->stdout("Пробный прогон, запись отключена\n");
}
// ...
return ExitCode::OK;
}
}Вызов: ./yii import/run api --limit=1000 --dryRun=1. Аргумент api попадёт в $source, опции — в свойства.
Приём 5. Одна команда — один запущенный экземпляр
Если команда работает дольше интервала cron, следующий запуск стартует поверх незавершённого. Два импорта одновременно — гонка и дубли. В Yii2 нет встроенного мьютекса на уровне команды, но есть компонент yii\mutex\Mutex (реализации FileMutex, MysqlMutex, PgsqlMutex). Захватывайте блокировку в начале действия.
<?php
public function actionRun()
{
$mutex = \Yii::$app->mutex; // настроен в config/console.php
if (!$mutex->acquire('import-products', 0)) {
$this->stderr("Уже выполняется, пропускаю\n");
return ExitCode::TEMPFAIL; // код 75
}
try {
// основная работа
} finally {
$mutex->release('import-products');
}
return ExitCode::OK;
}Второй аргумент acquire() — таймаут ожидания в секундах. Ноль означает «не ждать, сразу вернуть false, если занято». Настройте компонент mutex в конфиге консоли перед использованием.
Сравнение способов обработки большой выборки
| Способ | Память | Когда использовать |
|---|---|---|
all() | Вся выборка в памяти | Малые фиксированные выборки, справочники |
each(N) | Пачка N в БД, объект по одному в PHP | Построчная обработка каждой записи |
batch(N) | Пачка N записей за итерацию | Групповые операции: массовая вставка, отправка пачками |
Сырой SQL + createCommand()->query() | Минимальная, без объектов AR | Когда AR не нужен и важна скорость |
Что учесть в проде
- Транзакции внутри долгой итерации: одна транзакция на 200 000 записей держит блокировки и раздувает лог БД. Дробите на пачки по несколько тысяч с коммитом.
- Память ActiveRecord: если объекты не нужны после обработки, освобождайте ссылки и не накапливайте их в массив «на потом».
- Логи: в консоли по умолчанию активна цель логирования, но проверьте, что в
config/console.phpпрописанFileTargetс нужным уровнем — консоль и веб имеют разные конфиги. - Таймаут PHP: в CLI по умолчанию
max_execution_time = 0(без лимита), но лимит памятиmemory_limitдействует. Это и есть та стена, в которую упирается наивныйall().
Что дальше
Соберите базовый контроллер с обработкой исключений и кодами возврата и наследуйте от него остальные команды — так вы не забудете вернуть код в новой команде. Настройте мьютекс для всех задач, которые в cron идут чаще, чем успевают завершиться. Замерьте пиковую память командой в проде: запустите с memory_get_peak_usage(true) в конце действия и сравните с memory_limit — этот запас и есть ваш реальный резерв на рост данных.
Помощник по курсовой и диплому: Большая работа поэтапно: от плана до защиты
Спросить за 25 ₽Источники: Yii2 Guide — Console Applications, Yii2 API — yii\db\ActiveQuery (each, batch)
Частые вопросы
У меня Yii 1.1, эти приёмы подойдут?
Команда всё равно падает по памяти, хотя я использую each(). Что не так?
Как проверить, что команда вернула правильный код завершения?
Обязательно ли объявлять свойство в options(), чтобы передать опцию?
Мьютекс через FileMutex надёжен, если приложение работает на нескольких серверах?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.