Yii под нагрузкой: где утекает время и как это мерить
Приложение на Yii2 держало 40 запросов в секунду, а на пике пошли таймауты. Разбираем, что тормозит первым — ORM, компонент кеша или сама инициализация фреймворка — и как это профилировать.

Типичная картина: приложение на Yii2 спокойно живёт на 40–50 запросах в секунду, а в акционный день при трёхстах одновременных подключениях время ответа со 120 мс уходит за 2 секунды, а часть запросов отваливается по таймауту php-fpm. CPU не в полке, база отвечает быстро на прямых запросах из консоли — а приложение стоит. Разберём, где именно теряется время под нагрузкой в Yii2 (актуально для 2.0.4x), как это измерить встроенными средствами и внешним профайлером, и какие места ломаются первыми.
Угол статьи — поиск узкого места и его устранение под реальной параллельной нагрузкой. Настройку деплоя, выбор веб-сервера и тюнинг PostgreSQL здесь не трогаем — это отдельные темы.
Что тормозит в Yii2 первым
Порядок, в котором всплывают проблемы при росте RPS, почти всегда один и тот же:
- Запросы ActiveRecord в цикле — классический N+1. На 10 записях незаметно, на 500 в списке это 500 отдельных SELECT.
- Гидрация объектов AR — каждая строка результата превращается в объект модели с событиями
afterFind. При выборке тысяч строк это заметная доля времени и памяти. - Кеш, который ходит по сети синхронно — если компонент
cacheнастроен на удалённый Redis/Memcached, каждый промах и каждая запись это round-trip. - Инициализация приложения — сборка контейнера, парсинг конфигов, регистрация компонентов. На высоком RPS без OPcache это отъедает базовое время каждого запроса.
Пока не измерено — не оптимизируйте. Ускорение того, что и так занимает 3% времени ответа, не изменит ничего.
Встроенный профайлер: Yii::beginProfile
Yii2 умеет размечать участки кода метками. Данные видны в Debug-панели (модуль yii2-debug) или пишутся в лог через ProfileTarget.
use Yii;
// оборачиваем подозрительный участок парой меток с одинаковым токеном
Yii::beginProfile('render_catalog', __METHOD__);
$products = Product::find()
->with('category', 'images') // eager loading против N+1
->limit(500)
->all();
$html = $this->renderPartial('_list', ['products' => $products]);
Yii::endProfile('render_catalog', __METHOD__);Метки вкладываются друг в друга: обернув отдельно выборку и отдельно рендер, вы увидите в панели, что из двух заняло больше. Категория (второй аргумент) группирует метки в отчёте.
SQL-запросы Yii профилирует сам — вкладка Database в Debug-панели показывает каждый запрос, его длительность и трассировку до строки кода, откуда он вызван. Именно там N+1 виден невооружённым глазом: 500 одинаковых SELECT с меняющимся ID.
Debug-панель — инструмент для стенда, а не для прода. Она сама добавляет накладные расходы и хранит трассировки. На боевом сервере модуль debug должен быть отключён в конфиге, иначе вы будете мерить его собственные тормоза.Профилирование под параллельной нагрузкой
Встроенный профайлер меряет один запрос. Проблема таймаутов проявляется при конкуренции за ресурсы — соединения к базе, блокировки, пул php-fpm. Тут нужен внешний замер. Снимите профиль CPU на живом процессе под нагрузкой через расширение, которое семплирует стек: подойдёт excimer или встроенный сэмплирующий профайлер, если он собран в вашей сборке PHP. Настройку расширения сверяйте с его документацией — имена ini-параметров зависят от версии.
Нагрузку создавайте инструментом вроде k6 или wrk с тем же профилем параллельности, что в проде: не 1 виртуальный пользователь в цикле, а 100–300 одновременных.
Как убрать основные потери
N+1 и лишняя гидрация
Против N+1 — жадная загрузка связей через with(), как в примере выше. Если после выборки данные только выводятся и модели как объекты не нужны, откажитесь от гидрации совсем — читайте массивами через asArray():
// объекты моделей не создаются, afterFind не вызывается
$rows = Product::find()
->select(['id', 'title', 'price'])
->asArray()
->all();Разница на выборке в несколько тысяч строк — кратная и по времени, и по памяти. Всегда указывайте select() с нужными полями вместо SELECT *: лишние TEXT-поля тянутся по сети и раздувают память процесса.
Кеш и синхронные походы по сети
Если под нагрузкой профиль показывает время в вызовах компонента кеша, проверьте два момента. Первый — не делаете ли вы десятки отдельных $cache->get() там, где можно один multiGet() (метод есть в базовом классе кеша Yii2, проверьте сигнатуру для вашей версии в документации). Второй — dog-pile: когда ключ протух, сотня параллельных запросов одновременно кинулась пересчитывать одно и то же тяжёлое значение. Лечится через getOrSet() с зависимостью и, при необходимости, мьютексом на пересчёт.
// getOrSet: если в кеше нет — вычисляем, кладём и возвращаем
$data = Yii::$app->cache->getOrSet('top_products', function () {
return Product::find()
->where(['is_top' => true])
->asArray()
->all();
}, 300); // TTL 300 секундСтоимость инициализации
Без OPcache каждый запрос заново парсит и компилирует все PHP-файлы фреймворка — а их сотни. Первое, что проверить на проде под нагрузкой: включён ли OPcache и не сбрасывается ли он. Дальше — не держите ли вы тяжёлую логику в конструкторах компонентов и в bootstrap: всё, что перечислено в секции bootstrap конфига, выполняется на каждом запросе до маршрутизации.
Сравнение подходов к выборке данных
| Подход | Когда применять | Цена |
|---|---|---|
AR с with() | Нужны объекты моделей, поведения, связи | Гидрация каждой строки, память на объекты |
AR + asArray() | Данные только выводятся, поведения не нужны | Теряете методы моделей и afterFind |
| Query Builder | Сложные агрегаты, свои JOIN | Пишете SQL-логику руками, нет связей из коробки |
Batch (each()/batch()) | Обработка десятков тысяч строк без OOM | Больше кода, нельзя случайно материализовать всё |
Обработка больших наборов
Если импортируете или пересчитываете большой объём, не грузите всё в память через all(). Итерируйте порциями — batch() отдаёт данные пачками, each() — по одной записи, читая порциями из базы:
// читаем по 1000 строк, но в коде работаем с одной записью за раз
foreach (Order::find()->each(1000) as $order) {
// обработка одного заказа; в памяти держится максимум 1000 строк
}Что ломается первым под нагрузкой
- Пул соединений к базе. Каждый воркер php-fpm держит своё соединение. 300 воркеров — 300 коннектов, и вы упираетесь в
max_connectionsбазы, а не в код. Считайте: воркеры × соединений на запрос. - Память процесса. Выборка без
asArray()и безselect()на большом объёме упирается вmemory_limit— процесс падает, а не тормозит. В логах php-fpm это fatal error по памяти. - Сессии в файлах. Файловый обработчик сессий по умолчанию блокирует файл на время запроса. Два параллельных запроса одного пользователя выстраиваются в очередь. Под нагрузкой на одного пользователя переносите сессии в Redis.
- Логи в синхронный таргет. Запись каждого сообщения сразу в файл или базу под нагрузкой становится узким местом. Проверьте
flushIntervalиexportIntervalу таргетов лога.
Что делать дальше
Порядок действий: включите Debug-панель на стенде, воспроизведите тяжёлую страницу, посмотрите вкладку Database — почти всегда N+1 виден сразу. Затем снимите профиль CPU под параллельной нагрузкой внешним семплером, чтобы увидеть, что реально ест время в бою, а не в единичном запросе. Метрики, за которыми следить в проде: p95 времени ответа, число активных соединений к базе, число запросов на HTTP-запрос (растёт — значит появился новый N+1), использование памяти воркерами. Инвалидация кеша и стратегия его прогрева — отдельная большая тема, здесь мы её не разворачивали.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: Yii2 Guide: Performance Tuning, Yii2 Guide: Query Builder и Active Record
Частые вопросы
У меня Yii2 версии 2.0.15, а не последней — приёмы сработают?
Debug-панель ничего не показывает, хотя модуль включён — почему?
Профилирование замедляет боевой сервер — это нормально?
Заменил AR на asArray, но выигрыш почти незаметен — в чём дело?
Под нагрузкой падают запросы к базе, хотя сама база не загружена — почему?
Стоит ли отказываться от ActiveRecord ради скорости?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.