<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · MIDDLE ЧАСТО ПИТАЮТЬ

Як діагностувати й виправити проблему N+1?

N+1 виникає, коли для колекції з N записів виконується ще N запитів на звʼязки; виправляється eager loading, а ловиться через preventLazyLoading, Debugbar або Telescope.

Чому сторінка зі списком робить 200 запитів до бази?
Що таке eager loading і коли він шкодить?
Як зловити N+1 до того, як він потрапить у продакшн?
Eloquent N+1 eager loading

N+1 — це ситуація, коли один запит повертає N записів, а далі для кожного з них виконується ще один запит по звʼязку. Кожен окремий запит швидкий, тому профайлер повільних запитів нічого не покаже. Проблему видно лише за кількістю: сторінка на 50 постів робить 51 запит, на 500 постів 501.

В Eloquent причина завжди одна: доступ до незавантаженого звʼязку всередині циклу, у PHP чи в Blade. Виправлення теж одне: завантажити звʼязок заздалегідь через with() на запиті або load() на готовій колекції. Замість N запитів Eloquent зробить один із WHERE id IN (...) і розкладе результат по моделях.

Eager loading має свою ціну. Якщо потрібен лише лічильник або сума, withCount і withSum дешевші, ніж завантаження всіх звʼязаних рядків. Глибокі звʼязки на великих колекціях завантажують у памʼять десятки тисяч моделей, тому в таких місцях краще обмежений with через closure або окрема пагінація.

Найважливіша частина відповіді — як не допустити N+1 знову. Model::preventLazyLoading() у не-продакшн середовищах кидає виняток на кожен лінивий доступ, і проблема падає в тестах. У продакшені порушення варто логувати через handleLazyLoadingViolationUsing, а кількість запитів на HTTP-запит виводити в метрики.

// Було: 1 запит на пости + N запитів на авторів
foreach (Post::all() as $post) {
    echo $post->author->name;
}

// Стало: 2 запити (пости, потім автори через WHERE id IN (...))
$posts = Post::with('author')->get();

// Лише лічильник: не тягнемо самі коментарі
$posts = Post::withCount('comments')->get();
$posts->first()->comments_count;

// Обмежений eager loading через closure
$posts = Post::with(['comments' => fn ($q) => $q->latest()->limit(3)])->get();

// AppServiceProvider::boot(): ловимо N+1 ще в розробці та тестах
Model::preventLazyLoading(! $this->app->isProduction());

// У продакшені не падаємо, а логуємо
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    Log::warning("Lazy loading [{$relation}] on ".$model::class);
});
Розуміння механіки: lazy loading звʼязку всередині циклу породжує окремий запит на кожну ітерацію.
Що with() перетворює N запитів на один додатковий з WHERE IN, а load() робить те саме для вже отриманої колекції.
Що Model::preventLazyLoading(! app()->isProduction()) кидає виняток на кожен лінивий доступ і ловить проблему ще в тестах.
Що withCount, withSum і підзапити через addSelect вирішують випадки, коли потрібні лише агрегати, а не самі звʼязані моделі.
Що eager loading не безкоштовний: завантажити 10 000 коментарів заради лічильника гірше, ніж withCount, а глибокі with на великих колекціях зʼїдають памʼять.
Плутати N+1 з повільним запитом: тут кожен запит швидкий, проблема в їхній кількості.
Вважати, що with() всередині циклу щось вирішує: eager loading має бути на запиті, який формує колекцію.
Додавати with('comments') щоб порахувати коментарі замість withCount('comments').
Не знати, що N+1 буває і в Blade: доступ до $post->author у шаблоні всередині @foreach те саме, що в PHP-циклі.
Лікувати індексом на зовнішній ключ: індекс прискорює кожен запит, але не зменшує їхню кількість.
ПОРАДА

Найкраща відповідь згадує Model::preventLazyLoading() у AppServiceProvider — тоді N+1 падає з помилкою ще на етапі розробки. Додайте, як логуєте кількість запитів на запит у продакшені.

оновлено 3 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Eager loading перетворює N запитів на один додатковий, а preventLazyLoading ловить проблему ще в розробці.