Профилирование в Yii на ревью: что ловить в pull request
Медленный контроллер редко ломается в проде внезапно — его пропускают на ревью. Разбираем, какие места в коде на Yii 2 указывают на проблему до того, как она станет инцидентом.

Контроллер отдаёт список из 200 записей за 3,4 секунды, панель отладки показывает 806 SQL-запросов. Это классический N+1, и на ревью его должно было остановить одно замечание в комментарии к pull request. Не остановило, потому что ревьюер смотрел на структуру кода, а не на то, сколько запросов породит вот этот безобидный foreach. Профилирование на ревью — это не запуск профайлера, а умение читать код так, чтобы видеть будущие замеры в статике. Разберём, какие конструкции в проектах на Yii 2.0.x чаще всего проходят ревью и превращаются в деградацию.
Речь про Yii 2 (актуальная ветка 2.0.x). Часть замечаний применима и к Yii 3, но имена компонентов и синтаксис ActiveRecord там отличаются, поэтому дословно переносить не стоит.
Почему медленный код проходит ревью
Ревьюер видит diff, а не поведение. В diff запрос Post::find()->all() и запрос с ->with('author') отличаются на одно слово, а в проде — на сотни обращений к базе. Автор PR тестировал на трёх записях в локальной базе, где разница между 4 и 400 запросами не заметна ни в панели отладки, ни в браузере. Проблема всплывает на боевых объёмах через месяц, и связать её с конкретным коммитом уже трудно.
Поэтому профилирование на ревью работает на упреждение: ищем не «где сейчас медленно», а «где станет медленно, когда данных станет много». Дальше — конкретные паттерны, каждый с формулировкой замечания, которое стоит оставить в комментарии к PR.
Хорошее замечание на ревью называет не «оптимизируйте запрос», а конкретный симптом: сколько запросов породит цикл, на каком объёме это станет заметно и какой метод это чинит.
Что ловить в коде на Yii
1. N+1 в связях ActiveRecord
Самый частый и самый дорогой случай. Связь дёргается внутри цикла без предварительной жадной загрузки.
// Плохо: 1 запрос на посты + по одному на автора каждого поста
$posts = Post::find()->all();
foreach ($posts as $post) {
echo $post->author->name; // отдельный SELECT на каждой итерации
}
// Хорошо: 2 запроса независимо от числа постов
$posts = Post::find()->with('author')->all();
foreach ($posts as $post) {
echo $post->author->name; // автор уже загружен
}Формулировка замечания: «$post->author в цикле даёт N+1. Добавьте ->with('author') в запрос выше. На 200 постах это 201 запрос против 2».
Отдельно ловите вложенные связи: $post->author->company->name без ->with(['author.company']) — это уже N×2 запросов.
2. count() в цикле и внутри шаблона
Вызов count() на связи, если связь не была загружена жадно, порождает отдельный SELECT COUNT(*). В цикле по списку — снова N+1, только менее заметный, потому что не тянет данные.
// Плохо: SELECT COUNT(*) на каждой итерации
foreach ($users as $user) {
echo count($user->orders); // связь не загружена — запрос
}
// Лучше: считаем на стороне БД одним запросом через связь-агрегат
// либо грузим заказы жадно, если они и так нужны
$users = User::find()->with('orders')->all();
foreach ($users as $user) {
echo count($user->orders); // теперь count() по массиву в памяти
}Тонкость: после with('orders') связь возвращает массив, и count() считает элементы в памяти без запроса. Но если заказы нужны только для числа, а не для вывода — жадная загрузка тянет лишние данные. Тогда правильнее отдельный агрегирующий запрос. На ревью это повод спросить автора: число нужно само по себе или заказы всё равно выводятся ниже.
3. SELECT * там, где нужны два поля
ActiveRecord по умолчанию тянет все колонки. Если в списке выводится только имя и id, а таблица содержит текстовое поле на десятки килобайт, каждая строка выборки таскает лишнее через сеть и в память PHP.
// Тянет все колонки, включая тяжёлое поле content
$titles = Post::find()->limit(50)->all();
// Тянет только нужное
$titles = Post::find()
->select(['id', 'title'])
->asArray()
->limit(50)
->all();Замечание: «Для списка достаточно id и title. ->select() + ->asArray() снимут нагрузку на память, если объекты модели дальше не нужны».
4. Отсутствие пагинации на списочных запросах
->all() без limit и без Pagination — мина замедленного действия. На стадии разработки в таблице 40 записей, через полгода — 400 000, и ->all() кладёт процесс по памяти. Ищите любой ->all(), который отдаёт данные во view напрямую, и требуйте ActiveDataProvider с пагинацией.
5. Логика в цикле рендеринга
Форматирование даты через Yii::$app->formatter, разбор JSON, обращение к Yii::$app->cache внутри foreach по 500 строк — каждое по отдельности дёшево, вместе дают заметный CPU-хвост. Это не про базу, но профилирование это ловит по секции «Timing» в панели отладки.
Как замерять, а не гадать
Замечание на ревью весомее, если подкреплено цифрой. Просите автора приложить два числа из панели отладки Yii (yii2-debug): количество SQL-запросов и суммарное время БД на репрезентативном объёме данных, а не на пустой локальной базе.
Для точечного замера участка кода в Yii есть встроенное профилирование через Yii::beginProfile() / Yii::endProfile():
// Оборачиваем подозрительный участок
Yii::beginProfile('загрузка постов с авторами', __METHOD__);
$posts = Post::find()->with('author')->all();
foreach ($posts as $post) {
$formatted[] = Yii::$app->formatter->asDatetime($post->created_at);
}
Yii::endProfile('загрузка постов с авторами', __METHOD__);Метки с одинаковым токеном должны быть парными и вложенными корректно — иначе результат в панели профилирования будет искажён. Данные видны на вкладке Profiling в yii2-debug либо в логах, если настроен соответствующий таргет.
Чек-лист ревьюера
- Есть ли обращение к связи модели внутри
foreachбез->with()выше по коду. - Есть ли
->all()без ограничения на количество строк. - Тянется ли вся модель там, где во view выводятся два-три поля.
- Считается ли что-то через
count()по незагруженной связи в цикле. - Приложил ли автор число запросов и время БД на реальном объёме.
Приоритеты замечаний
Не все находки равны. Блокировать merge стоит за то, что деградирует нелинейно с ростом данных, остальное — комментарием без блокировки.
| Паттерн | Рост нагрузки | Реакция на ревью |
|---|---|---|
| N+1 по связи в цикле | Линейный по числу строк | Блокировать merge |
->all() без пагинации | Линейный, риск OOM | Блокировать merge |
count() по незагруженной связи | Линейный, но лёгкие запросы | Просить исправить |
| SELECT * вместо нужных полей | Постоянный overhead на строку | Комментарий |
| Форматирование в цикле рендера | Линейный по CPU | Комментарий |
Что настроить, чтобы ревью не было единственной защитой
Ручное ревью пропускает N+1 регулярно — человек устаёт и торопится. Снизить нагрузку на ревьюера помогает автоматика:
- Держите
yii2-debugвключённым на dev/stage и приучите команду смотреть счётчик запросов после каждой задачи со списками. - Заведите в CI шаг, который прогоняет ключевые списочные эндпоинты на наполненной тестовой базе и падает, если число SQL-запросов превысило порог. Порог задаёте по факту, а не по интуиции.
- Логируйте медленные запросы на стороне СУБД (в PostgreSQL —
log_min_duration_statement), чтобы видеть деградацию в проде до жалоб.
Следующий шаг после внедрения этих замечаний в практику — договориться в команде, какой порог числа запросов на один HTTP-ответ считается нормой для вашего проекта. Без общего числа спор «это медленно / это нормально» на каждом ревью повторяется заново.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Yii 2 Guide — Active Record: Eager Loading, Yii 2 Guide — Performance Tuning and Profiling
Частые вопросы
А если N+1 в коде, но данных всегда мало — блокировать merge?
У меня Yii 3, а не Yii 2 — приёмы те же?
with() грузит всё сразу — это не хуже, чем много мелких запросов?
Как быстро увидеть число запросов, не гоняя профайлер вручную?
beginProfile не показывает данные в панели — что не так?
Стоит ли автоматически блокировать PR по числу запросов в CI?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.