ActiveRecord в Yii: что происходит внутри и чем платите
Каждый вызов save() и find() в Yii 2 — это цепочка событий, гидратации объектов и ленивых запросов. Разбираем, где ActiveRecord тратит время и память и когда его абстракция обходится дорого.

Список из 500 заказов, у каждого — покупатель и три позиции. Страница отдаётся 4 секунды, в логе Yii Debug — 1503 SQL-запроса. Классический N+1: ActiveRecord честно сходил в базу за каждой связью каждой строки. Тот же результат через joinWith() и asArray() укладывается в три запроса и десятки миллисекунд. Разница не в базе — в том, что делает ActiveRecord между вашим вызовом и SQL.
Разбираем на Yii 2 (2.0.x, PHP 8.1), как модель ActiveRecord превращается в запрос, что происходит при гидратации строк в объекты, где тратится память и в каких местах абстракция начинает стоить реальных денег на CPU и RAM. Оптимизацию конкретных запросов и настройку индексов не трогаем — здесь только про механику самого ActiveRecord.
Что такое ActiveRecord в Yii и из чего он состоит
ActiveRecord в Yii — это связка из нескольких слоёв. Понимать их границы важно, потому что цена абстракции распределена между ними неравномерно.
- yii\db\ActiveRecord — ваша модель. Наследуется от
yii\db\BaseActiveRecord, которая расширяетyii\base\Model. Отсюда — атрибуты, валидация, события, поведения. - yii\db\ActiveQuery — построитель запроса. Собирает условия, связи, сортировку. Ничего не выполняет, пока не вызван терминальный метод (
all(),one(),count()). - yii\db\Command и yii\db\QueryBuilder — превращают структуру запроса в строку SQL и биндят параметры.
- yii\db\Connection — обёртка над PDO, пул подготовленных выражений.
Когда вы пишете Order::find()->where(['status' => 1])->all(), работают все четыре слоя. Каждый добавляет свою долю к тому, что PDO мог бы сделать голым запросом.
Что происходит при find()->all()
Разложим по шагам, что делает Yii между вызовом и массивом моделей:
find()создаёт объектActiveQueryи запоминает класс модели. Запроса ещё нет.where(),orderBy()и подобные — накапливают структуру в свойствах объекта запроса. По-прежнему без обращения к базе.all()вызываетQueryBuilder, тот собирает строку SQL и массив параметров, создаётCommand.Commandготовит PDO-выражение, биндит параметры, выполняет запрос, получает массив ассоциативных строк.- Начинается гидратация: для каждой строки Yii создаёт новый экземпляр модели через
Yii::createObject(), заполняет атрибуты, помечает запись как существующую в базе (isNewRecord = false), сохраняет копию старых значений в$oldAttributes, вызывает событиеEVENT_AFTER_FIND.
Шаг 5 — главный источник накладных расходов. На 10 строках он незаметен. На 50 000 — превращается в десятки мегабайт объектов и заметное время на создание экземпляров и рассылку событий.
Где именно тратится время и память
Гидратация: объект против массива
Каждая строка результата в режиме ActiveRecord — это не просто данные, а полноценный объект модели с копией атрибутов в $oldAttributes. Копия нужна, чтобы save() знал, какие поля изменились и обновлял только их. Значит, каждая запись занимает память как минимум дважды: текущие значения и снимок старых.
Если вы читаете данные только на вывод и не собираетесь их менять, платить за это незачем. Метод asArray() отдаёт те же строки в виде вложенных массивов, минуя создание объектов, события и хранение старых значений.
// Модели: удобно, но каждая строка — объект с $oldAttributes и событиями
$orders = Order::find()->where(['status' => 1])->all();
// Массивы: те же данные без гидратации в объекты
$rows = Order::find()->where(['status' => 1])->asArray()->all();
// $rows[0]['id'], $rows[0]['status'] — обычные ключи массиваНа больших выборках разница в памяти между этими двумя вызовами кратная — от объектной обвязки ActiveRecord вы избавляетесь целиком.
ActiveRecord оптимизирован под «прочитать несколько записей, поменять, сохранить». Как только сценарий — «прочитать десятки тысяч строк и отдать наружу», модель из помощника превращается в налог на каждую строку.
N+1: ленивая загрузка связей
Обращение к связи через геттер ($order->customer) запускает отдельный запрос при первом доступе. В цикле это и рождает N+1: один запрос на список плюс по одному на каждую связь каждого элемента.
Жадная загрузка через with() собирает связанные записи отдельными пакетными запросами: один на заказы, один на всех покупателей разом по списку customer_id, один на позиции. Yii затем раскладывает их по родительским объектам в памяти.
// N+1: 1 запрос на заказы + по запросу на customer каждого заказа
$orders = Order::find()->all();
foreach ($orders as $order) {
echo $order->customer->name; // ленивый запрос на каждой итерации
}
// Жадная загрузка: связи подтягиваются пакетно
$orders = Order::find()->with('customer', 'items')->all();
foreach ($orders as $order) {
echo $order->customer->name; // уже в памяти, запроса нет
}Здесь важна цена with(): он не делает JOIN. Он выполняет отдельные запросы и склеивает результат в PHP. Это дешевле по числу запросов, но связанные объекты тоже гидратируются как полноценные модели. Если вам нужно только имя покупателя для вывода — рассмотрите joinWith() с явной выборкой полей или asArray().
save(): что стоит за одним вызовом
Одиночный save() — это не один INSERT. По умолчанию он запускает валидацию (все правила модели), рассылает события EVENT_BEFORE_VALIDATE, EVENT_AFTER_VALIDATE, EVENT_BEFORE_INSERT/UPDATE, EVENT_AFTER_INSERT/UPDATE, сравнивает текущие атрибуты со снимком старых и формирует SQL только по изменённым полям.
В цикле на тысячи записей это заметно. Если данные вы уже проверили, валидацию можно отключить, а для массовой вставки — обойти ActiveRecord вовсе.
// Пропустить валидацию, если данные уже проверены
$order->save(false);
// Массовая вставка без гидратации и событий вообще
Yii::$app->db->createCommand()->batchInsert(
'order',
['customer_id', 'status', 'created_at'],
[
[1, 1, 1710000000],
[2, 1, 1710000100],
]
)->execute();batchInsert() собирает один многострочный INSERT и не создаёт ни одного объекта модели. За это вы теряете валидацию, события и автозаполнение через поведения вроде TimestampBehavior — их придётся делать руками. Это и есть обмен абстракции на скорость в чистом виде.
Таблица: что выбирать под задачу
| Задача | Инструмент | Чем платите |
|---|---|---|
| Прочитать 1–50 записей, поменять, сохранить | find()->all() + save() | Ничем существенным — сценарий целевой для AR |
| Отдать большой список на вывод | asArray()->all() | Теряете методы модели и связи как объекты |
| Список с данными из связей | with() или joinWith() | with — лишние объекты; joinWith — сложнее условия |
| Вставить тысячи строк | batchInsert() | Нет валидации, событий, TimestampBehavior |
| Обновить много строк по условию | updateAll() | Нет событий и валидации, работает мимо модели |
| Читать миллионы строк потоком | each() / batch() | Сложнее логика, курсор держит соединение |
Где абстракция ломается первой
Проблемы начинаются не на функционале, а на объёме и на неявном поведении.
- Пиковая память при all() на большой выборке.
all()тянет весь результат в память и гидратирует его целиком. На десятках тысяч строк процесс упирается вmemory_limit. Решение —batch()илиeach(): они читают данные порциями через курсор БД. Но курсор держит соединение открытым на время обхода, и внутри нельзя выполнять посторонние запросы к тому же соединению. - updateAll() и deleteAll() идут мимо модели. Они не запускают ни валидацию, ни события, ни поведения. Если на модели висит
TimestampBehaviorили логика вafterSave, при массовом обновлении она молча не сработает. - Магические геттеры связей стоят дороже, чем кажется. Каждое обращение к
$order->customerв цикле без предзагрузки — это запрос плюс гидратация. В шаблоне это легко не заметить. - События и поведения множатся. Чем больше правил валидации и навешанных поведений, тем дороже каждый
save()и каждая гидратация. На единичной записи — неощутимо, в пакетной операции — суммируется.
Как померить у себя
Не гадайте — снимайте цифры. В dev-окружении подключите модуль yii\debug\Module (Yii Debug Toolbar): панель Database покажет число запросов, дубли и время каждого. Это первый способ поймать N+1 — вы увидите одинаковые SQL, отличающиеся только параметром.
Память меряйте вокруг подозрительного участка:
$before = memory_get_usage();
$orders = Order::find()->with('items')->all();
$after = memory_get_peak_usage();
echo ($after - $before) / 1024 / 1024 . ' MB, строк: ' . count($orders);Сравните это же число для asArray()->all() на тех же данных. Разница и есть цена гидратации в объекты на вашей выборке — она зависит от числа полей, связей и правил модели, поэтому конкретную цифру надо получать на своих данных, а не брать из чужой статьи.
Что делать дальше
Не отказывайтесь от ActiveRecord — откажитесь от его применения там, где сценарий не совпадает с целевым. Практический порядок действий:
- Поставьте Yii Debug Toolbar и пройдитесь по тяжёлым страницам — ищите повторяющиеся запросы. Это первый и самый дешёвый выигрыш.
- На выборках только для чтения переводите запросы на
asArray(). - Связи в списках грузите через
with(); если нужны лишь несколько полей — черезjoinWith()с явной выборкой. - Массовые вставки и обновления делайте через
batchInsert()иupdateAll(), не забыв руками то, что раньше делали поведения. - Обходы больших таблиц — через
each(), а неall().
Дальше смотрите в сторону слоя запросов: yii\db\Query без привязки к модели даёт те же построители, но без объектной обвязки вообще. Точное поведение методов сверяйте с документацией вашей ветки Yii — между минорными версиями детали гидратации и работы с курсором менялись.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Yii 2 Guide — Active Record, Yii 2 Guide — Query Builder
Частые вопросы
У меня Yii 1.1, а не Yii 2 — эти приёмы подойдут?
Если заменил all() на asArray(), но связи всё равно грузятся отдельными запросами?
Правда ли, что with() делает JOIN?
После перехода на updateAll() перестало заполняться поле updated_at — почему?
each() держит соединение с базой — это опасно в проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.