N+1 и eager loading: как один список «тормозит» весь сайт

N+1 — это классическая проблема ORM: вы делаете один запрос за списком записей, а потом для каждой записи — ещё по запросу за её связью. Итого: 1 запрос на список + N запросов на связи (по одному на каждую строку). Eager loading — лечение: связи загружаются заранее, отдельными пакетными запросами (with()), независимо от размера списка. Понимать тему нужно, потому что N+1 не выдаёт себя ошибкой: код работает, тесты зелёные, а на проде — 201 запрос на страницу каталога, тайм-ауты и счёт за базу. Это самая частая причина «Laravel тормозит», и при этом лечится буквально одной строкой.


Зачем это нужно

Боль из практики: страница каталога товаров. На локалке с пятью товарами — идеально, в проде с 200 — четыре секунды:

php
// ПЛОХО: ленивая загрузка в цикле — N+1
$products = Product::limit(200)->get();   // 1 запрос: 200 товаров

foreach ($products as $product) {
    echo $product->category->name;        // ЕЩЁ 200 запросов — по одному на товар
}

Каждое обращение к $product->category — отдельный SQL, потому что категория не была загружена заранее, и Eloquent наивно идёт в базу «на месте»:

sql
-- Первый запрос: 1 шт.
SELECT * FROM products LIMIT 200;
-- И потом в цикле, по одному на КАЖДЫЙ товар: 200 шт.
SELECT * FROM categories WHERE id = 17;   -- id разный, запрос не кешируется
SELECT * FROM categories WHERE id = 42;
-- ... ещё 198 раз

201 запрос на одну страницу. При 50 мс на запрос — десять секунд только на базу. Исправление — eager loading, одна строка:

php
// ХОРОШО: связи загружены заранее — 2 запроса вместо 201
$products = Product::with('category')->limit(200)->get();

foreach ($products as $product) {
    echo $product->category->name;        // категория уже в памяти, запроса нет
}

Eloquent выполнит всего два запроса: за товарами и за всеми их категориями разом (WHERE id IN (...)), затем свяжет их в памяти. Разница — не в стиле, а в двух порядках величины.

Как это работает

Ленивая загрузка (lazy loading) — связь достаётся из базы при первом обращении, в моменте:

php
$order = Order::find(1);
$order->user;        // прямо сейчас пошёл SELECT * FROM users WHERE id = ...

Для одной записи это нормально. В цикле — катастрофа: запрос на каждую итерацию.

Eager loading — связи грузятся заранее, пакетно:

php
// with() — загрузка при построении запроса
$orders = Order::with('user')->get();
// SELECT * FROM orders;
// SELECT * FROM users WHERE id IN (3, 5, 12, ...);  — один запрос на ВСЕ заказы

// Вложенные связи — по одному запросу на каждый уровень
$orders = Order::with('user.orders.items')->get();
// 4 запроса: orders → users → orders(users) → items

// Условия на связь
$orders = Order::with(['items' => fn ($q) => $q->where('quantity', '>', 1)])->get();

load() — докупить связь, когда модель уже получена:

php
$order = Order::find(1);
$order->load('items');       // выполнится сейчас, а не при первом обращении к $order->items

loadMissing() — загрузить только если ещё не загружено; спасает, когда часть связей уже в памяти:

php
$order->loadMissing('user');   // запроса не будет, если user уже загружен

Счётчики и проверка существования — отдельные инструменты, которые часто путают с eager loading:

php
// withCount: нужна цифра, а не данные — один подзапрос, без загрузки связи
$users = User::withCount('orders')->get();
$users[0]->orders_count;                        // 17 — заказы не загружались

// whereHas: нужен фильтр «есть ли связь с условием»
$users = User::whereHas('orders', fn ($q) => $q->where('status', 'paid'))->get();

with() не заменяет where по связи. Он управляет тем, что загружено, а не тем, какие записи выбраны: Product::with('category') вернёт все товары — просто у каждого будет загружена категория. Отфильтровать товары по категории — это whereHas() или where('category_id', ...).

Как это выглядит в Laravel

  • Бороться с N+1 на уровне приложения — запретить ленивую загрузку. В AppServiceProvider:
php
// Laravel 8.43+ и новее: каждый незагруженный доступ к связи — исключение
Model::preventLazyLoading(! app()->isProduction());

