Проблема N+1 в Eloquent: как убить лишние запросы к базе
Один цикл по коллекции моделей превращается в сотни запросов к базе. Разбираем, почему Eloquent делает это по умолчанию и как загружать связи так, чтобы страница не тормозила.

Страница со списком из 50 постов и именами авторов делает не 1 запрос, а 51. Один запрос достаёт посты, а дальше на каждой итерации цикла Eloquent тихо ходит в базу за автором. Локально с пустой таблицей вы этого не заметите: 51 быстрый запрос отработает за миллисекунды. На проде с реальной нагрузкой и латентностью до базы 2–3 мс каждый эти лишние 50 запросов превращаются в лишние 100–150 мс на ровном месте — и это только один блок на странице.
Разбираем на Laravel 11.x (актуально и для 10.x): почему ленивая загрузка связей стреляет вам в ногу, как её увидеть, чем лечить и как сделать так, чтобы N+1 не вернулся через месяц после того, как вы про него забыли.
Откуда берутся лишние запросы
Eloquent загружает связи лениво: пока вы не обратились к свойству-связи, запрос к базе не выполняется. Логика разумная — не грузить лишнего. Но в цикле она даёт классический N+1.
// Laravel 11.x
$posts = Post::all(); // запрос 1: SELECT * FROM posts
foreach ($posts as $post) {
// на каждой итерации — отдельный SELECT * FROM users WHERE id = ?
echo $post->author->name;
}Один запрос за постами плюс по одному запросу за автором каждого поста. 50 постов — 51 запрос. Отсюда и название: 1 родительский запрос плюс N дочерних.
Почему это не видно сразу
Проблема не в скорости одного запроса, а в их количестве и накладных расходах: установка соединения из пула, парсинг, сетевой round-trip до базы. На локальной машине база рядом, данных мало. На проде база на отдельном хосте, round-trip стоит реальных миллисекунд, а таблица posts — не 50 строк, а 5000 на странице отчёта.
Как увидеть N+1, а не догадываться
Не полагайтесь на глаз. Включите инструмент, который покажет число запросов на страницу.
- Laravel Debugbar (
barryvdh/laravel-debugbar) — панель внизу страницы с количеством запросов и их текстом. Видите 51 одинаковый SELECT — вот он, N+1. - Laravel Telescope — вкладка Queries, группирует повторяющиеся запросы.
- Строгий режим Eloquent — самый надёжный способ: заставить фреймворк падать с исключением, если связь грузится лениво.
Последний вариант ловит проблему ещё в разработке, а не на проде. В методе boot() сервис-провайдера:
// app/Providers/AppServiceProvider.php, Laravel 11.x
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// включаем только вне прода, чтобы прод не падал из-за пропущенного with()
Model::preventLazyLoading(! $this->app->isProduction());
}Теперь любое обращение к незагруженной связи в цикле выбросит LazyLoadingViolationException — вы узнаете о проблеме на этапе разработки.
Решение: eager loading через with()
Идея простая: сказать Eloquent заранее, какие связи понадобятся. Тогда он достанет их одним дополнительным запросом на всю коллекцию, а не по запросу на строку.
// Laravel 11.x
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // связь уже в памяти, в базу не ходим
}Теперь запросов ровно 2: один за постами, второй — SELECT * FROM users WHERE id IN (1, 2, 3, ...) за всеми авторами разом. Было 51, стало 2.
Вложенные связи
Связи грузятся на любую глубину через точку. Посты, их авторы и профили авторов:
$posts = Post::with('author.profile')->get();Несколько связей — массивом:
$posts = Post::with(['author', 'comments', 'tags'])->get();Ограничение загружаемой связи
Можно грузить не всю связь, а только нужное — через замыкание. Например, только опубликованные комментарии:
use Illuminate\Database\Eloquent\Builder;
$posts = Post::with(['comments' => function (Builder $query) {
$query->where('is_published', true)->latest();
}])->get();Правило, которое экономит часы отладки: если вы обращаетесь к связи внутри цикла или в шаблоне-итераторе — эта связь должна быть в with(). Без исключений.
Когда данные уже загружены: load()
Иногда коллекция уже получена, а связь нужна позже — например, по условию. Тогда догружаем связь на существующую коллекцию через load():
$posts = Post::all();
// ... какая-то логика ...
if ($needAuthors) {
$posts->load('author'); // один запрос на всю коллекцию
}Разница простая: with() — до запроса, планируете заранее; load() — после, когда коллекция уже в руках.
Считать связанные записи без их загрузки
Отдельная ловушка — счётчики. Вывести число комментариев к каждому посту через count() на связи — это снова N+1:
// плохо: SELECT count(*) на каждый пост
foreach ($posts as $post) {
echo $post->comments->count();
}Для счётчиков есть withCount() — одна агрегация вместо N запросов:
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
// Eloquent добавил поле comments_count
echo $post->comments_count;
}Сравнение подходов
| Метод | Когда применять | Что делает |
|---|---|---|
with() | Знаете о связи до запроса | Грузит связь одним доп. запросом сразу |
load() | Коллекция уже получена | Догружает связь на готовую коллекцию |
withCount() | Нужно только число записей | Добавляет поле-счётчик через агрегацию |
| ленивая (по умолчанию) | Обращение к связи вне цикла, один раз | Отдельный запрос при первом обращении |
Где это ломается
- Eager loading тоже можно передозировать.
with(['a', 'b', 'c', 'd'])на коллекции, где реально нужна толькоa, — это лишние запросы и память. Грузите только то, что используете на странице. - Связь в аксессоре. Если у модели есть аксессор, который внутри обращается к связи, N+1 прячется в нём и не виден в контроллере. Строгий режим
preventLazyLoadingловит и это. - with() не спасёт от логики в цикле. Если внутри итерации вы делаете новый
Model::where(...)->get(), никакая жадная загрузка не поможет — это отдельный запрос, который вы пишете руками. Выносите его за цикл. - Дублирование в отчётах. На больших выборках
WHERE id IN (...)с тысячами id тоже не бесплатен. Если строк десятки тысяч — думайте про пагинацию иchunk(), а не про то, чтобы загрузить всё разом.
Что делать дальше
Включите preventLazyLoading в dev-окружении прямо сейчас — это переведёт N+1 из разряда «заметим на проде по логам» в разряд «упадёт при первом же прогоне». Поставьте Debugbar или Telescope и возьмите за привычку смотреть на счётчик запросов при разработке каждой новой страницы: если число запросов растёт вместе с числом строк на странице — у вас N+1.
Следующая тема, которая идёт в паре с этой, — глобальные скоупы и $with на модели для связей, которые нужны всегда. И отдельно — профилирование медленных запросов на проде через slow query log базы, когда число запросов уже в норме, а страница всё равно тормозит.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Laravel Docs — Eloquent Relationships: Eager Loading, Laravel Docs — Eloquent: preventLazyLoading
Частые вопросы
А если у меня Laravel 8 или 9, всё это работает?
Я добавил with(), а запросов всё равно много. Почему?
Не будет ли with() наоборот замедлять, если связь нужна не всегда?
Чем with() отличается от load()?
Можно ли включить preventLazyLoading на проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.