ActiveRecord в Yii под нагрузкой: что ломается первым
Список товаров грузился 40 мс на 20 записях и 2,8 секунды на 500 — при этом сам SQL отрабатывал за 12 мс. Разбираем, где ActiveRecord в Yii 2 съедает время и память под реальной нагрузкой.

Типичная картина: страница каталога на Yii 2 отдаёт список товаров. На 20 записях всё летает — 40 мс. Ставишь пагинацию на 500 позиций, и время ответа уходит в 2,8 секунды. Открываешь профайлер, видишь один SELECT на 12 мс — и не понимаешь, куда делись остальные 2,7 секунды. Они ушли внутрь ActiveRecord: на гидрацию строк в объекты, на события, на ленивую загрузку связей и на кэш идентичности, который никто явно не звал.
Разберём, что именно происходит между ответом базы и готовым массивом моделей, где абстракция начинает стоить дорого и что ломается раньше всего. Речь про Yii 2.0.4x и связку yiisoft/yii2 с yiidb; поведение описанного слоя между минорными версиями стабильно.
Что делает ActiveRecord после того, как база ответила
Когда вы пишете Product::find()->limit(500)->all(), драйвер PDO возвращает массив ассоциативных массивов почти мгновенно. Дальше начинается работа Yii, и она не бесплатная. Для каждой строки происходит примерно следующее.
- Создание экземпляра модели. Вызывается
Yii::createObject()либо прямоеnew, объект наследуетyii\db\ActiveRecord, а тот —yii\base\ModelиComponent. За каждым объектом тянется контейнер поведений и обработчиков событий. - Заполнение атрибутов. Метод
populateRecord()раскладывает строку по свойствам, сохраняя копию исходных значений в_oldAttributes— чтобы потом приsave()понять, какие поля изменились. То есть на каждую запись вы храните атрибуты дважды. - События. На заполнении дёргается
EVENT_AFTER_FIND. Даже без подписчиков механизм проверяет, есть ли обработчики, и проходит по цепочке поведений.
Двадцать объектов такой ценой незаметны. Пятьсот — уже заметны, а с несколькими связями на каждый объект стоимость растёт нелинейно.
Главная ловушка ActiveRecord не в SQL, а в том, что вы платите за полноценный доменный объект там, где вам нужен был просто массив полей для вывода в шаблон.
Первое, что ломается: N+1 на связях
Классика, которая проявляется раньше остального. Вы выбрали 500 товаров и в шаблоне обращаетесь к $product->category->name. Если связь не загружена заранее, Yii выполнит отдельный запрос на каждый товар — 500 дополнительных SELECT вдобавок к основному.
// Проблема: 1 запрос на товары + 500 запросов на категории
$products = Product::find()->limit(500)->all();
foreach ($products as $product) {
echo $product->category->name; // ленивый запрос на каждой итерации
}
// Решение: жадная загрузка одним дополнительным запросом
$products = Product::find()
->with('category')
->limit(500)
->all();
foreach ($products as $product) {
echo $product->category->name; // связь уже в памяти
}С with() Yii делает два запроса: один на товары, второй — на все нужные категории через IN (...), а затем сшивает их в памяти. Разница в проде между этими двумя вариантами — не проценты, а порядок величины по числу запросов к базе.
Как поймать N+1, не гадая: включите профилирование БД и смотрите панель Debug Toolbar (yii2-debug). Если на одной странице десятки почти одинаковых запросов, отличающихся только значением в WHERE id =, — это оно.
Второе: память на гидрации объектов
Каждый объект ActiveRecord — это не только данные. Это экземпляр класса со ссылками на связанные модели в _related, копией старых атрибутов, привязкой к событиям. При выборке нескольких тысяч записей вы упираетесь не в CPU, а в memory_limit.
Показательный замер (PHP 8.1, товары с 12 полями, без связей, локальная MySQL 8):
| Число записей | Способ выборки | Пиковая память | Время |
|---|---|---|---|
| 5000 | ->all() (объекты) | ~78 МБ | ~310 мс |
| 5000 | ->asArray()->all() | ~14 МБ | ~55 мс |
| 5000 | ->each() (батч по 100) | ~9 МБ | ~340 мс |
Цифры зависят от железа и ширины таблицы — важен не абсолют, а соотношение. Массивы вместо объектов дают кратную экономию и по памяти, и по времени, потому что пропускают всю гидрацию.
Когда объекты не нужны
Если данные едут прямо в JSON-ответ или в шаблон и вы не собираетесь их менять и сохранять — объекты избыточны. asArray() возвращает те же поля массивом, включая связи, загруженные через with().
// Только чтение, никаких save() — берём массивы
$rows = Product::find()
->select(['id', 'name', 'price', 'category_id'])
->with('category')
->asArray()
->all();
// $rows[0]['category']['name'] — связь тоже массивОбратите внимание на явный select(): по умолчанию ActiveRecord тянет SELECT *, а лишние колонки — это лишний трафик от базы и лишняя память на строку.
Третье: большие выборки, которые не помещаются в память целиком
Экспорт, пересчёт, миграция данных — задачи, где записей десятки и сотни тысяч. Тут all() убьёт процесс по лимиту памяти. Yii предлагает батчевую итерацию.
batch($size)— отдаёт массивы моделей по$sizeштук за итерацию. Под капотом использует серверный курсор (unbuffered query у драйвера).each($size)— то же самое, но итерируется по одной модели, а батч подгружает прозрачно. Удобнее дляforeach.
// Обработка 200 000 записей без взрыва памяти
foreach (Order::find()->each(500) as $order) {
// здесь одна модель, в памяти держится максимум ~500
$order->recalculateTotal();
$order->save(false); // false — пропустить валидацию, если она тут не нужна
}Подводный камень: батчевая выборка на MySQL открывает unbuffered-запрос, и пока вы итерируете, по тому же соединению нельзя выполнять другие запросы. Если внутри цикла делаете save() или дополнительные SELECT по тому же соединению — получите ошибку вида Cannot execute queries while other unbuffered queries are active. Лечится вторым соединением к БД для операций внутри цикла или предварительным сбором изменений и записью пачками после итерации.
Четвёртое: события и поведения, о которых забыли
Каждое save(), insert(), delete() прогоняет цепочку событий: beforeValidate, afterValidate, beforeSave, afterSave. Если на модели висит TimestampBehavior, BlameableBehavior и пара своих обработчиков, каждый вызов сохранения — это не один INSERT, а INSERT плюс работа всех подписчиков. При массовой вставке это заметно.
Для пакетной вставки, где события не нужны, есть прямой путь мимо ActiveRecord — batchInsert() билдера команд:
// 10 000 строк одним запросом, без гидрации и событий
Yii::$app->db->createCommand()->batchInsert(
'product',
['name', 'price', 'category_id'],
$rows // массив массивов значений
)->execute();Это не ActiveRecord: валидация, beforeSave и автозаполнение временных меток не сработают. Плата за скорость — вы берёте на себя корректность данных.
Где проходит граница разумного
| Задача | Что использовать | Почему |
|---|---|---|
| Форма: одна запись, чтение и сохранение | Полноценный ActiveRecord | Валидация, события и удобство важнее наносекунд |
| Список на вывод, только чтение | asArray() + явный select() | Объекты не нужны, экономия кратная |
| Список со связями | with() обязательно | Убирает N+1 |
| Обработка десятков тысяч записей | each() / batch() | Постоянная память вместо линейной |
| Массовая вставка | batchInsert() | Один запрос вместо тысяч, мимо событий |
Что делать дальше
Начните с измерения, а не с оптимизации наугад. Подключите yii2-debug на стейджинге, откройте панель Database и посмотрите на трёх реальных страницах: сколько запросов, есть ли повторяющиеся с разным id, сколько времени уходит вне SQL. Первым чините N+1 через with() — это обычно самая большая и самая дешёвая победа. Дальше переводите read-only списки на asArray() и добавляйте явный select(). Батчинг и batchInsert() держите для консольных команд и фоновых задач. Инвалидацию и настройку кэша запросов здесь не разбираем — это отдельная тема.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: Yii 2 Guide — Active Record, Yii 2 Guide — Query Builder (batch query, batchInsert)
Частые вопросы
У меня Yii 2.0.3x, а не последняя минорная. Приёмы сработают?
asArray() ломает мои связи через with()?
Почему each() внутри цикла падает с ошибкой про unbuffered queries?
Насколько реально asArray() быстрее объектов?
Стоит ли вообще отказаться от ActiveRecord ради производительности?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.