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

Страница со списком из 50 заказов открывалась за 1,8 секунды, хотя сама таблица заказов крошечная. Debugbar показал 214 SQL-запросов: один на выборку заказов и по одному на клиента и статус для каждой строки. Это классическая N+1 — самая частая причина, по которой список в Laravel тормозит без видимой причины. Ниже — почему Eloquent так делает, как отличить N+1 от других проблем и какой способ загрузки связей выбирать под каждую задачу. Ориентируюсь на Laravel 10.x и 11.x, поведение ленивой загрузки одинаково начиная с версии 5.x.
Откуда берётся лишний запрос
Eloquent загружает связи лениво: обращение к $order->customer само по себе не выполняется, пока вы не прочитаете свойство. Как только вы читаете его в цикле, ORM для каждой итерации делает отдельный SELECT. Один запрос на родительскую коллекцию плюс N запросов на связь — отсюда и имя.
// N+1: 1 запрос на orders + по одному на customer для каждой строки
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name; // отдельный SELECT на каждой итерации
}
На 50 заказах это 51 запрос. Добавьте вторую связь в тот же цикл ($order->status->title) — станет 101. Каждый запрос сам по себе быстрый, но накладные расходы на round-trip к базе складываются.
N+1 редко валит сервис по-крупному. Он медленно съедает время ответа, а под ростом данных превращает страницу на 20 строк в страницу на 500 — и тогда 500 отдельных запросов уже заметны всем.
Как увидеть проблему, а не гадать
Прежде чем чинить, убедитесь, что дело именно в N+1, а не в одном тяжёлом запросе без индекса. Способы, по возрастанию строгости:
- Laravel Debugbar (пакет
barryvdh/laravel-debugbar) — вкладка Queries показывает количество и текст запросов на странице. Видите десятки почти одинаковыхSELECT ... WHERE id = ?— это N+1. - Telescope — вкладка Queries по каждому запросу к приложению, удобно на стейджинге.
- Автоматический детектор. Начиная с Laravel 8.43 у Eloquent есть защита от ленивой загрузки. Включите её в
AppServiceProvider, и обращение к незагруженной связи будет бросать исключение в dev-окружении.
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Ловим ленивую загрузку только вне продакшена
Model::preventLazyLoading(! $this->app->isProduction());
}
После этого код из первого примера бросит LazyLoadingViolationException с указанием модели и связи — вы сразу увидите место, где забыли eager load.
Три способа загрузить связь заранее
Все три решают одну задачу — убрать запрос из цикла, — но платят за это по-разному.
with() — eager loading при выборке
Основной инструмент. Указываете связи в момент запроса, Eloquent делает один дополнительный SELECT ... WHERE customer_id IN (...) на всю коллекцию.
// 2 запроса вместо 51: orders + все customer одним IN-запросом
$orders = Order::with('customer')->get();
foreach ($orders as $order) {
echo $order->customer->name; // связь уже в памяти
}
Несколько связей и вложенность через точку:
$orders = Order::with(['customer', 'items.product'])->get();
load() — догрузка на уже полученной коллекции
Когда коллекция уже в руках (например, пришла из другого сервиса), а связь нужна не всегда:
$orders = Order::all();
if ($needCustomers) {
$orders->load('customer'); // один IN-запрос на всю коллекцию
}
Результат тот же, что у with(), но выбор загрузки вы делаете после выборки — удобно в контроллерах, где условие зависит от параметров запроса.
withCount() и агрегаты — когда нужны только числа
Если в цикле вам нужна не сама связь, а её количество, не грузите связанные модели вовсе. withCount('items') добавит поле items_count одним подзапросом — вместо того чтобы тянуть все items в память ради count().
$orders = Order::withCount('items')->get();
foreach ($orders as $order) {
echo $order->items_count; // число, без загрузки самих items
}
Аналогично есть withSum, withAvg, withMax для агрегатов по связи.
Типичные ошибки, которые создают N+1 заново
- Связь в accessor или ресурсе. Вы добавили
with('customer')в контроллере, но вOrderResourceобращаетесь к$order->customer->manager->name— вторая связьmanagerне загружена, N+1 вернулся во вложенности. Грузите цепочку целиком:with('customer.manager'). - Загрузка в цикле по ошибке. Вызов
->load()внутриforeach— это тот же N+1, только явный. Загружайте один раз до цикла. - Условная связь без constrained eager load. Фильтрация связи внутри цикла (
$order->items->where('active', true)) фильтрует уже загруженную коллекцию в PHP. Если items много, это память. Ограничивайте на уровне запроса:with(['items' => fn ($q) => $q->where('active', true)]). - Отключённый детектор в тестах. Если
preventLazyLoadingзавязан только на! isProduction(), а тесты гоняются в окруженииtesting, проверьте, что нарушение всплывёт и там — иначе N+1 проедет в мастер незамеченным.
Когда что выбирать
| Ситуация | Инструмент | Почему |
|---|---|---|
| Связь нужна всегда при выборке | with() | Один запрос, связь готова сразу |
| Коллекция уже получена, связь по условию | load() | Не переписывать исходный запрос |
| В цикле нужно только количество | withCount() | Не тянуть связанные модели в память |
| Нужна отфильтрованная часть связи | with([... => fn]) | Фильтр уходит в SQL, а не в PHP |
| Связь используется в 5% запросов | Ленивая загрузка | Не грузить то, что почти не читается |
Не бросайтесь глобально включать with() для всех связей модели — это создаёт обратную проблему: вы грузите таблицы, которые на большинстве страниц не читаются. Eager loading оправдан там, где связь читается в цикле, а не там, где она в принципе объявлена.
Где подход даёт сбой
Eager loading убирает count запросов, но не всегда ускоряет. Если связей много и таблицы большие, один IN-запрос с сотнями идентификаторов может оказаться тяжелее нескольких мелких — особенно без индекса на внешнем ключе. Проверяйте EXPLAIN для сгенерированного запроса, а не только число строк в Debugbar.
Вторая граница — глубокая вложенность. with('a.b.c.d') делает по одному запросу на уровень, но объём данных в памяти растёт мультипликативно. Иногда вместо четырёх уровней связей чище написать один явный запрос через query builder с нужными полями.
Полностью отдельная тема — денормализация и материализованные счётчики (хранить items_count в таблице заказов и обновлять по событию). Здесь её не разбираем — она уместна, когда даже withCount становится узким местом при выборках-миллионниках.
Что сделать дальше
Включите preventLazyLoading в dev и прогоните основные страницы — детектор покажет реальные места N+1, а не предполагаемые. Дальше по каждому: смотрите в Debugbar число запросов до и после исправления и текст сгенерированного SQL. Замеряйте не только количество запросов, но и суммарное время ответа — иногда 200 быстрых запросов дешевле одного тяжёлого джойна, и тогда правильное решение — не eager loading, а индекс.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Laravel Documentation — Eloquent: Relationships (Eager Loading), Laravel Documentation — Preventing Lazy Loading
Частые вопросы
У меня Laravel 7, там нет preventLazyLoading. Как ловить N+1?
with() и load() дают одинаковый результат — какой правильнее?
Я добавил with(), но запросов всё равно много. Почему?
Грозит ли ошибка в проде, если preventLazyLoading включён?
Всегда ли eager loading ускоряет страницу?
Мне нужно только количество связанных записей — грузить связь целиком?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.