ActiveRecord в Yii 2: что происходит под капотом запроса
Один вызов $model->save() превращается в связку из событий, валидации, dirty-атрибутов и SQL. Разбираем, как ActiveRecord в Yii 2 хранит состояние записи и где это состояние ломается под нагрузкой.

Профилировщик показывает: страница списка заказов делает 214 SQL-запросов вместо ожидаемых трёх. Причина не в базе и не в индексах — в том, как ActiveRecord ленится загружать связи и как вы этого не заметили, пока таблица заказов не выросла до сотни тысяч строк. Чтобы понимать, почему $order->customer->name в цикле стреляет вам в ногу, нужно знать, что ActiveRecord держит в памяти между запросами и когда именно ходит в базу.
Разбор написан для Yii 2.0.x (актуальная ветка на момент написания — 2.0.4x). Yii 3 и его слой yiisoft/active-record устроены иначе — здесь речь только про Yii 2. Читателю нужно понимать SQL и базовое устройство ActiveRecord, но не обязательно знать внутренности фреймворка заранее.
Три слоя, которые прячутся за одной моделью
Когда вы пишете class Order extends \yii\db\ActiveRecord, вы наследуете цепочку из трёх компонентов, и каждый отвечает за свою часть.
yii\base\Model— атрибуты, валидация, сценарии, ошибки. Ничего не знает о базе.yii\db\BaseActiveRecord— состояние записи: старые и новые значения атрибутов, признак новой записи, кеш загруженных связей.yii\db\ActiveRecord— привязка к таблице черезtableName()и схему, генерация SQL черезActiveQuery.
Ключевое поведение живёт в среднем слое — в BaseActiveRecord. Именно он решает, будет ли save() делать INSERT или UPDATE и попадёт ли изменённый атрибут в запрос.
Как модель понимает, что запись «грязная»
Внутри каждого объекта AR живут два массива: текущие значения атрибутов и _oldAttributes — снимок значений на момент загрузки из базы. Когда вы присваиваете $order->status = 'paid', меняется только текущее значение, снимок остаётся прежним.
При save() фреймворк вызывает getDirtyAttributes() — сравнивает текущие значения со снимком и собирает только изменившиеся. В UPDATE попадают ровно они, а не все поля таблицы. Это защищает от гонок: два процесса, меняющие разные колонки одной строки, не затрут работу друг друга.
Если_oldAttributesравен null, запись считается новой, иsave()идёт по ветке INSERT. Именно поэтому объект, собранный черезnew Order(), всегда вставляется, а не обновляется.
Практическое следствие: если вы читаете модель, а потом присваиваете атрибуту то же самое значение, что уже там было, getDirtyAttributes() вернёт по нему пусто, и UPDATE вообще не выполнится. Метод save() в этом случае вернёт true, но ни одного запроса к базе не будет — иногда это удивляет тех, кто ждёт запись в лог по каждому вызову.
Событийный конвейер save()
Вызов save() — это не одна строка SQL, а последовательность шагов, каждый из которых можно перехватить.
save()по умолчанию вызываетvalidate(). Если валидация не прошла — запроса к базе не будет, метод вернётfalse, ошибки лягут вgetErrors().- Дальше —
insert()илиupdate()в зависимости отisNewRecord. - Перед записью срабатывает событие
EVENT_BEFORE_INSERTилиEVENT_BEFORE_UPDATE. Обработчик может отменить операцию, установив$event->isValid = false. - Выполняется собственно INSERT/UPDATE через построитель запросов.
- После записи —
EVENT_AFTER_INSERT/EVENT_AFTER_UPDATE. Здесь удобно чистить связанные кеши. - После успешной записи
_oldAttributesобновляется текущими значениями — запись перестаёт быть «грязной».
На этом конвейере построены поведения (behaviors). TimestampBehavior подписывается на before-события и подставляет created_at/updated_at. BlameableBehavior — created_by/updated_by. Они не магия: это обработчики событий, которые правят атрибуты до того, как соберётся SQL.
// Yii 2.0.x — минимальная модель с dirty-логикой
class Order extends \yii\db\ActiveRecord
{
public static function tableName()
{
return '{{%order}}';
}
public function behaviors()
{
return [
// проставит created_at и updated_at на нужных событиях
\yii\behaviors\TimestampBehavior::class,
];
}
}
$order = Order::findOne(42); // SELECT, снимок кладётся в _oldAttributes
$order->status = 'paid'; // меняется только текущее значение
$order->save(); // UPDATE только по status и updated_at
save(false) — когда пропускают валидацию
Вызов save(false) пропускает шаг валидации. Это ускоряет запись данных, в чистоте которых вы уверены (например, при импорте из уже проверенного источника), но снимает страховку: если в атрибуте лежит null там, где база требует NOT NULL, вы получите SQL-исключение вместо аккуратной ошибки валидации.
Откуда берутся 214 запросов: ленивые и жадные связи
Связь, объявленная методом getCustomer() через hasOne(), возвращает объект ActiveQuery, а не готовые данные. Пока вы не обратились к $order->customer как к свойству, запроса нет. При первом обращении AR выполняет отдельный SELECT и кеширует результат в приватном хранилище связей внутри объекта.
Отсюда классический N+1. Такой код на списке из 200 заказов сделает 1 запрос за заказами плюс 200 запросов за покупателями:
// ПЛОХО: N+1
$orders = Order::find()->limit(200)->all();
foreach ($orders as $order) {
echo $order->customer->name; // отдельный SELECT на каждой итерации
}
// ХОРОШО: жадная загрузка через with()
$orders = Order::find()->with('customer')->limit(200)->all();
foreach ($orders as $order) {
echo $order->customer->name; // связь уже загружена, запросов нет
}
При with('customer') Yii делает не JOIN, а второй отдельный запрос: собирает все customer_id из загруженных заказов и тянет покупателей одним WHERE id IN (...). Затем раскладывает результат по объектам заказов в PHP. Два запроса вместо 201.
Важное различие: with() генерирует отдельные запросы, а joinWith() добавляет JOIN — он нужен, когда вы хотите фильтровать или сортировать по полям связанной таблицы. Путать их — частая причина того, что фильтр «по имени покупателя» не работает: если сделать with() и попытаться отфильтровать по колонке покупателя, этой колонки в основном запросе просто нет.
Кеш связей живёт внутри объекта
Загруженная связь хранится в самом экземпляре модели, пока он существует. Повторное $order->customer базу не трогает. Если данные покупателя изменились в базе, а вам нужно перечитать, вызовите сброс кеша связи для этого объекта — иначе получите устаревшее значение из памяти. Инвалидация связей — отдельная тема, здесь важно только то, что кеш привязан к конкретному объекту и умирает вместе с ним.
Где это ломается под нагрузкой
Гидрация — превращение строки результата в объект AR — стоит дороже, чем кажется. На каждую строку создаётся полноценный объект модели с событиями, поведениями и двумя копиями атрибутов. На выборке в десятки тысяч строк это заметно и по времени, и по памяти.
| Способ выборки | Что возвращает | Когда уместно |
|---|---|---|
Order::find()->all() | Массив объектов AR | Нужны методы модели, связи, сохранение |
Order::find()->asArray()->all() | Массив ассоциативных массивов | Только чтение, вывод в список, экспорт |
Order::find()->each() | Объекты по одному через генератор | Большие выборки, чтобы не держать всё в памяти |
Три типичных места, где AR начинает мешать:
- Списки на чтение. Для таблицы, которую вы только показываете, объекты AR не нужны.
asArray()пропускает гидрацию в объекты и валидацию сценариев, отдавая голые массивы. На больших списках разница ощутима — замерьте на своих данных черезYii::getLogger()и панель отладки. - Массовые UPDATE/DELETE. Цикл с
$model->save()по тысячам строк — это тысячи запросов и тысячи прогонов событий.Order::updateAll(['status' => 'archived'], ['<', 'created_at', $date])делает то же одним SQL. Ценой: события и валидация не срабатывают, поведения не отработают. - Пакетная обработка.
find()->all()на миллионе строк съест память.find()->batch(1000)илиeach()тянет данные порциями через серверный курсор.
Правило простое: AR — про удобную работу с отдельными сущностями и их жизненным циклом. Как только вы обрабатываете большой объём или просто читаете список, спрашивайте себя, нужны ли вам события, валидация и объекты вообще.
Транзакции и оптимистичная блокировка
Метод transactions() в модели позволяет обернуть save() в транзакцию автоматически для указанных сценариев. Для защиты от конкурентной перезаписи есть оптимистичная блокировка через optimisticLock(): AR добавляет в UPDATE условие по версии строки, и если версия изменилась между чтением и записью, save() бросит StaleObjectException. Настройка требует отдельной колонки-версии — детали смотрите в разделе документации про concurrency.
Что мерить и куда смотреть дальше
Включите панель отладки (yii2-debug) на стенде и откройте вкладку с логом БД: она показывает число запросов и дубли. Дубли одинаковых SELECT с разным id — почти всегда N+1, лечится with(). Дальше проверьте свои списочные экшены на asArray() и замерьте память на самых больших выборках через memory_get_peak_usage().
Следующий шаг — разобраться, как ActiveQuery строит SQL и как переопределить дефолтный find() через собственный класс запроса, чтобы вынести повторяющиеся условия (скоупы) из контроллеров в модель.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Yii 2 Guide — Active Record, Yii 2 API — yii\db\BaseActiveRecord
Частые вопросы
У меня Yii 3, здесь всё так же?
Почему save() вернул true, но запись в базе не изменилась?
with() и joinWith() — что выбрать?
Почему массовый импорт через save() в цикле тормозит?
Безопасно ли отключать валидацию через save(false)?
Как понять, что у меня проблема N+1, а не что-то другое?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.