Рефакторинг Yii: замеры до и после, где текут запросы
Что реально даёт рефакторинг Yii-приложения в цифрах: N+1 в ActiveRecord, ленивая загрузка, тяжёлые события. Как замерить прирост, а не поверить на слово.

Панель заказов интернет-магазина на Yii 2.0 открывалась 4,2 секунды при 800 строках в таблице. В логах Yii debug — 611 SQL-запросов на один рендер списка. После переписывания выборки заказов приложение стало отдавать ту же страницу за 380 мс при 41 запросе. Ни одной новой машины, ни строчки кеша — только рефакторинг доступа к данным. Разберём, где в типичном Yii-проекте копятся эти сотни запросов, как их замерить и какие изменения дают реальный выигрыш, а какие только выглядят красиво в дифе.
Откуда берутся сотни запросов
Главный источник — ленивая загрузка связей в ActiveRecord. Yii по умолчанию тянет связанную модель отдельным запросом в момент первого обращения к свойству. В цикле по коллекции это превращается в классический N+1: один запрос на список плюс по одному на каждую строку.
// Плохо: N+1. На 800 заказов — 801 запрос
$orders = Order::find()->limit(800)->all();
foreach ($orders as $order) {
// каждое обращение к customer — новый SELECT
echo $order->customer->name;
echo $order->status->label;
}
Здесь два обращения к связям внутри цикла дают 1 + 800 + 800 = 1601 запрос. Именно такие места и всплывали в панели Yii debug тем самым красным числом.
Жадная загрузка через with()
Метод with() у ActiveQuery собирает связанные записи одним-двумя дополнительными запросами через IN (...) вместо запроса на каждую строку.
// Хорошо: 3 запроса независимо от числа заказов
$orders = Order::find()
->with(['customer', 'status'])
->limit(800)
->all();
foreach ($orders as $order) {
echo $order->customer->name; // из уже загруженного набора
echo $order->status->label;
}
Один запрос на заказы, один на customer, один на status. 1601 запрос превратился в 3. Это и есть основной вклад в те 611 → 41 из первого абзаца — остальное дали связи второго уровня.
Правило проверки простое: если число SQL-запросов на странице растёт вместе с числом строк в списке — у вас N+1, и это чинится with(), а не индексом.
Как замерить, а не угадать
Прежде чем что-то переписывать, поставьте цифру. Иначе вы не отличите рефакторинг от перекладывания кода.
- Включите Yii Debug Toolbar (модуль
yii\debug\Module) в конфигурации окружения разработки. Панель Database покажет число запросов, суммарное время и дубли. - Откройте проблемную страницу и запишите три числа: количество запросов, суммарное время SQL, время рендера. Это ваша точка отсчёта.
- Найдите в списке запросов повторяющиеся с одинаковой структурой и разным параметром — это и есть N+1.
- Внесите одно изменение. Повторите замер на тех же данных. Сравните три числа.
Если нужны цифры вне режима отладки, замеряйте на реальном профиле нагрузки. Для суммарного времени приложения используйте Yii::getLogger()->flush() и категорию профилирования, но для разовой диагностики хватает toolbar.
Что именно сравнивать
| Изменение | Что мерить | Когда даёт эффект |
|---|---|---|
| with() вместо ленивой загрузки | число SQL-запросов | списки со связями, N+1 |
| select() с нужными полями | объём переданных данных, время SQL | широкие таблицы, blob/text столбцы |
| asArray() для чтения | память PHP, время гидрации | вывод больших списков без методов модели |
| индекс на колонку фильтра | время одного запроса | WHERE/ORDER BY по неиндексированному полю |
| batch() при обходе больших выборок | пиковая память | обработка десятков тысяч строк |
Приёмы с реальной ценой
asArray() там, где не нужны объекты
Гидрация в объекты ActiveRecord — не бесплатна: на каждую строку создаётся объект, вызываются init() и события. Если строки идут прямо в таблицу или JSON, объекты не нужны.
// Для чтения без бизнес-логики модели
$rows = Order::find()
->select(['id', 'number', 'total', 'created_at'])
->asArray()
->limit(800)
->all();
На широких выборках это снижает и время, и память. Цена: вы теряете методы модели, вычисляемые свойства и приведение типов — total придёт строкой, а не float, приводить придётся вручную.
select() против SELECT *
ActiveRecord по умолчанию тянет все колонки. Если в таблице заказов лежит текстовое поле с историей или сериализованными данными, каждый запрос волочёт их по сети, даже когда они не нужны для списка. Явный select() с перечнем полей убирает лишний трафик и снижает время SQL, особенно на удалённой БД.
batch() для больших обходов
Обработка отчёта по всем заказам через all() поднимает в память сразу всю выборку. При десятках тысяч строк это ловит fatal error по памяти. Метод batch() отдаёт данные порциями через курсор.
// Обход по 500 строк, память держится ровно
foreach (Order::find()->batch(500) as $chunk) {
foreach ($chunk as $order) {
// обработка одного заказа
}
}
Цена: пока идёт обход, соединение с БД занято курсором — в этом соединении нельзя выполнять другие запросы. Для MySQL с PDO это означает отдельное соединение под тяжёлый обход.
Что выглядит рефакторингом, но эффекта не даёт
Не всё, что улучшает читаемость, ускоряет приложение. Разделяйте эти две цели и мерьте их по-разному.
- Разбиение контроллера на трейты и сервисы. Полезно для поддержки, но на время ответа не влияет — SQL остаётся прежним. Мерить тут нужно не миллисекунды, а сложность и число ответственностей класса.
- Замена запросов через QueryBuilder на ActiveRecord. Часто это шаг назад по производительности: ActiveRecord добавляет гидрацию. Обратный переход на построитель запросов или чистый DAO для горячих участков — реальный выигрыш.
- Кеширование поверх N+1. Кеш прячет проблему, но при промахе страница снова делает 600 запросов. Сначала уберите N+1, потом кешируйте — тогда и кеш будет мельче.
Где подход ломается
Жадная загрузка тоже имеет предел. with() по связи hasMany с большим количеством дочерних строк тянет их все одним IN-запросом — если у заказа тысячи позиций, вы получите огромный набор в память. В таком случае лучше отдельный агрегирующий запрос с COUNT или joinWith() с фильтром, а не полная загрузка коллекции.
Второй граничный случай — joinWith() вместо with(). Первый делает JOIN и позволяет фильтровать по полям связи, но при hasMany порождает дублирование строк главной таблицы. Если вы фильтруете по связи и одновременно жадно её грузите, читайте раздел про joinWith в документации Yii — там разобрана разница между режимами и когда включать eager.
Что делать дальше
После того как убрали N+1 на главных списках, смотрите на медленные запросы уже в БД: EXPLAIN на тех, что остались в верху toolbar по времени. Дальше — индексы под реальные WHERE и ORDER BY, а не под все колонки подряд. И держите Debug Toolbar включённым на стейджинге: регрессия по числу запросов — первый сигнал, что кто-то снова обратился к связи внутри цикла в шаблоне.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Yii 2.0 Guide — Active Record: Eager and Lazy Loading, Yii 2.0 — Debug Toolbar and Debugger
Частые вопросы
У меня Yii 1.1, а не 2.0 — приёмы подойдут?
Я добавил with(), а число запросов не упало. Почему?
Стоит ли переписывать весь проект на asArray()?
Грозит ли рефакторинг доступа к данным поломкой в проде?
Как понять, что тормозит PHP-гидрация, а не сама БД?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.