Профилирование Symfony под нагрузкой: что ломается первым
Symfony-приложение отдаёт 502 на пиковой нагрузке, а локально всё летает. Разбираем, где реально уходит время под трафиком и чем профилировать прод без остановки сервиса.

Типичная картина: локально страница отвечает за 40 мс, на стейдже — 60 мс, а в проде на 200–300 одновременных запросах начинаются таймауты и 502 от Nginx. Symfony Profiler, который спасает в разработке, в проде выключен, и правильно: он сам по себе съедает память и замедляет каждый запрос. Ниже — что ломается в Symfony-приложении первым при росте трафика, чем это увидеть без остановки сервиса и какие цифры смотреть. Ориентируюсь на Symfony 6.4/7.x и PHP 8.2+ с PHP-FPM.
Почему прод ведёт себя не как локалка
Локальный запрос живёт в одиночестве: у него весь пул соединений к базе, свободный CPU и тёплый файловый кеш. Под нагрузкой каждый запрос конкурирует за ограниченные ресурсы. Узкое место сдвигается: то, что на одном запросе занимает 2 мс, на трёхстах параллельных может превратиться в очередь на 500 мс.
Symfony добавляет свою специфику. Контейнер зависимостей, сериализация, Doctrine ORM, event listeners — всё это работает предсказуемо на одном запросе и начинает вести себя нелинейно, когда PHP-FPM обслуживает десятки процессов одновременно.
Профилирование под нагрузкой — это не «найти медленную функцию». Это найти ресурс, за который дерутся запросы: соединение к базе, процесс FPM, память или блокировку в Redis.
Что ломается первым: порядок по частоте
По опыту разбора инцидентов в Symfony-проектах очередь отказов почти всегда одна и та же.
1. Пул PHP-FPM упирается в pm.max_children
Nginx отдаёт 502, а в логах FPM появляется server reached pm.max_children setting. Это значит, что все процессы заняты, а новые запросы висят в очереди listen.backlog и отваливаются по таймауту. Симптом обманчивый: CPU и база при этом могут быть недогружены — процессы просто ждут ответа от медленного внешнего вызова (база, HTTP-клиент, кеш).
Как увидеть: включите статус-страницу FPM. В www.conf:
; php-fpm pool config, PHP 8.2
pm.status_path = /fpm-status
; сколько процессов сейчас активны, сколько в очереди
; смотрим active processes и listen queue
Дёргаете /fpm-status?full и смотрите active processes против total processes и listen queue. Если active упёрлось в потолок, а listen queue растёт — процессов не хватает, но сначала выясните почему они заняты, а не просто поднимайте max_children: если каждый процесс ждёт базу, добавление процессов только добьёт базу.
2. Doctrine и N+1 под трафиком
N+11 локально стоит копейки: 50 мелких запросов к тёплой базе. Под нагрузкой те же 50 запросов на каждый HTTP-запрос умножаются на сотни параллельных клиентов и выжирают пул соединений PostgreSQL или MySQL. База начинает отвечать медленно всем, включая быстрые запросы.
Как увидеть в проде без Profiler: включите slow query log на стороне базы и логирование медленных SQL через Doctrine middleware. В dev вы бы открыли профайлер, но в проде смотрите на количество запросов на HTTP-request — его пишет monolog-канал doctrine, если поднять уровень логирования точечно.
Точный API логирующего middleware зависит от версии DBAL — сверьте с документацией Doctrine DBAL 3.x/4.x, там менялись интерфейсы логирования между мажорами. Не подставляйте имя класса из старого туториала: в DBAL 3 старый SQLLogger помечен deprecated.
3. Сериализация и нормализация в API
Symfony Serializer при выдаче больших коллекций через API Platform или руками собранный JSON тратит ощутимое время на нормализацию каждого объекта, особенно с рекурсивными связями и группами. На одной сущности незаметно, на выдаче в 500 объектов с вложенностью — десятки миллисекунд CPU на запрос, которые под нагрузкой суммируются.
4. Кеш контейнера и OPcache
Если после деплоя не прогрет кеш (cache:warmup) или сброшен OPcache, первые запросы после релиза компилируют контейнер и файлы заново. На пике это лавина: холодный старт совпадает с трафиком, и время ответа взлетает на секунды.
Чем профилировать прод
Symfony Profiler в проде не вариант. Инструменты, которые работают под нагрузкой:
- Blackfire — профилировщик от создателя Symfony. Профилирует отдельные запросы в проде по требованию, не замедляя остальной трафик. Показывает дерево вызовов, время, память, число SQL. Платный, есть бесплатный тариф.
- Tideways / New Relic / Datadog APM — постоянный мониторинг с сэмплированием. Дают распределение времени ответа по эндпоинтам и разбивку на SQL/PHP/внешние вызовы. Именно распределение, а не среднее — под нагрузкой важен p95/p99.
- excimer — расширение PHP для сэмплирующего профилирования с низким оверхедом. Подходит, когда нельзя ставить коммерческий APM.
- slow query log базы + FPM slowlog — бесплатный минимум, который уже отвечает на вопрос «процессы висят на базе или нет».
FPM slowlog: что процессы делают в момент затыка
Настройте slowlog, чтобы увидеть стек PHP в момент долгого запроса:
; www.conf, PHP-FPM 8.2
slowlog = /var/log/php-fpm/slow.log
; логировать запрос, если он выполняется дольше 2 секунд
request_slowlog_timeout = 2s
В slow.log попадёт backtrace: на какой строке PHP завис процесс. Если это раз за разом одна функция подключения к базе или вызов внешнего API — вы нашли ресурс, который держит процессы.
Метрики, которые смотрят под нагрузкой
Среднее время ответа обманывает: 100 быстрых запросов и 5 висящих по 10 секунд дадут приемлемое среднее и злых пользователей. Смотрите распределение.
| Метрика | Что показывает | Тревожный сигнал |
|---|---|---|
| p95 / p99 latency | Худшие 5% и 1% запросов | p99 в разы выше p50 |
| FPM active processes | Занятость пула | Упёрлось в max_children |
| FPM listen queue | Очередь на процессы | Стабильно > 0 и растёт |
| SQL per request | Признак N+1 | Десятки запросов на один HTTP |
| DB active connections | Давление на базу | Близко к max_connections |
| OPcache hit rate | Работает ли кеш байткода | Ниже 99% под трафиком |
Где подход ломается
Профилирование одного запроса через Blackfire покажет дерево вызовов, но не покажет конкуренцию за ресурс. Запрос, который в изоляции идёт 50 мс, под трафиком идёт 800 мс из-за очереди к базе — и в профиле одиночного запроса вы этого не увидите. Поэтому одиночный профиль дополняйте APM-метриками с боевого трафика.
Второй капкан — сэмплирование. APM берёт не все запросы, а долю. Редкий, но убийственный эндпоинт может не попасть в выборку. Если инцидент точечный, включайте профилирование адресно по URL, а не полагайтесь на общую картину.
Третий — Xdebug в проде. Никогда. Даже с выключенным профайлером само расширение замедляет PHP в разы. Убедитесь, что в проде его нет: php -m | grep -i xdebug должно быть пусто.
Порядок действий при инциденте
- Смотрите FPM status: процессы упёрлись в потолок или нет. Это разводит «мало процессов» и «процессы висят».
- Если висят — FPM slowlog покажет, на чём именно (база, HTTP-клиент, блокировка Redis).
- Если это база — slow query log и число SQL на запрос. N+1 лечится eager loading и
fetch joinв Doctrine. - Проверьте OPcache hit rate и что кеш прогрет после деплоя.
- Только после этого меняйте
pm.max_children— и то с оглядкой на память и лимиты базы.
Что делать дальше
Заведите постоянный мониторинг p95/p99 по ключевым эндпоинтам и алерт на listen queue FPM и число активных соединений базы — это два сигнала, которые загораются раньше, чем пользователи увидят 502. Настройте автоматический cache:warmup в пайплайне деплоя, чтобы холодный старт не совпадал с трафиком. И держите один заранее собранный Blackfire-профиль тяжёлого эндпоинта как эталон: когда что-то деградирует, сравнение с эталоном находит регрессию быстрее, чем чтение логов.
1 N+1 — антипаттерн Doctrine ORM: один запрос грузит список сущностей, а затем на каждую из N сущностей выполняется отдельный запрос за связанными данными. Итого 1 + N запросов вместо одного с join.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Symfony Performance — официальная документация, PHP-FPM Configuration — php.net
Частые вопросы
Можно ли включить Symfony Profiler в проде хотя бы для одного запроса?
У меня Symfony 5.4, а не 7.x — что-то поменяется?
Поднял pm.max_children, а стало только хуже — почему?
Blackfire показывает быстрый запрос, а пользователи жалуются на медленноту. Как так?
Как понять, что проблема в OPcache, а не в коде?
Xdebug точно нельзя оставлять в проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.