Легаси на Yii2 под нагрузкой: что ломается первым
Старый проект на Yii2 держал сотню запросов в минуту, а на распродаже упал при трёхстах в секунду. Разбираем, какие места приложения отваливаются раньше остальных и почему.

Типичная картина: интернет-магазин на Yii2 версии 2.0.x живёт пять лет, код местами написан ещё под PHP 5.6, но всё работает. Приходит трафик — рекламная кампания, сезон, репост — и приложение начинает отдавать 502 и 504 при 250–350 одновременных запросах. База при этом почти не нагружена, а PHP-FPM забит воркерами в статусе Reading. Ниже — про то, что в легаси на Yii2 ломается раньше всего под нагрузкой, в каком порядке это лечить и на что смотреть в замерах.
Речь именно про рефакторинг под нагрузку, а не про общее «улучшить код». Читаемость, покрытие тестами и структура модулей — отдельная тема, здесь их не трогаем. Смотрим только на то, что деградирует при росте RPS.
Что ломается первым и почему
В старом Yii2-проекте узкие места почти всегда идут в одном и том же порядке. Не потому, что так задумано, а потому, что новички и спешащие разработчики совершают одни и те же ошибки.
- ActiveRecord в цикле (N+1). Список из 30 товаров тянет за собой 30 запросов за категорией, ещё 30 за остатками, ещё 30 за картинками. При 300 RPS это тысячи мелких запросов в секунду. База отвечает быстро на каждый, но их слишком много.
- Сессии в файлах. По умолчанию
yii\web\Sessionпишет в файлы и блокирует файл сессии на всё время запроса. Два параллельных запроса от одного пользователя выстраиваются в очередь. AJAX-корзина, где вкладка шлёт три запроса разом, начинает тормозить именно здесь. - Отсутствие кеша конфигурации и метаданных схемы. На каждый запрос Yii дёргает схему таблиц (
SHOW FULL COLUMNS), еслиschemaCacheне включён. - Тяжёлый bootstrap. Десятки компонентов в
componentsконфига, часть из которых инициализируется на каждый запрос, даже когда не нужна. - Логирование в файл синхронно. При включённом trace-уровне каждый запрос пишет мегабайты, и диск становится узким местом раньше процессора.
Под нагрузкой первым ломается не то, что медленнее всего, а то, что вызывается чаще всего. Один лишний запрос в цикле безвреден на превью и убивает базу на распродаже.
N+1: находим и убираем
Самая частая причина деградации. Найти проще всего через yii\debug\Module (панель отладки) на копии прода: она показывает число запросов на страницу. Если на списке из 30 сущностей вы видите 90+ запросов — это N+1.
Лечится жадной загрузкой через with(). Было:
// Yii2 2.0.x — плохо: N+1
$products = Product::find()->limit(30)->all();
foreach ($products as $product) {
// на каждой итерации отдельный SELECT
echo $product->category->name;
echo $product->stock->quantity;
}
Стало:
// Yii2 2.0.x — жадная загрузка одним пакетом запросов
$products = Product::find()
->with(['category', 'stock'])
->limit(30)
->all();
foreach ($products as $product) {
// связи уже загружены, новых запросов нет
echo $product->category->name;
echo $product->stock->quantity;
}
Вместо 61 запроса (1 + 30 + 30) получаем 3: сами товары, все категории одним IN и все остатки одним IN. На карточке товара с десятком связей эффект нагляднее.
Осторожно с joinWith(): он делает JOIN и может раздувать выборку при связях «один ко многим». Для фильтрации по связанной таблице он нужен, для простого доступа к данным связи — with() обычно дешевле.
Сессии: убрать блокировку файла
Файловые сессии — тихий убийца параллельных запросов. Пока один запрос держит сессию открытой, остальные от того же клиента ждут. Перенос в Redis решает проблему: там блокировка либо короче, либо снимается явно.
Настройка компонента в конфиге приложения:
// config/web.php, Yii2 2.0.x, пакет yiisoft/yii2-redis
'components' => [
'redis' => [
'class' => 'yii\redis\Connection',
'hostname' => '127.0.0.1',
'port' => 6379,
'database' => 0,
],
'session' => [
'class' => 'yii\redis\Session',
],
],
Второй приём, который снимает нагрузку сразу: закрывать сессию как можно раньше в запросах, которым запись в сессию не нужна. После чтения нужных данных вызовите Yii::$app->session->close() — блокировка снимется, и параллельные запросы перестанут ждать. Особенно помогает на AJAX-эндпоинтах, которые только читают.
Кеш схемы и продовый режим
Две настройки, которые дают заметный выигрыш почти бесплатно.
Первая — кеш метаданных схемы у компонента db. Без него ActiveRecord на каждый запрос узнаёт структуру таблиц заново:
// config/web.php
'components' => [
'cache' => [
'class' => 'yii\caching\FileCache', // или yii\redis\Cache
],
'db' => [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=localhost;dbname=shop',
'enableSchemaCache' => true,
'schemaCacheDuration' => 3600,
'schemaCache' => 'cache',
],
],
Вторая — убедиться, что приложение работает в продовом режиме. В точке входа web/index.php при боевой нагрузке не должно быть:
// НЕ должно быть на проде
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'dev');
При YII_DEBUG = true Yii собирает трейсы, подробные логи и держит debug-модуль. Один этот флаг способен утроить время ответа под нагрузкой. Значения задаются через переменные окружения веб-сервера, а не правкой файла на бою.
Логи: асинхронно и по уровню
Синхронная запись логов в файл на каждый запрос упирается в диск. Два шага: поднять минимальный уровень логирования до error/warning на проде и увеличить flushInterval, чтобы Yii писал пачками, а не по одной строке.
// config/web.php
'components' => [
'log' => [
'flushInterval' => 1000, // накапливать сообщения перед сбросом
'targets' => [
[
'class' => 'yii\log\FileTarget',
'levels' => ['error', 'warning'],
'exportInterval' => 1000,
],
],
],
],
Замеры: что и как мерить
Не оптимизируйте вслепую. Порядок такой: снять базовую картину, изменить одно место, снять снова. Нужны как минимум три источника цифр.
| Что смотрим | Чем | Сигнал проблемы |
|---|---|---|
| Число SQL-запросов на страницу | Debug-панель Yii на копии | Рост запросов пропорционально числу сущностей — N+1 |
| Время ответа под RPS | Нагрузочный тест (например, k6, wrk) | Резкий рост p95 после определённого RPS |
| Статусы воркеров PHP-FPM | Страница статуса FPM | Много воркеров в Reading/Writing — упор в I/O или сессию |
| Медленные запросы | slow query log MySQL | Запросы без индекса, полные сканы таблиц |
Условия замера фиксируйте: версия PHP, число воркеров FPM, объём тестовых данных, уровень параллельности нагрузочного теста. Замер на пустой базе из 100 товаров ничего не скажет о поведении на 200 тысячах.
Где подход ломается
Жадная загрузка через with() перестаёт помогать, когда связь тянет тысячи строк на каждую родительскую запись — тогда один IN-запрос сам становится тяжёлым. В таких случаях нужна пагинация связей или отдельная агрегация, а не with().
Redis-сессии переносят проблему на сеть и на сам Redis: если он один и без реплики, вы получаете новую точку отказа. Под серьёзной нагрузкой это осознанный размен, а не бесплатный выигрыш.
Кеш схемы нужно инвалидировать при миграциях — иначе после изменения таблицы приложение будет работать со старой структурой из кеша до истечения TTL. Инвалидация кеша при деплое — отдельная тема, здесь только отмечаем риск.
Что делать дальше
Порядок работ, который даёт результат быстрее всего: сначала выключить debug на проде и включить schemaCache — это минуты работы и заметный эффект. Затем найти и убрать N+1 на самых посещаемых страницах по данным debug-панели. Потом перенести сессии в Redis и закрывать их рано на read-only эндпоинтах. Логи — последним из «дешёвых» шагов.
После этого смотрите на p95 времени ответа под целевым RPS и на slow query log. Если база всё ещё узкое место — следующий уровень это индексы под реальные запросы и кеширование готовых фрагментов через Yii::$app->cache, но это уже другой разбор.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Yii2 Guide: Query Builder и Active Record, Yii2 Guide: Caching (Schema Caching)
Частые вопросы
У меня Yii2 версии 2.0.13, а не последняя 2.0.x — приёмы сработают?
Убрал N+1 через with(), а стало не быстрее — почему?
Грозит ли перенос сессий в Redis потерей активных сессий пользователей?
Можно ли просто увеличить число воркеров PHP-FPM вместо рефакторинга?
Как понять, что дело именно в N+1, а не в медленном отдельном запросе?
Стоит ли включать debug-панель на проде, чтобы ловить N+1 в реальном времени?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.