N+1 в Eloquent: чек-лист перед продакшеном
Один foreach по коллекции с обращением к связям превращает 1 запрос в 51. Разбираем, как найти скрытые N+1 в Laravel и закрыть их до релиза.

Страница со списком из пятидесяти заказов открывалась за 1.8 секунды на локали и за 6 секунд в проде под реальной базой. В логе Debugbar — 214 SQL-запросов вместо ожидаемых трёх. Классический N+1: код выглядит чисто, ORM тихо стреляет отдельным запросом на каждую связь внутри цикла. Разберём, как поймать это до релиза и что вписать в чек-лист ревью. Статья для Laravel 10.x и 11.x, поведение eager loading в этих версиях одинаковое.
Что рассматриваем: как N+1 возникает, как его увидеть, четыре способа закрыть и когда какой брать. Инвалидацию кеша и оптимизацию самих SQL-запросов не трогаем — это отдельные темы.
Откуда берётся N+1
Eloquent по умолчанию загружает связи лениво (lazy loading): пока вы не обратились к $order->customer, запроса за клиентом нет. Обращение внутри цикла и даёт эффект «1 запрос на список + N запросов на связи».
// 1 запрос: выбрать 50 заказов
$orders = Order::limit(50)->get();
foreach ($orders as $order) {
// на каждой итерации — отдельный SELECT * FROM customers WHERE id = ?
echo $order->customer->name;
}
// Итого: 1 + 50 = 51 запрос
Коварство в том, что на локальной базе из 50 строк это незаметно. Проблема вылезает под реальным объёмом данных и сетевой задержкой до сервера БД, где каждый round-trip стоит миллисекунды.
Вложенные связи умножают эффект
Если внутри цикла обращаться к связи связи — например $order->customer->company->name — счётчик растёт кратно. Пятьдесят заказов, у каждого клиент, у каждого клиента компания: 1 + 50 + 50 = 101 запрос.
Как увидеть N+1, а не догадываться
Не полагайтесь на «кажется, тормозит». Есть три инструмента, каждый ловит на своей стадии.
- Laravel Debugbar (barryvdh/laravel-debugbar) — показывает список всех SQL-запросов на странице и количество дублей. Первое, что стоит поставить на локали.
- DB::listen — встроенный слушатель запросов, можно логировать каждый SQL прямо в код без пакетов.
- preventLazyLoading — режим строгого Eloquent: любое ленивое обращение к незагруженной связи бросает исключение. Включается на dev и в тестах, ловит N+1 автоматически.
Последний способ — самый честный. Вместо ручного просмотра логов вы получаете падение теста там, где кто-то забыл eager loading.
// AppServiceProvider::boot(), Laravel 10/11
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Только вне продакшена: в проде исключение положит запрос
Model::preventLazyLoading(! $this->app->isProduction());
}
Теперь любое ленивое обращение к незагруженной связи на dev бросит LazyLoadingViolationException с именем модели и связи. Это превращает скрытый N+1 в громкую ошибку.
Четыре способа закрыть
1. Eager loading через with()
Базовое решение. Одним запросом грузим заказы, вторым — всех нужных клиентов по списку id. Было 51, стало 2.
$orders = Order::with('customer')->limit(50)->get();
foreach ($orders as $order) {
echo $order->customer->name; // связь уже в памяти, запроса нет
}
// Вложенные связи — через точку
$orders = Order::with('customer.company')->get();
// Несколько связей сразу
$orders = Order::with(['customer', 'items'])->get();
Цена: грузите все выбранные связи целиком. Если из клиента нужно только имя, а модель тяжёлая, выбирайте столбцы явно.
// Обязательно тянем внешний ключ, иначе связь не соберётся
$orders = Order::with('customer:id,name')->get();
2. Ленивая догрузка через load()
Когда коллекция уже получена (например, пришла из другого сервиса) и вы поняли, что нужны связи. load() догрузит их одним запросом на связь для всей коллекции.
$orders = Order::limit(50)->get();
// ...условие, при котором связь всё-таки нужна
if ($needCustomers) {
$orders->load('customer');
}
3. Агрегаты через withCount вместо загрузки коллекций
Если внутри цикла нужно только количество связанных записей, не грузите сами записи. withCount добавит подсчёт одним запросом.
// Плохо: грузим все items, чтобы взять count()
$orders = Order::with('items')->get();
foreach ($orders as $order) {
echo $order->items->count();
}
// Хорошо: считаем на стороне БД
$orders = Order::withCount('items')->get();
foreach ($orders as $order) {
echo $order->items_count; // атрибут появляется автоматически
}
4. Ограничение eager loading по умолчанию
Если связь нужна почти всегда, можно объявить её в свойстве $with модели — тогда она грузится автоматически при каждом запросе. Осторожно: это ускоряет одни сценарии и замедляет те, где связь не нужна.
class Order extends Model
{
// Грузится при любом Order::get() — держите список коротким
protected $with = ['customer'];
}
Когда что выбирать
| Ситуация | Решение | Запросов |
|---|---|---|
| Связь известна на этапе запроса | with() | 1 + число связей |
| Коллекция уже получена, связь нужна по условию | load() | +1 на связь |
| Нужно только количество | withCount() | 1 |
| Из модели нужны 2-3 поля | with('rel:id,field') | 1 + связь, легче по памяти |
| Связь нужна почти во всех запросах модели | $with в модели | авто, риск лишней загрузки |
Eager loading экономит запросы, но не память. Загрузив with('items') для тысячи заказов, вы держите все items в памяти PHP-процесса. Иногда 200 запросов дешевле, чем OOM на воркере.
Что ломается на практике
- with() внутри цикла.
Order::with('customer')в телеforeachне спасает — на каждой итерации это новый запрос к базе. Eager loading должен стоять до получения коллекции. - Забытый внешний ключ при выборе столбцов.
with('customer:name')безidвернёт связь пустой: Laravel нечем сопоставить строки. Всегда включайте ключ связи. - N+1 в аксессорах и ресурсах. Обращение к связи внутри
toArray()API-ресурса или в accessor модели даёт тот же эффект, но прячется от беглого взгляда на контроллер. Именно здесьpreventLazyLoadingокупается. - Полиморфные связи.
morphToбез указания типов вmorphWithможет грузить связи неоптимально. Уточняйте загружаемые типы явно. - whereHas без with.
whereHasфильтрует, но не загружает связь в память. Если после фильтра вы к ней обращаетесь — добавьтеwithотдельно.
Чек-лист перед продакшеном
- Включён
Model::preventLazyLoading()вне прода и в тестовом окружении. - Прогнали ключевые страницы под Debugbar: число запросов не растёт с числом строк в списке.
- Все обращения к связям внутри циклов, ресурсов и аксессоров покрыты
with()илиload(). - Где нужен только счётчик — стоит
withCount(), а не загрузка коллекции. - При выборе столбцов в связи присутствует внешний ключ.
- Проверили, что
$withв моделях не тянет лишнее в запросах, где связь не используется. - На тяжёлых списках оценили память: eager loading не должен упирать воркер в лимит.
Что делать дальше
Настройте в CI прогон тестов с включённым preventLazyLoading — тогда новый N+1 не пройдёт мимо ревью, а упадёт на пайплайне. В проде подключите APM (например, Laravel Telescope на стейдже или сторонний трейсинг) и смотрите на метрику «число SQL-запросов на запрос» по эндпоинтам: её рост — первый сигнал вернувшегося N+1. Следующий шаг после eager loading — измерить, не стал ли теперь узким местом объём выбираемых данных, и там уже думать о пагинации связей и выборке столбцов.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Laravel: Eloquent Relationships — Eager Loading, Laravel: Preventing Lazy Loading
Частые вопросы
А если у меня Laravel 8 или 9, preventLazyLoading сработает?
Включил with(), но запросов всё равно много — почему не сработало?
with() загружает всё подряд, а мне из связи нужно два поля. Как ограничить?
Стоит ли включать preventLazyLoading на продакшене?
Eager loading всегда лучше ленивой загрузки?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.