Профилирование Symfony в проде: что ревьюить и что резать
Профилировщик Symfony по умолчанию не работает в prod, а blackfire включают не всегда. Разбираем, где искать реальные тормоза без dev-профайлера и какие паттерны ловить на ревью.

Стандартный сценарий: страница каталога в dev-окружении отдаётся за 80 мс, а в проде тот же роут под реальным трафиком выдаёт 900 мс на 95-м перцентиле. Web Profiler в проде отключён (и правильно), Symfony Profiler хранит данные только в dev/test, а blackfire стоит не на всех проектах. Разбираем, как локализовать источник задержки в боевом Symfony 6.4/7.x и какие места кода стоит помечать на ревью красным ещё до того, как код доедет до прода.
Это разбор для тех, кто отвечает за работающий сервис: где искать медленное, как отличить проблему БД от проблемы сериализации и какие паттерны в PR почти всегда превращаются в N+1 и лишние аллокации под нагрузкой. Инструменты установки blackfire и настройку APM здесь не разбираем — только чтение сигналов и рефакторинг.
Почему dev-профайлер бесполезен для проды
Web Profiler (пакет symfony/web-profiler-bundle) собирает данные через коллекторы и хранит их в кеше профилировщика. В APP_ENV=prod он по умолчанию не активен, и включать его под трафиком нельзя: коллекторы сами по себе добавляют накладные расходы и пишут дампы на диск. Локальные 80 мс врут ещё и потому, что в dev база часто пустая, opcache настроен иначе, а APCu-кеша конфигурации может не быть.
Значит, боевые тормоза ловятся тремя другими путями: постоянный APM (blackfire, Tideways, Datadog), точечное семплирование через профилировщик на part трафика и структурные метки в самом коде — таймеры и счётчики запросов. Ни один из них не заменяет ревью: большинство деградаций видно в диффе раньше, чем в графиках.
Профилировщик показывает, где медленно. Ревью отвечает, почему это попало в прод. Первое лечит инцидент, второе — причину.
Что мерить и на чём
Три сигнала, которые дают 80% диагноза до того, как вы откроете flame graph:
- Число SQL-запросов на запрос HTTP. Это первое, что взрывается от N+1. В dev оно видно в тулбаре, в проде — через логгер Doctrine или счётчик из
SQLLogger(устарел в поздних версиях DBAL — см. ниже про middleware). - Время в БД против времени в PHP. Если суммарное время запросов 40 мс, а ответ 900 мс — проблема не в базе, а в PHP: сериализация, лишние объекты, синхронный HTTP-вызов внутри контроллера.
- Пиковая память на запрос. Гидрация 10 000 сущностей Doctrine в объекты — типичная причина того, что воркер упирается в
memory_limitпод нагрузкой, хотя на одном запросе всё «работает».
Счётчик запросов без dev-профайлера
В Doctrine DBAL 3.x SQLLogger помечен как устаревший, счёт запросов делается через middleware. Точный интерфейс middleware стоит сверить с версией вашего doctrine/dbal в документации — API middleware менялся между 3.x. Схематично идея такая: обёртка над драйвером считает вызовы query/exec и складывает суммарное время. На проде такой счётчик держат за фиче-флагом и включают на доле трафика через заголовок или сэмплинг, а не постоянно.
Если не хотите писать middleware — включите на короткое окно логирование медленных запросов на стороне СУБД (в PostgreSQL это log_min_duration_statement) и смотрите, что реально доходит до базы. Это не профилирование PHP, но моментально отделяет N+1 от медленной сериализации.
Паттерны, которые ловят на ревью
N+1 из ленивой загрузки
Классика: репозиторий вернул коллекцию заказов, а в шаблоне или сериализаторе для каждого дёргается $order->getCustomer()->getName(). Doctrine делает отдельный SELECT на каждый заказ. На 200 заказах это 201 запрос.
// Плохо: связь customer грузится лениво, по одному запросу на заказ
$orders = $this->orderRepository->findBy(['status' => 'paid']);
foreach ($orders as $order) {
// здесь на каждой итерации летит SELECT в customer
$names[] = $order->getCustomer()->getName();
}
// Хорошо: подтягиваем связь одним JOIN через QueryBuilder
// Doctrine ORM 2.x / 3.x
$orders = $this->orderRepository->createQueryBuilder('o')
->addSelect('c') // без addSelect JOIN не устранит N+1
->innerJoin('o.customer', 'c')
->where('o.status = :status')
->setParameter('status', 'paid')
->getQuery()
->getResult();
Формулировка замечания в PR: «Здесь N+1: getCustomer() в цикле без fetch join. Добавь addSelect('c') и innerJoin, иначе на боевом объёме это сотни запросов». Именно с addSelect — один innerJoin без него отфильтрует, но связь всё равно останется ленивой.
Гидрация в объекты там, где нужен массив
Если данные идут прямо в JSON-ответ и не проходят через бизнес-логику сущности, гидрация в объекты Doctrine — лишняя работа: создаются прокси, идентити-мап растёт, память течёт. Для read-only списков берите массивный режим.
// Для больших read-only выборок — скалярный/массивный режим гидрации
use Doctrine\ORM\Query;
$rows = $this->orderRepository->createQueryBuilder('o')
->select('o.id', 'o.total', 'c.name AS customerName')
->innerJoin('o.customer', 'c')
->getQuery()
->getResult(Query::HYDRATE_ARRAY);
// $rows — массивы, а не сущности: нет прокси, меньше памяти
Замечание на ревью: «Эта выборка уходит только в API-ответ, сущности не мутируются — используй HYDRATE_ARRAY и выбирай только нужные поля, а не SELECT o целиком».
Синхронный внешний вызов в контроллере
HTTP-запрос к платёжному шлюзу или отправка письма прямо в теле контроллера добавляет к времени ответа сетевую задержку, которая в проде плавает от 50 мс до нескольких секунд. В профайлере это выглядит как «дыра» — время идёт, а PHP ничего не делает.
На ревью такое выносится в symfony/messenger с асинхронным транспортом. Замечание: «Отправка уведомления блокирует ответ. Заверни в Message и диспатчь через message bus, обработку — в отдельный воркер».
Как разные подходы к профилированию соотносятся
| Способ | Что видно | Накладные расходы | Когда применять |
|---|---|---|---|
| Web Profiler (dev) | Полная картина запроса | Высокие, только dev/test | Локальная отладка, не прод |
| APM (blackfire/Tideways) | Flame graph, БД, память по строкам | Низкие при семплинге | Постоянно на проде, семплинг части трафика |
| Счётчик запросов (middleware) | Число и время SQL | Минимальные | Отлов N+1 на доле трафика |
| Slow query log СУБД | Медленные запросы к базе | Только на стороне БД | Быстрая проверка «база или PHP» |
Когда что выбирать
Инцидент прямо сейчас и есть APM — идёте во flame graph и смотрите самый широкий фрейм. APM нет, но ответ медленный — включаете slow query log СУБД на 10–15 минут: если там пусто, проблема в PHP, а не в базе. Подозрение на N+1 в конкретном роуте — временный счётчик запросов за фиче-флагом на этом роуте. Постоянный мониторинг деградаций — только семплирующий APM, самописные счётчики под полным трафиком сами станут источником нагрузки.
Граничные случаи, где всё ломается
- Кеш конфигурации маскирует проблему. На первом запросе после деплоя Symfony прогревает кеш контейнера и роутинга. Замер на «холодном» воркере покажет ложно высокое время — мерьте после прогрева.
- OPcache и preloading. Если в проде не настроен
opcacheили отличаетсяopcache.memory_consumption, PHP тратит время на компиляцию. Это не видно в профайле уровня приложения — смотрите на уровне PHP-FPM. - Fetch join и пагинация.
addSelectс коллекционной связью ломаетLIMIT: Doctrine не может корректно ограничить строки при JOIN «один ко многим». Для этого используютPaginatorиз ORM, который делает выборку в два запроса. - Гидрация в массив и события.
HYDRATE_ARRAYне создаёт сущности — значит, не сработают lifecycle-события и постзагрузочная логика. Не применяйте к данным, которым нужна доменная обработка.
Что делать дальше
Начните с одного показателя в проде: время в БД против общего времени ответа на самом тяжёлом роуте. Это разделит все ваши проблемы на две кучи и определит, куда копать. Дальше — заведите в pipeline ревью чек-лист из трёх пунктов: fetch join вместо ленивых связей в циклах, HYDRATE_ARRAY для read-only списков, вынос внешних вызовов в messenger. На проде подключите семплирующий APM и настройте алерт на рост числа SQL-запросов на роут — именно этот график первым покажет N+1, который проскочил ревью.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: Symfony Profiler (официальная документация), Doctrine ORM: Improving Performance
Частые вопросы
А если у меня Doctrine DBAL 2.x, там ведь SQLLogger не устарел?
Можно ли включить Web Profiler в проде на короткое время для диагностики?
Fetch join не помог, запросов по-прежнему много — что не так?
HYDRATE_ARRAY ускорил выборку, но сломалась логика — почему?
Как понять, тормозит база или PHP, если APM нет?
Grozит ли включение счётчика запросов деградацией на проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.