N+1 виникає, коли для колекції з N записів виконується ще N запитів на звʼязки; виправляється eager loading, а ловиться через preventLazyLoading, Debugbar або Telescope.
Як питають
Чому сторінка зі списком робить 200 запитів до бази?
Що таке eager loading і коли він шкодить?
Як зловити N+1 до того, як він потрапить у продакшн?
EloquentN+1eager 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-запит виводити в метрики.
КодPHP
// Було: 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 падає з помилкою ще на етапі розробки. Додайте, як логуєте кількість запитів на запит у продакшені.
Додаткові питанняЗ ВІДПОВІДЯМИ
Не кидати виняток, а логувати: preventLazyLoading увімкнути всюди, але через handleLazyLoadingViolationUsing відправляти порушення в лог або Sentry замість падіння. Додатково рахувати кількість запитів на HTTP-запит через DB::listen або middleware і алертити, коли вона перевищує поріг, наприклад 50.
Коли звʼязок великий, а потрібна лише його частина або агрегат. with('comments') на 1000 постів витягне сотні тисяч рядків у памʼять. Замість цього withCount, обмежений with з closure і limit, або окремий запит з пагінацією. Також шкодить сліпе $with у моделі: воно завантажує звʼязок навіть там, де він не потрібен.
Через $posts->load('author') або loadMissing, який пропускає вже завантажені. Це корисно, коли колекція приходить з іншого місця, наприклад з кешу або з результату пошуку, і ви лише в цьому шляху потребуєте звʼязку.
JsonResource часто звертається до звʼязків через whenLoaded або напряму. Якщо колекція з 100 моделей віддається без with, кожен ресурс робить свій запит. Правило: контролер завантажує звʼязки, ресурс використовує whenLoaded і ніколи не ініціює lazy loading сам.