Жизненный цикл запроса в Laravel под нагрузкой: что ломается первым
Приложение на Laravel держит 50 запросов в секунду, а на 300 начинает отдавать 502 и есть память. Разбираем, где именно в цикле запроса возникает узкое место и что чинить в первую очередь.

Типичная картина: приложение на Laravel 11 работает на PHP-FPM за Nginx. При 50 запросах в секунду ответ отдаётся за 80 мс. При 300 одновременных начинаются 502 Bad Gateway, время ответа скачет до 5-10 секунд, а в логах FPM висит server reached pm.max_children. Кода не меняли — изменилась только нагрузка. Разберём, что в жизненном цикле запроса ломается раньше остального и почему.
Речь идёт о синхронной модели PHP-FPM, где на каждый запрос выделяется отдельный воркер, живущий от начала до конца обработки. Если вы на Octane (Swoole/RoadRunner) — поведение под нагрузкой другое, это отдельный разбор. Здесь только классический FPM, самый частый прод-сценарий.
Как выглядит путь запроса
Прежде чем искать узкое место, зафиксируем порядок. Запрос в Laravel проходит примерно так:
- Nginx принимает соединение и проксирует через FastCGI в PHP-FPM.
- FPM выделяет свободного воркера из пула и запускает
public/index.php. - Composer-автозагрузчик подтягивает классы, создаётся ядро HTTP (
Kernel). - Бутстрап: загрузка конфигурации, регистрация сервис-провайдеров, их
boot(). - Запрос проходит стек глобальных middleware, затем middleware группы и роута.
- Роутинг подбирает контроллер, контейнер разрешает его зависимости.
- Контроллер работает с БД, кешем, внешними API — формирует ответ.
- Ответ идёт обратно через middleware, воркер отдаёт его FPM и освобождается.
Под нагрузкой ломается не тот шаг, который дольше всех сам по себе, а тот, где раньше всего заканчивается общий ресурс: воркеры FPM, соединения к БД или память процесса. Именно в таком порядке они обычно и кончаются.
Первым кончаются воркеры FPM
Каждый одновременный запрос занимает одного воркера на всё время обработки, включая ожидание ответа от базы или внешнего API. Если pm.max_children = 20, а 300 клиентов ждут медленный внешний запрос по 2 секунды, то 280 из них стоят в очереди FPM. Nginx ждёт свободного воркера дольше fastcgi_read_timeout — и отдаёт 502.
Ключевой момент: воркер занят не потому, что PHP считает, а потому, что он ждёт. Медленный SELECT или внешний HTTP-вызов держит воркера так же, как тяжёлый цикл. Синхронная модель не умеет отпускать воркера на время ожидания.
Под нагрузкой воркер FPM — самый дефицитный ресурс. Всё, что заставляет запрос ждать, тратит его целиком. Сначала оптимизируют не код, а длительность удержания воркера.
Как понять, что упёрлись именно сюда: в логе FPM появляется строка WARNING: [pool www] server reached pm.max_children setting (N), consider raising it. Это прямой сигнал. Мониторьте listen queue через страницу статуса FPM — если она стабильно не пустая, воркеры не успевают.
Что мерить
Включите страницу статуса пула в конфиге пула FPM (pm.status_path), затем следите за полями active processes, listen queue и max children reached. Рост последнего счётчика — момент, когда начались отказы.
Вторым кончаются соединения к базе
Наивный ответ на нехватку воркеров — поднять pm.max_children. Но каждый воркер под нагрузкой открывает соединение к PostgreSQL или MySQL. Подняли воркеров с 20 до 200 — и упёрлись в max_connections базы (в PostgreSQL по умолчанию 100). База начинает отвечать too many connections, и теперь падает уже она, а с ней всё приложение сразу.
Laravel по умолчанию не использует пул соединений на стороне приложения — каждый запрос открывает своё. При высокой параллельности это означает: число активных соединений к БД примерно равно числу активных воркеров FPM. Отсюда правило: max_children нельзя задирать в отрыве от лимитов базы.
Решение — не бесконечно растить воркеры, а поставить внешний пулер соединений. Для PostgreSQL это PgBouncer в режиме transaction pooling: он держит небольшое число реальных соединений к базе и мультиплексирует между ними сотни клиентских. Приложение думает, что соединений много, база видит десятки.
Подводный камень: в режиме transaction pooling не работают серверные prepared statements и сессионные настройки между запросами. Для Laravel это обычно не проблема, но проверьте, что не полагаетесь на такое поведение.
Третьей кончается память воркера
Проблема, которую видно позже всех, — рост потребления памяти на запрос. Один воркер FPM с лимитом memory_limit = 256M кажется безопасным. Но 200 воркеров по 256M — это до 50 ГБ теоретического потолка. На практике до лимита не доходит, но именно память чаще всего убивает сервер под нагрузкой через OOM Killer, который прибивает FPM целиком.
Классический источник — выгрузка коллекции целиком там, где нужен курсор:
// Плохо: грузит все строки в память разом.
// При 500 000 записей воркер съедает сотни мегабайт.
foreach (Order::where('status', 'pending')->get() as $order) {
$this->process($order);
}
// Лучше: chunkById обрабатывает пачками по 1000,
// в памяти одновременно только одна пачка.
Order::where('status', 'pending')
->chunkById(1000, function ($orders) {
foreach ($orders as $order) {
$this->process($order);
}
});Под низкой нагрузкой оба варианта отвечают одинаково. Под высокой первый вариант, запущенный в 200 воркерах одновременно, исчерпывает RAM сервера. Пример проверялся на Laravel 11 с Eloquent; chunkById и lazy() доступны и в актуальных версиях — точную сигнатуру и поведение сверяйте с разделом Eloquent в документации вашей версии.
Второй частый источник роста памяти
N+1 запросы. Не столько по памяти, сколько по удержанию воркера: страница со списком из 50 записей и жадной загрузкой связей вместо одного запроса делает 51. Каждый круглый рейс к базе — микроожидание, а под нагрузкой микроожидания складываются в занятые воркеры. Ловите N+1 через DB::listen() в дебаге или через ленивую загрузку с включённым Model::preventLazyLoading() в non-production окружении.
Где ломается порядок бутстрапа
На каждый запрос Laravel регистрирует и бутстрапит сервис-провайдеры. Если вы разрабатываете без кеша конфигурации и роутов, под нагрузкой это заметная доля времени старта. Разница между «холодным» приложением и прогретым — это команды кеширования, которые обязаны выполняться на деплое, а не быть забытыми.
php artisan config:cache— собирает всю конфигурацию в один файл, отключает чтение.envна каждый запрос.php artisan route:cache— сериализует таблицу роутов.php artisan event:cache— кеширует привязки событий и слушателей.- OPcache на уровне PHP — без него автозагрузчик перечитывает и компилирует файлы каждый раз.
Важный нюанс: после config:cache вызовы env() вне конфиг-файлов возвращают null. Если у вас env() раскидан по контроллерам, кеш конфига сломает приложение неочевидным образом. Это регулярный источник инцидентов при первом переходе на кеширование конфигурации.
Порядок диагностики
Когда приложение начало отдавать 502 под нагрузкой, идите по ресурсам в порядке их исчерпания, а не по коду:
| Симптом | Где смотреть | Первое действие |
|---|---|---|
502, max_children reached в логе FPM | Статус пула FPM, listen queue | Найти, что держит воркеров: медленная БД или внешний API |
too many connections от БД | SHOW STATUS / pg_stat_activity | Поставить PgBouncer, не растить воркеры |
| Сервер уходит в swap, OOM в dmesg | Потребление памяти воркерами | Найти выгрузку коллекций через get(), заменить на chunk/lazy |
| Медленный старт даже без нагрузки | Наличие кешей config/route, OPcache | Выполнить команды кеширования на деплое |
Что делать дальше
Первым делом снимите базовые метрики под реальной нагрузкой: активные воркеры FPM, число соединений к БД и потребление памяти на процесс. Без этих трёх цифр любой тюнинг — угадывание. Настройте страницу статуса FPM и включите логи медленных запросов (request_slowlog_timeout в конфиге пула) — она покажет, на какой строке кода воркер застревает под нагрузкой.
Когда упрётесь в потолок синхронной модели даже после пулера соединений и вычистки N+1 — смотрите в сторону Laravel Octane, где приложение бутстрапится один раз и держится в памяти между запросами. Но это меняет модель жизненного цикла целиком и требует отдельной проверки кода на утечки состояния между запросами.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Laravel — Request Lifecycle, PgBouncer — Features and pooling modes
Частые вопросы
У меня Laravel Octane, а не PHP-FPM. Применимо ли это?
Почему просто не поднять pm.max_children побольше?
Как понять, что воркер держится из-за медленной базы, а не из-за кода?
config:cache сломал приложение — env() возвращает null. Что делать?
Нужен ли PgBouncer, если у меня MySQL?
chunkById есть в моей версии Laravel?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.