ActiveRecord в Yii 2: цена абстракции на ревью кода
ActiveRecord в Yii 2 удобен, пока не встречается с циклом на тысячу записей. Разбираем, во что превращаются find() и with() в SQL, и какие места приложения ловить на ревью.

Страница списка заказов в админке грузилась 4,2 секунды на 800 строках. В логе запросов — 1 601 SQL-запрос: один на выборку заказов и по два на каждый заказ, чтобы дотянуть клиента и статус. Классическая N+1, спрятанная за $order->customer->name внутри цикла в шаблоне. ActiveRecord в Yii 2 честно исполнил то, что ему сказали: для каждого обращения к связи он сходил в базу. Ниже — что именно делает AR под капотом, во что обходится его удобство и какие формулировки замечаний писать на ревью, чтобы это не доехало до прода.
Речь про Yii 2.0.x и yii\db\ActiveRecord. Поведение ленивой и жадной загрузки в этой ветке стабильно много минорных версий, но перед копированием чисел про производительность сверяйтесь со своим окружением.
Что AR делает вместо вас
ActiveRecord — это объектная обёртка над строкой таблицы. Вызов Order::findOne(5) под капотом собирает yii\db\Query, генерирует SELECT * FROM order WHERE id = 5, отдаёт Command, тот дергает PDO, а полученную строку гидрирует в объект: раскладывает колонки по $attributes, запоминает исходные значения в $oldAttributes для последующего save().
Три вещи, за которые вы платите на каждом объекте:
- Гидрация. Массив из PDO превращается в объект модели через
populateRecord(). На выборке в тысячи строк это тысячи инстанцирований и присваиваний атрибутов. - Отслеживание изменений. AR хранит копию исходных атрибутов, чтобы
save()писал в UPDATE только изменённые поля. Память на каждый объект удваивается по данным. - Ленивые связи. Обращение к
$model->relationNameпри первом чтении выполняет отдельный запрос и кеширует результат в объекте. Внутри цикла это и есть N+1.
Для одной сущности всё это незаметно. Проблема начинается там, где AR применяют к множеству строк как к таблице.
Ленивая загрузка против жадной
Ключевая точка ревью — как код тянет связи. Сравним два способа вывести список заказов с именем клиента.
Ленивый доступ — источник N+1
// Yii 2.0.x
$orders = Order::find()->limit(800)->all();
foreach ($orders as $order) {
// на КАЖДОЙ итерации — отдельный SELECT в таблицу customer
echo $order->customer->name;
}
// Итого: 1 запрос заказов + 800 запросов клиентовЖадная загрузка через with()
// with() говорит AR подтянуть связь заранее
$orders = Order::find()
->with('customer')
->limit(800)
->all();
foreach ($orders as $order) {
// связь уже в памяти, запросов внутри цикла нет
echo $order->customer->name;
}
// Итого: 2 запроса — заказы и один IN(...) по клиентамКак with() это делает: сначала выбирает заказы, собирает уникальные customer_id, выполняет один SELECT * FROM customer WHERE id IN (...) и раскладывает результат по объектам заказов в памяти. Один дополнительный запрос вместо восьмисот.
Ленивая загрузка не зло. Зло — ленивая загрузка внутри цикла. Один$order->customerна странице заказа безобиден; тот же вызов вforeachна списке — инцидент.
Когда AR не нужен вовсе
Если строки не редактируются, а только читаются и отдаются в JSON или в шаблон, гидрация в полноценные модели — лишняя работа. У Yii есть два способа этого избежать.
| Подход | Что возвращает | Когда брать |
|---|---|---|
Order::find()->all() | Массив объектов AR | Данные будут меняться и сохраняться |
->asArray()->all() | Массив ассоциативных массивов | Только чтение: списки, экспорт, API |
Yii::$app->db->createCommand() | Сырые строки, полный контроль SQL | Тяжёлые агрегаты, оконные функции, батч-обновления |
Разница на больших выборках заметная: asArray() пропускает создание объектов и отслеживание изменений. Но платите вы тем, что теряете методы модели, поведения (behaviors) и связи как объекты — with() с asArray() работает, но вернёт вложенные массивы, а не модели.
Что ловить на ревью
Конкретные места, на которые смотреть в pull request с AR, и формулировки замечаний, которые понятны автору.
- Обращение к связи внутри
foreach/whileбезwith(). Замечание: «Здесь N+1:$order->customerвнутри цикла даст запрос на каждую итерацию. Добавь->with('customer')в выборку выше». - Выборка полной модели там, где данные только читаются. Замечание: «Список нигде не сохраняется — добавь
->asArray(), объекты AR тут не нужны». Model::find()->all()безlimit()в контроллере. Замечание: «Нет лимита — на росте таблицы выберет всё в память. Нужна пагинация или явный предел».save()в цикле. Каждый вызов — отдельный INSERT/UPDATE плюс валидация и события. Замечание: «Массовая вставка — черезbatchInsert()одним запросом вместоsave()в цикле».select('*')по умолчанию при широкой таблице. Если из тридцати колонок нужны три —->select(['id', 'status', 'customer_id']). Экономит и трафик, и гидрацию.
Про batchInsert
Для вставки множества строк одним запросом в Yii 2 есть Yii::$app->db->createCommand()->batchInsert($table, $columns, $rows)->execute(). Он не запускает валидацию и события модели — это его плюс по скорости и одновременно то, о чём нужно помнить: правила из rules() не сработают, проверяйте данные до вставки.
Рефакторинг того самого списка заказов
Исходный код собирал заказы и в шаблоне дёргал $order->customer->name и $order->status->title — две связи в цикле, отсюда и полторы тысячи запросов. Правка в контроллере:
// было
$orders = Order::find()->limit(800)->all();
// стало
$orders = Order::find()
->select(['id', 'total', 'created_at', 'customer_id', 'status_id'])
->with(['customer', 'status'])
->asArray() // список только читается в таблице
->limit(800)
->all();Шаблон при этом переходит с $order->customer->name на $order['customer']['name'] — при asArray() связи возвращаются вложенными массивами. Если менять шаблон нежелательно, оставьте выборку объектов, но обязательно с with(): сам по себе он убирает N+1 без перехода на массивы.
После правки на том же наборе данных SQL-запросов стало три вместо 1 601. Точные цифры времени зависят от вашей БД, индексов на customer_id и status_id и объёма строк — меряйте у себя, а не берите чужие миллисекунды на веру.
Где смотреть в проде
Чтобы такие места находились до ревью, а не по жалобе на медленную страницу:
- Включите панель отладки
yii2-debugв dev-окружении — она показывает число и текст SQL-запросов на каждый запрос страницы. Скачок с двух до сотен — сигнал N+1. - На проде смотрите на медленный лог MySQL/PostgreSQL и на количество запросов на HTTP-запрос через APM, если он есть.
- Метрика, которую стоит держать в голове при ревью списков: сколько SQL-запросов порождает страница и растёт ли это число вместе с количеством строк. Если растёт линейно — где-то ленивая связь в цикле.
Следующий шаг — прогоните debug-панель по своим страницам-спискам и экспортам: именно там ленивые связи в циклах живут дольше всего, потому что на малых данных незаметны.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Yii 2 Guide — Active Record, Yii 2 API — yii\db\ActiveQuery::with()
Частые вопросы
with() убирает N+1, но список всё равно медленный. Что ещё смотреть?
У меня Yii 1.x, а не 2.0 — приёмы те же?
asArray() всегда лучше по производительности?
Можно ли комбинировать with() и asArray()?
batchInsert не запускает валидацию — чем это грозит?
Как быстро найти все места с ленивой загрузкой в проекте?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.