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()— посмотреть количество запросов руками:
phpDB::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 и тестах |
Подводный камень
- Вложенный N+1.
Order::with('user')->get()не спасёт, если потом в цикле обращаетесь к$order->user->company— «компании» нет в загрузке, и пойдёт по запросу на каждую строку. Правило: смотрите на всю цепочку обращений к связям в шаблоне и коде, грузите её целиком:with('user.company'). with()без использования — тоже потеря. Загрузилиwith(['items', 'user', 'comments']), а используете толькоuser— потратили память и два лишних запроса. Eager loading не «запас прочность», а точная загрузка того, что будет нужно.- N+1 в ресурсах API и коллекциях.
UserResource::collection($users)внутри обращается к$user->posts— и N+1 возникает уже в сериализации, не в контроллере. Загружайте связи до передачи в ресурсы. - N+1 в очередях и воркерах. В job модели приходят с минимальной загрузкой; обращение к связи внутри
handle()— lazy loading, который не видно в dev, но который валит воркер при больших объёмах. Связи для job — грузить доdispatchили в началеhandle()черезloadMissing(). 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());
Три вывода
- N+1 — не ошибка, а молчаливая деградация: код работает, тесты зелёные, а прод живёт с сотнями запросов на страницу; лечится
with()и одной строкой правила «связи, которые пойдут в цикл, — грузить заранее». with()— про что загружено,whereHas()— про какие записи выбраны; подмена одного другим — источник «у меня все записи, а не только нужные».- Лучший способ убить N+1 в проекте навсегда —
preventLazyLoading()в dev и тестах: каждый пропущенныйwith()станет видимым исключением ещё до деплоя.
Вопрос-ловушка
Спрашивают так: «Что такое N+1 и как его исправить?» Простой ответ «много запросов, исправить with()» — половина дела. Сильный — объяснить механику: ленивая загрузка идёт в базу при первом обращении к связи, поэтому в цикле получается 1 запрос на список и N на связи; eager loading заменяет N запросов одним пакетным WHERE id IN (...). Хитрый поворот: «Поставили with('category'), а страница всё равно тормозит — почему?» Ответ: вложенный N+1 — внутри категорий обратились к другой связи, которую не загрузили; или загрузили лишнее. И ещё: «withCount() заменяет eager loading?» — нет: он возвращает число без загрузки самих связей; когда нужна цифра, а не данные — withCount экономнее.