// В dev и тестах: страница с N+1 упадёт с LazyLoadingViolationException
// В проде: запись в лог, пользователи не страдают

Это самый дешёвый способ выловить все N+1 в проекте: включили — и IDE-подобный охранник сам показывает виновника в трейсах.

  • php artisan tinker + DB::enableQueryLog() — посмотреть количество запросов руками:
php
DB::enableQueryLog();
Product::limit(50)->get()->each->category->name;
count(DB::getQueryLog());   // 51 — привет, N+1
  • Laravel Debugbar / Telescope — вкладка Queries на странице: количество запросов, время, дубли — первый инструмент, когда «страница тормозит».

Плохо / Хорошо

ПлохоХорошо
Связь обращается в цикле без подготовкиwith() на запросе до get()
with() на всё подряд «на всякий случай»Загружать только то, что используется
foreach с ->count() по связи на каждой строкеwithCount() — один подзапрос
Фильтрация по связи через with()whereHas() — фильтрует записи, а не грузит связи
N+1 в очереди: связь внутри handle()loadMissing() или загрузка до отправки в очередь
Ленивые загрузки разрешены вездеpreventLazyLoading() в dev и тестах

Подводный камень

  1. Вложенный N+1. Order::with('user')->get() не спасёт, если потом в цикле обращаетесь к $order->user->company — «компании» нет в загрузке, и пойдёт по запросу на каждую строку. Правило: смотрите на всю цепочку обращений к связям в шаблоне и коде, грузите её целиком: with('user.company').
  2. with() без использования — тоже потеря. Загрузили with(['items', 'user', 'comments']), а используете только user — потратили память и два лишних запроса. Eager loading не «запас прочность», а точная загрузка того, что будет нужно.
  3. N+1 в ресурсах API и коллекциях. UserResource::collection($users) внутри обращается к $user->posts — и N+1 возникает уже в сериализации, не в контроллере. Загружайте связи до передачи в ресурсы.
  4. N+1 в очередях и воркерах. В job модели приходят с минимальной загрузкой; обращение к связи внутри handle() — lazy loading, который не видно в dev, но который валит воркер при больших объёмах. Связи для job — грузить до dispatch или в начале handle() через loadMissing().
  5. preventLazyLoading() не решает всё. Он запретит ленивую загрузку, но не заметит «лишний» eager loading и N+1 через whereHas в цикле. Это охранник против главного вида проблемы, а не полный аудит запросов — Debugbar всё ещё нужен.

Мини-шпаргалка

php
// Основное оружие
Product::with('category')->get();                  // 2 запроса вместо N+1
Product::with('user.orders.items')->get();         // вложенная цепочка
Product::with(['category', 'tags'])->get();        // несколько связей

// Докупить то, чего не хватает
$order->load('items');
$order->loadMissing('user');                       // если ещё не загружен

// Цифра вместо данных
User::withCount('orders')->orderByDesc('orders_count')->get();

// Фильтр по связи — это не with()
User::whereHas('orders', fn ($q) => $q->where('status', 'paid'))->get();

// Охранник против N+1 — AppServiceProvider
Model::preventLazyLoading(! app()->isProduction());

Три вывода

  1. N+1 — не ошибка, а молчаливая деградация: код работает, тесты зелёные, а прод живёт с сотнями запросов на страницу; лечится with() и одной строкой правила «связи, которые пойдут в цикл, — грузить заранее».
  2. with() — про что загружено, whereHas() — про какие записи выбраны; подмена одного другим — источник «у меня все записи, а не только нужные».
  3. Лучший способ убить N+1 в проекте навсегда — preventLazyLoading() в dev и тестах: каждый пропущенный with() станет видимым исключением ещё до деплоя.

Вопрос-ловушка

Спрашивают так: «Что такое N+1 и как его исправить?» Простой ответ «много запросов, исправить with()» — половина дела. Сильный — объяснить механику: ленивая загрузка идёт в базу при первом обращении к связи, поэтому в цикле получается 1 запрос на список и N на связи; eager loading заменяет N запросов одним пакетным WHERE id IN (...). Хитрый поворот: «Поставили with('category'), а страница всё равно тормозит — почему?» Ответ: вложенный N+1 — внутри категорий обратились к другой связи, которую не загрузили; или загрузили лишнее. И ещё: «withCount() заменяет eager loading?» — нет: он возвращает число без загрузки самих связей; когда нужна цифра, а не данные — withCount экономнее.