Двадцять один запит замість двох
Сторінка показує 20 вакансій з назвою компанії. Перший запит бере вакансії. Потім шаблон для кожної звертається до $vacancy->company, і Eloquent, побачивши, що звʼязок ще не завантажено, тихо робить окремий запит за компанією. Це : 1 запит за списком і N запитів за звʼязками, звідси назва N+1.
Кожен з цих запитів швидкий: пошук за первинним ключем, частки мілісекунди. Повільна їхня кількість. Кожен запит це похід по мережі до бази, розбір SQL, план, відповідь, створення моделі. Локально 21 запит займає кілька мілісекунд, на продакшні з базою на іншому сервері до кожного додається затримка мережі, а N росте разом зі сторінкою.
Ліки це : сказати Eloquent заздалегідь, які звʼязки знадобляться. Тоді після запиту за вакансіями Laravel збирає всі company_id і робить один запит WHERE id IN (...), а потім сам розкладає компанії по вакансіях. Запитів стає два, хоч би скільки рядків було на сторінці.
Вісім рядків у циклі по компаніях
Список компаній, у кожної вакансії. Те, що виглядає однаково в шаблоні, дає дуже різну кількість запитів. Кількість для трьох компаній знято з журналу запитів Laravel.
$companies = Company::all(); ... $company->vacancies
N+1
Lazy loading: 1 запит за компаніями і по запиту на кожну компанію, тобто 4 запити для трьох компаній і 21 для двадцяти.
Company::with('vacancies')->get(); ... $company->vacancies
2 запити
Другий запит один на всіх: select * from vacancies where company_id in (1, 2, 3).
$companies->load('vacancies')
2 запити
Те саме, але після запиту. Зручно, коли список уже є, наприклад прийшов з репозиторію чи пагінатора. loadMissing() не повторює вже завантажене.
Company::withCount('vacancies')->get(); ... $company->vacancies_count
1 запит
Потрібне лише число: Laravel додає підзапит (select count(*) ...) as vacancies_count, і самих вакансій не вантажить.
Company::with('vacancies')->get(); ... $company->vacancies->count()
зайві рядки
Запитів два, але заради числа база віддала й PHP створив усі вакансії: близько 500 моделей на компанію. Для числа беріть withCount().
Company::with('vacancies')->get(); ... $company->vacancies()->where('status', 'active')->count()
N+1
Дужки після vacancies повертають новий конструктор запиту, а не завантажену колекцію. Eager loading тут нічого не дає: по запиту count(*) на кожну компанію.
Company::with(['vacancies' => fn ($q) => $q->where('status', 'active')])
2 запити
Умова йде в сам eager-запит: where company_id in (...) and status = 'active'. Колекція $company->vacancies вже відфільтрована.
Company::find(1)->vacancies
норма
Одна модель, один звʼязок, два запити. Lazy loading тут не помилка, і навіть preventLazyLoading() на нього не реагує: він стежить лише за моделями, що прийшли списком.
Laravel 13.30, журнал через DB::listen. Для компанії з 500 вакансіями кожен рядок with('vacancies') це 500 моделей у памʼяті.
Двадцять вакансій з назвою компанії
Той самий код Eloquent на трьох базах: 20 найсвіжіших активних вакансій і назва компанії біля кожної. Без with() журнал показує запит за вакансіями і двадцять однакових запитів за компаніями, що відрізняються лише id. З with('company:id,name') другий запит один, з IN на всі 20 id і лише двома колонками.
Час нижче локальний, база на тій самій машині, кеш прогрітий. Навіть так 21 запит у два-чотири рази повільніший за два. Через мережу до кожного з 21 запиту додається ще й затримка до сервера бази.
без with(): 21 запит, 3.83 ms у базі, 6.44 ms разом з PHP з with(): 2 запити, 0.95 ms у базі, 1.86 ms разом з PHP JOIN: 1 запит, 1.52 ms у базі, 2.30 ms разом з PHP
Числа з одного прогону на прогрітому кеші. Сам рядок IN PostgreSQL перетворює на id = ANY ('{68,209,...}'::bigint[]) і шукає по первинному ключу: Index Scan using companies_pkey, 54 сторінки на 20 компаній.
без with(): 21 запит, 3.74 ms у базі, 6.19 ms разом з PHP з with(): 2 запити, 0.76 ms у базі, 1.68 ms разом з PHP JOIN: 1 запит, 0.72 ms у базі, 1.38 ms разом з PHP
EXPLAIN для запиту з IN: type = range, key = PRIMARY, rows = 20. Тобто 20 точкових пошуків по кластерному індексу всередині одного запиту.
без with(): 21 запит, 0.46 ms у базі, 2.59 ms разом з PHP з with(): 2 запити, 0.20 ms у базі, 1.19 ms разом з PHP JOIN: 1 запит, 0.10 ms у базі, 0.76 ms разом з PHP
SQLite працює в тому самому процесі, мережі немає взагалі, і все одно 21 запит удвічі повільніший: більшу частину часу забирає не база, а PHP, який готує кожен запит і створює кожну модель.
Окремий запит з IN чи JOIN
Eager loading у Laravel це завжди окремий запит з IN, а не JOIN. Для ключів-цілих чисел Laravel вписує id просто в SQL, без плейсхолдерів (whereIntegerInRaw), тож ліміту на кількість параметрів не існує навіть для тисяч id.
JOIN дає один запит, але повертає плоскі рядки: колонки вакансії й колонки компанії поруч, і моделі Company з них не буде. Він кращий, коли з повʼязаної таблиці потрібна одна-дві колонки, коли за ними треба фільтрувати чи сортувати, або в звітах, де модель не потрібна. Звʼязок «один до багатьох» через JOIN множить рядки: компанія з 500 вакансіями прийде 500 разів.
Index Scan using companies_pkey on companies (cost=0.28..41.90 rows=20 width=20) (actual time=0.021..0.057 rows=20 loops=1)
Index Cond: (id = ANY ('{68,209,388,742,799,818,851,902,957,1120,1191,1216,1281,1577,1584,1612,1633,1722,1865,1957}'::bigint[]))
Buffers: shared hit=54
Execution Time: 0.100 ms JOIN тут дав 1 запит за 1.52 ms, з with() було 2 запити за 0.95 ms разом. Один запит не завжди швидший за два: важить, скільки рядків база читає, а не скільки разів її питають.
type: range key: PRIMARY rows: 20 Extra: Using where
Для довгих списків IN MySQL рахує рядки за статистикою індексу замість точного підрахунку, коли значень у списку більше за eq_range_index_dive_limit (за замовчуванням 200). На план з первинним ключем це майже не впливає.
Як завантажити звʼязки заздалегідь і як ловити забуті
Eloquent дає кілька способів не робити запит у циклі і два способи дізнатися, що десь його все-таки роблять.
Каталог вантажить компанії одним запитом
Каталог вакансій просить компанії разом з вакансіями й лише ті колонки, що потрібні картці: with('company:id,name,slug,kind,logo_url'). Далі card() перекладає кожну модель у JobCard, і шаблон отримує вже готові обʼєкти, а не моделі Eloquent, тож lazy loading у Blade тут просто нема звідки взятися. Model::preventLazyLoading() у проєкті не ввімкнено: від N+1 захищає саме те, що моделі не виходять за межі Infrastructure.
src/Hiring/Infrastructure/Catalog/EloquentDeveloperResponses.phpВідгуки розробника вантажать три звʼязки наперед: with('job:id,slug,title,status,company_id', 'job.company:id,name', 'events'). У job є company_id, бо без нього вкладений звʼязок job.company не знайде компаній.src/Hiring/Infrastructure/Catalog/EloquentCompanyDirectory.phpКаталог компаній рахує відкриті вакансії через withCount(['jobs as open_jobs' => ...]) і сортує за цим числом, а самі вакансії вантажить через with() з умовою на статус і лише колонками для фільтрів. withCount це підзапит на кожну компанію, а окремого індексу під vacancies.company_id на PostgreSQL немає: поки вакансій тисячі, це непомітно.src/Hiring/Infrastructure/Persistence/EloquentDeveloperProfileRepository.phpЩоб показати, чи є в розробника проєкти, профіль не вантажить проєкти взагалі: withProjects() додає в SELECT exists(select 1 from projects where ...) as shows_projects. byUserIds() бере профілі для списку людей одним whereIn, а не по одному.Де N+1 ховається
Звʼязок у шаблоні
Контролер віддає $vacancies у Blade, а шаблон звертається до $vacancy->company->name. У коді контролера жодного циклу немає, і N+1 видно лише в журналі запитів.
Натомість: with() там, де будується запит для шаблону, і preventLazyLoading() у тестах. Або, як у цьому проєкті, віддавати в шаблон готові обʼєкти замість моделей.
Дужки після звʼязку
$company->vacancies() з дужками це новий запит щоразу, навіть коли звʼязок завантажено через with(). ->count(), ->where() чи ->exists() після дужок у циклі дають N запитів.
Натомість: Без дужок для завантаженої колекції ($company->vacancies->where(...)) або withCount() і with() з умовою.
Атрибут-аксесор зі звʼязком
Аксесор getCompanyNameAttribute() звертається до $this->company, а атрибут потрапляє в $appends. Кожна серіалізація моделі в JSON робить запит, і API-список з 50 моделей дає 50 запитів.
Натомість: with('company') у запиті, який віддає модель, або колонка через JOIN чи підзапит у SELECT.
Eager loading усього підряд
with('vacancies') заради кількості чи першої вакансії вантажить сотні моделей на кожну компанію. Запитів два, але памʼять і час на створення моделей ростуть.
Натомість: withCount(), withExists() або підзапит. Для «останньої вакансії» є звʼязок latestOfMany().
withCount без індексу під зовнішнім ключем
withCount() це корельований підзапит, і база виконує його для кожного рядка батьківської таблиці. Сортування за лічильником змушує порахувати його для всіх. На PostgreSQL, який не індексує зовнішні ключі сам, 2 000 компаній по мільйону вакансій без індексу на company_id рахувалися 80 секунд, з індексом за 1 секунду: loops=2000 у плані.
Натомість: Індекс на колонку зовнішнього ключа. Якщо лічильник потрібен на кожній сторінці, тримайте його в окремій колонці й оновлюйте при зміні або рахуйте одним GROUP BY.
Шукати N+1 лише в коді
N+1 часто зʼявляється не там, де пишуть запит, а в шаблоні, аксесорі, ресурсі API, слухачі події. Читаючи один файл, його не видно.
Натомість: Дивіться на кількість запитів сторінки: DB::listen() у локальному лозі, Laravel Debugbar чи Telescope, у тестах DB::enableQueryLog() і перевірка кількості.
- Lazy loading
- Завантаження звʼязку в момент першого звернення до нього, наприклад $vacancy->company. Для однієї моделі це зручно, для кожної моделі зі списку це N окремих запитів.
- Eager loading
- Завантаження звʼязку для всіх моделей списку одним додатковим запитом: with() до запиту або load() після нього. Laravel будує WHERE ключ IN (...) і сам розкладає результати по моделях.
Питання зі співбесід
Один запит за списком і ще по запиту на кожен елемент списку, коли код у циклі звертається до незавантаженого звʼязку. Виправляє eager loading: with('company') до запиту або load('company') після. Laravel робить один запит WHERE id IN (...) на весь список.
22: один за компаніями, один eager-запит за вакансіями і ще 20 count(*). Дужки повертають конструктор запиту, а не завантажену колекцію, тож with() тут не допомагає. Правильно withCount('vacancies') і $company->vacancies_count, тоді запит один.
Коли з повʼязаної таблиці потрібна одна-дві колонки, а не модель; коли за колонкою повʼязаної таблиці треба фільтрувати чи сортувати, наприклад вакансії за назвою компанії; у звітах з GROUP BY. with() кращий, коли потрібні моделі звʼязку або коли JOIN «один до багатьох» розмножить рядки батьківської таблиці.
Виняток кидатимуть лише моделі, що прийшли списком: окрема модель з find() вантажить звʼязки як раніше. Падатимуть шаблони, ресурси API, аксесори й слухачі, які звертаються до незавантажених звʼязків, і саме це потрібно знайти. Вмикають на локальному середовищі й у тестах, а на продакшні через handleLazyLoadingViolationUsing() пишуть порушення в лог замість винятку, поки їх не виправлять. Альтернатива для старого коду це automaticallyEagerLoadRelationships().