Ревью PHP-кода под нагрузкой: что ломается первым
На ревью легко пропустить код, который работает на демо и падает на проде. Разбираем замечания, которые стоит писать первыми: запросы в цикле, память на выборках, соединения и блокировки.

Код прошёл ревью, тесты зелёные, на стейджинге всё летает. Через неделю в проде эндпоинт начинает отдавать 504, а free -m показывает, что PHP-FPM съел всю память. Проблема почти всегда была видна на ревью — просто её искали не там. Ниже конкретные формулировки замечаний, которые дают отдачу под нагрузкой, а не абстрактное «обратите внимание на производительность».
Статья про то, что ревьюить в PHP-коде именно с точки зрения нагрузки: запросы к базе, память, внешние соединения, блокировки. Читаемость, именование и архитектуру здесь не трогаем — это отдельный разговор. Ориентир — PHP 8.1–8.3, но большинство замечаний версии не зависят.
N+1 — первое, что стоит искать
Классика, которая на десяти записях в тестовой базе незаметна, а на десяти тысячах кладёт базу. Цикл по коллекции, внутри — обращение к связанной сущности, каждое обращение уходит отдельным запросом.
// Замечание: 1 запрос на заказы + N запросов на клиента внутри цикла
foreach ($orders as $order) {
// ленивое обращение к связи дергает БД на каждой итерации
echo $order->getCustomer()->getName();
}
На ревью это ловится не по коду, а по вопросу: «сколько запросов этот метод сделает, если в $orders придёт тысяча строк?». Если ответ «тысяча плюс один» — замечание блокирующее.
Формулировка замечания: «Здесь N+1: связь customer подгружается лениво в цикле. Нужен eager load — в Doctrine это addSelect/join с fetch join, в Eloquent — with('customer'). Приложи вывод счётчика запросов до и после.» Общее «оптимизируй запросы» автор проигнорирует, конкретное имя метода — исправит.
N+1 не виден в код-ревью по одному файлу. Он виден по вопросу «сколько раз это сходит в базу на реальном объёме». Задавайте этот вопрос к каждому циклу, внутри которого есть обращение к репозиторию или ORM.
Выборка целиком в память
Второй убийца — загрузка большого результата целиком. $repo->findAll() на таблице в полмиллиона строк, экспорт в CSV через сбор всего массива, гидратация объектов там, где хватило бы скалярных значений.
// Замечание: весь результат в памяти разом
$all = $repository->findAll(); // сотни тысяч объектов
foreach ($all as $entity) {
$writer->write($entity->toArray());
}
Под нагрузкой это выражается в двух симптомах: процесс упирается в memory_limit и падает с Allowed memory size exhausted, либо memory_limit задран так высоко, что несколько параллельных запросов исчерпывают память всего сервера.
Что писать в ревью: «Экспорт грузит всю таблицу в память. Нужна пакетная выборка (батчами по N строк) или потоковый курсор. В Doctrine — iterate/toIterable по query, в Eloquent — cursor() или chunk(). После каждого батча вызывать clear(), иначе UnitOfWork копит объекты.»
Отдельно проверяйте гидратацию: если из объекта нужны два поля, а ORM собирает граф сущностей, замечание — перейти на массивы или скалярную гидратацию. Экономия памяти на большой выборке измеряется разами.
Соединения: пулы, таймауты, повисшие сокеты
PHP по модели «запрос — процесс» не держит долгоживущий пул соединений сам по себе. Каждый вызов внешнего сервиса — новое соединение, если не настроено иначе. Под нагрузкой это выливается в исчерпание портов и в подвисшие запросы без таймаута.
- HTTP-клиент без таймаута. Запрос к чужому API без
timeoutиconnect_timeout— это воркер, который висит, пока чужой сервис не ответит. Триста таких воркеров — и FPM-пул исчерпан, сайт отдаёт 502, хотя ваша база в порядке. - Соединение с базой на каждый запрос. Само по себе нормально для PHP, но если между приложением и базой нет пулера (PgBouncer для PostgreSQL), при всплеске трафика база упирается в
max_connectionsи начинает отклонять новые коннекты. - Отсутствие ретраев с ограничением. Ретрай без предела и без backoff под нагрузкой превращает единичный сбой зависимости в лавину повторов, которая добивает и её, и вас.
Формулировка замечания к HTTP-вызову: «У клиента не задан timeout. Под нагрузкой один медленный ответ внешнего API заблокирует воркер на дефолтное время ожидания. Проставь connect_timeout и timeout явно, лимит выбери из SLA этого сервиса.»
Блокировки и гонки при конкурентной записи
Код, который читает значение, что-то с ним делает и пишет обратно, на одном пользователе работает всегда. На двух одновременных запросах — теряет обновления.
// Замечание: read-modify-write без блокировки — гонка
$balance = $account->getBalance(); // прочитали 100
$account->setBalance($balance - 50); // посчитали в PHP
$em->flush(); // записали 50
// параллельный запрос сделал то же — одно списание потерялось
Под нагрузкой это не падение, а тихая порча данных — хуже 500-й ошибки, потому что заметят не сразу. На ревью такой код ловится по паттерну: значение прочитано из базы, изменено в PHP, записано обратно, и между чтением и записью нет ни транзакции с блокировкой строки, ни атомарного UPDATE.
Что предлагать: атомарный запрос вида UPDATE accounts SET balance = balance - 50 WHERE ..., пессимистичную блокировку (SELECT ... FOR UPDATE) или оптимистичную через версионное поле. Выбор зависит от частоты конфликтов — но замечание о самой гонке обязательно.
Приоритеты замечаний
Не все замечания под нагрузкой одинаково срочны. Ориентир, что блокирует мёрж, а что можно оставить на потом:
| Замечание | Симптом в проде | Блокирует мёрж |
|---|---|---|
| N+1 на растущей коллекции | рост latency и нагрузки на БД | да |
| Выборка всей таблицы в память | OOM воркера, падение FPM | да |
| HTTP-вызов без таймаута | повисшие воркеры, 502/504 | да |
| Read-modify-write без блокировки | потерянные обновления | да |
| Ретрай без backoff | лавина повторов при сбое | по контексту |
| Гидратация объектов вместо массивов | лишняя память на выборках | нет, если объём мал |
Критерий простой: если поведение зависит от объёма данных или числа параллельных запросов и деградирует нелинейно — замечание блокирующее. Если это перерасход ресурсов, но система остаётся корректной, — комментарий на будущее.
Как отделить реальную проблему от предположения
Ревью под нагрузкой легко скатывается в спор «а вдруг тут станет медленно». Чтобы замечание было предметным, требуйте от автора цифру или замер, а не мнение.
- Счётчик запросов. Symfony Profiler, Laravel Telescope или Debugbar показывают число SQL-запросов на действие. Скриншот до/после снимает спор об N+1.
- Память.
memory_get_peak_usage(true)в конце тяжёлого действия на реалистичном объёме данных. Если пик близок к memory_limit — проблема есть. - План запроса.
EXPLAINна сгенерированном ORM SQL: увидите Seq Scan там, где ждали Index Scan. - Нагрузочный прогон. k6 или ab на эндпоинт с реальной параллельностью — гонки и исчерпание соединений всплывают именно здесь, а не в юнит-тестах.
Что делать дальше
Заведите в шаблоне pull request чек-лист из четырёх вопросов: сколько запросов на реальном объёме, сколько памяти на пиковой выборке, есть ли таймаут на каждом внешнем вызове, защищена ли конкурентная запись. Это отсекает большинство деградаций до прода.
Дальше — метрики: подключите APM (Sentry Performance, New Relic или связку Prometheus плюс экспортер FPM), настройте алерт на медленные запросы к базе и на приближение числа коннектов к max_connections. Профилировщик на ревью показывает потенциал проблемы, метрики в проде — её реализацию. Смотреть надо на оба.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: PHP Manual — Runtime Configuration (memory_limit и др.), Doctrine ORM — Batch Processing
Частые вопросы
N+1 не всегда зло — иногда join дороже отдельных запросов?
А если у меня Swoole или RoadRunner, а не классический FPM?
Как проверить N+1, если нет профайлера под рукой?
Стоит ли поднимать memory_limit, если выборка не влезает?
Замечание про гонку данных, а в коде уже есть транзакция — этого достаточно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.