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

Типичная картина: приложение на Laravel 11 в разработке отвечает за 80 мс, на стейдже держит 50 запросов в секунду, а на продакшене под 300 одновременными запросами время ответа уходит в 3–4 секунды, PHP-FPM забивает пул, Nginx начинает отдавать 502 Bad Gateway. Проблема почти никогда не в «медленном коде» как таковом — она в том, что несколько узких мест сходятся в одной точке. Разберём порядок, в котором они обычно ломаются, и что мерить, чтобы увидеть виновника раньше, чем это сделают пользователи.
Всё ниже — про типовой стек: Laravel 10/11, PHP 8.2+, PHP-FPM за Nginx, PostgreSQL или MySQL, Redis для кеша и очередей. Замеры зависят от вашего железа и данных, поэтому конкретные миллисекунды здесь — иллюстрация механизма, а не обещание. В конце — что и чем измерять у себя.
Что ломается первым: пул PHP-FPM
Раньше базы, раньше Redis, раньше вашего кода в 90% случаев исчерпывается пул воркеров PHP-FPM. Каждый HTTP-запрос к Laravel занимает один процесс FPM на всё время обработки. Если pm.max_children равен 20, то 21-й одновременный запрос встаёт в очередь и ждёт освобождения воркера — даже если сам он выполнился бы за 10 мс.
Механизм простой и жестокий. Пусть средний запрос держит воркер 200 мс (в основном ожидая ответа БД). Двадцать воркеров дают потолок примерно 20 / 0.2 = 100 запросов в секунду. На 300 rps очередь растёт неограниченно, время ответа складывается из времени ожидания в очереди плюс собственно обработки — отсюда те самые 4 секунды. Nginx по таймауту рвёт соединение и отдаёт 502.
Медленный запрос к базе на 200 мс сам по себе терпим. Но он держит воркер FPM 200 мс, и именно это — а не запрос — кладёт сервис под нагрузкой.
Наивная реакция — поднять pm.max_children до 200. Это перекладывает проблему на память и на базу. Каждый воркер FPM с загруженным Laravel-приложением занимает от 40 до 120 МБ. Двести воркеров — это 8–24 ГБ только под PHP, а следом 200 воркеров одновременно долбят в базу, у которой пул соединений куда меньше.
Как считать max_children
Отталкивайтесь от памяти, а не от желаемого rps:
- Замерьте реальное потребление одного воркера под вашим приложением:
psпо процессамphp-fpmв момент нагрузки, берите RSS с запасом. - Оставьте память под ОС, Nginx, локальный Redis и файловый кеш ОС.
max_children ≈ (доступная память под PHP) / (память на воркер).
Если по этой формуле выходит 40 воркеров, а вам нужно держать 300 rps — значит проблема не в FPM, а в том, что каждый запрос слишком долго держит воркер. Дальше ищем, на чём именно.
Второе узкое место: N+1 и число запросов к базе
Воркер держится занятым ровно столько, сколько ждёт БД. Классический N+1 — когда на список из 50 сущностей вы делаете 1 запрос на список и 50 запросов на связанные данные — превращает страницу из 3 запросов в 53. Под нагрузкой это не «страница медленнее грузится», а «воркер занят в 15 раз дольше и пул FPM исчерпывается в 15 раз раньше».
Проблема в том, что в разработке на пустой базе 53 запроса выполняются за те же 40 мс, что и 3, и вы её не видите. Она проявляется только под конкурентной нагрузкой на боевых объёмах.
Ловить N+1 нужно до прода. В Laravel есть строгий режим Eloquent, который выбрасывает исключение при попытке лениво загрузить незагруженную связь. Включается в AppServiceProvider:
use Illuminate\Database\Eloquent\Model;
// AppServiceProvider::boot(), Laravel 9+
// preventLazyLoading — падает при ленивой загрузке связи
public function boot(): void
{
// В проде можно не бросать исключение, а логировать,
// чтобы не ронять запросы из-за пропущенного eager load
Model::preventLazyLoading(! app()->isProduction());
}
После этого забытый with() вылезет в тестах, а не в проде. Сам fix — стандартный eager loading:
// Было: N+1 — по одному запросу на автора каждого поста
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
}
// Стало: 2 запроса вместо N+1
$posts = Post::with('author')->get();
Проверка на нагрузке: включите Laravel Telescope на стейдже или логируйте число запросов на request через DB::getQueryLog() — тяжёлые эндпоинты не должны делать десятки запросов на один HTTP-вызов.
Третье: пул соединений к базе и сами соединения
Laravel по умолчанию открывает новое соединение с БД на каждый воркер FPM и держит его на время запроса. Если у вас 100 воркеров FPM на трёх серверах приложений — это до 300 попыток одновременных соединений к PostgreSQL, у которого max_connections по умолчанию около 100.
Когда лимит соединений исчерпан, PostgreSQL начинает отказывать в новых, и Laravel падает с ошибкой соединения — под нагрузкой, в самый неудачный момент. Ставить max_connections в тысячу нельзя: каждое соединение PostgreSQL — это отдельный процесс со своей памятью, тысяча соединений съест сервер БД.
Решение — пулер соединений между приложением и базой, например PgBouncer в режиме transaction pooling. Он держит небольшой пул реальных соединений к PostgreSQL и мультиплексирует на них сотни коннектов от приложения. Важная оговорка: в transaction-режиме нельзя использовать серверные prepared statements без настройки — для PostgreSQL-драйвера это отдельный параметр, сверьтесь с документацией PgBouncer и настройками вашего драйвера, прежде чем включать в проде.
Четвёртое: кеш, сессии и очереди на файлах
Дефолты Laravel хороши для разработки и опасны под нагрузкой:
| Что | Дефолт | Почему ломается | Куда перевести |
|---|---|---|---|
| Кеш | database / file | Каждый Cache::get — запрос в БД или чтение файла с блокировкой | Redis / Memcached |
| Сессии | file | Блокировка файла сессии сериализует параллельные запросы одного юзера | Redis |
| Очереди | sync | Задача выполняется прямо в HTTP-запросе — воркер FPM занят отправкой письма | Redis + отдельный worker |
Блокировка файла сессии — недооценённая ловушка. Пока один запрос пользователя держит открытой файловую сессию, второй его запрос ждёт снятия блокировки. Для SPA, которое шлёт несколько параллельных XHR, это превращает конкурентные запросы в последовательные. Перевод сессий в Redis снимает блокировку.
Очередь на sync означает, что тяжёлые операции — отправка почты, генерация PDF, обращение к внешнему API — выполняются внутри HTTP-запроса. Внешний API отвечает за 2 секунды — воркер FPM занят эти 2 секунды. Всё, что не нужно пользователю прямо сейчас, выносите в очередь и обрабатывайте отдельным процессом php artisan queue:work.
Пятое: холодный старт фреймворка на каждый запрос
Laravel на каждый запрос загружает провайдеры, читает конфиги, роуты и события. В разработке это удобно, в проде — накладные расходы на каждом из сотен запросов в секунду. Три команды кеширования снимают заметную часть:
# Выполнять при деплое, не в разработке
php artisan config:cache
php artisan route:cache
php artisan event:cache
Важно: после config:cache вызовы env() вне конфиг-файлов вернут null. Все обращения к окружению должны идти через config(), иначе поведение в проде разойдётся с разработкой. Это частая причина «на локали работает, на проде — нет».
Отдельно — OPcache на уровне PHP: без него интерпретатор перекомпилирует байткод на каждом запросе. Проверьте, что OPcache включён и opcache.validate_timestamps в проде выключен (иначе PHP на каждый запрос проверяет mtime файлов).
Где эта картина ломается
Порядок «FPM → база → соединения → кеш → бутстрап» — типичный, но не универсальный:
- Если приложение CPU-bound (тяжёлая обработка изображений, шифрование в запросе) — упрётесь в процессор раньше, чем в пул FPM, и добавление воркеров только ускорит деградацию.
- Если ходите во внешние API синхронно — узким местом станет их latency, и никакие настройки вашего сервера не помогут. Выносите в очередь или ставьте жёсткие таймауты на HTTP-клиент.
- При работе на Laravel Octane (Swoole/FrankenPHP) модель другая: приложение живёт в памяти между запросами, холодного старта нет, но появляются утечки состояния между запросами — это отдельная тема.
Что делать дальше
Не гадайте — измеряйте под реалистичной нагрузкой:
- Прогоните нагрузочный тест (
k6,wrkилиab) на стейдже с боевым профилем запросов и следите, что упирается первым: очередь FPM, CPU, коннекты БД. - Смотрите
listen.backlogи статус пула FPM (pm.status_path) — растущая очередь активных процессов означает нехватку воркеров или медленные запросы. - Включите slow query log в БД и Telescope на стейдже, найдите эндпоинты с десятками запросов на request.
- Переведите кеш, сессии и очереди на Redis, вынесите тяжёлое в фоновые задачи.
- Закешируйте конфиг, роуты и события при деплое, проверьте OPcache.
Ключевой показатель для наблюдения в проде — время удержания воркера FPM (среднее и p95). Пока оно низкое, пул из скромного числа воркеров держит высокий rps. Как только оно растёт — ищите, что именно заставляет воркер ждать: базу, внешний API или блокировку.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: Laravel Deployment — Optimization (официальная документация), Laravel Eloquent Relationships — Eager Loading & Preventing Lazy Loading
Частые вопросы
У меня Laravel Octane — эти советы применимы?
Я поднял max_children, а стало только хуже. Почему?
Нагрузочный тест на стейдже показал 200 rps, а прод падает на 100. В чём подвох?
preventLazyLoading роняет запросы в проде, это безопасно?
Redis сам не станет узким местом?
config:cache сломал приложение — env() возвращает null. Что делать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.