Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти
Інше
Події Про нас
Найдешевший запит - той, якого немає: eager loading і лічильники
Посібники 20 липня 2026

Найдешевший запит - той, якого немає: eager loading і лічильники

Це перша стаття з циклу про те, як влаштована продуктивність laravelukraine.com. І почнемо з найпростішої, але найважливішої думки: найдешевший запит - той, якого немає. Перш ніж кешувати навантаження, варто спитати, чи створюємо ми його даремно. Найпоширеніше джерело зайвих запитів - N+1 у зв'язках моделей, від якого рятує класичний eager loading. Здавалося б, тема з першого туторіала по Eloquent, та на реальному проєкті вона має пастки, про які там не пишуть.

N+1 у зв'язках: класика і як її не проґавити

Список постів у блозі показує для кожного посту автора, категорію, теги й обкладинку. Якщо не подбати про завантаження зв'язків, Eloquent зробить окремий запит на кожен зв'язок кожного рядка: 20 постів × (автор + категорія + теги + медіа) - це десятки запитів там, де мало бути кілька. Ліки відомі - ->with():

$query = Post::query()
    ->published()
    ->with(['author', 'author.media', 'category', 'tags', 'media', 'series', 'seriesTopic.series'])
    ->withCount('comments');

Один запит на пости, і по одному додатковому - на кожен тип зв'язку для всієї сторінки одразу, а не для кожного рядка. Це нецікаво рівно доти, доки не натрапиш на два неочевидні місця.

Пастка 1: аватари через Spatie MediaLibrary

Найпідступніший зв'язок у цьому списку - author.media. Аватар автора в нас зберігається через Spatie MediaLibrary, а вона тримає файли в окремій таблиці media з поліморфним зв'язком. Тонкість у тім, що звернення до аватара виглядає як звичайне читання властивості - $post->author->getFirstMediaUrl('avatar'), - і не схоже на запит. Але за цим викликом ховається звернення до зв'язку media моделі автора.

Тобто мало завантажити author - треба завантажити ще й author.media. Забудеш вкладене .media - і отримаєш N+1, який ще й важче помітити, ніж звичайний: author завантажений, IDE не свариться, автор рендериться - а на кожен аватар усе одно летить прихований запит у media. Саме тому в списку явно стоїть author.media, а не просто author. Той самий подвійний зв'язок бачимо всюди, де показуємо людей з аватарами - у коментарях навіть на два рівні вглиб:

// Коментарі: автор і його аватар, плюс автори відповідей та їхні аватари
->with(['author', 'author.media', 'reactions', 'replies.author', 'replies.author.media', 'replies.reactions'])

Урок: eager-load'ити треба не «модель», а весь ланцюг звернень, які зробить в'юха - включно з тими зв'язками, що ховаються за зручними хелперами пакетів.

Пастка 2: лічильники - це теж запити

Друга поширена помилка - рахувати пов'язані сутності «вручну» в циклі або через окремий запит на рядок. Скільки в цієї компанії підписників? Скільки вакансій? Наївно - $company->subscribers()->count() на кожну картку, знову N+1. Правильно - попросити базу порахувати все одним проходом через withCount:

$query = Company::query()
    ->published()
    ->with(['media', 'tags'])
    ->withCount(['subscribers', 'publishedVacancies', 'comments']);

withCount додає до основного запиту підзапити-лічильники, і кожна модель отримує готові subscribers_count, published_vacancies_count, comments_count без жодного зайвого походу в базу. А коли модель уже завантажена (детальна сторінка, де об'єкт прийшов роутером), той самий трюк робить loadCount пост-фактум:

// У mount() сторінки компанії, коли її вже розв'язано роутером:
$company->loadCount(['subscribers', 'comments']);

Це прибирає два окремі COUNT-запити в хедері, які інакше виконувалися б на кожен рендер, - і, що важливо, без жодного кешу й потреби щось інвалідувати. Часто найдешевша оптимізація - не «закешувати важке», а «порахувати за один прохід замість трьох».

Коли навіть кеш - зайвий шар

І тут ми забігаємо наперед - до кешування, яким займемося далі в серії. Розібравшись, що запит дешевий (eager-loaded зв'язки, withCount замість ручних лічильників), починаєш бачити випадки, де кешувати теж не варто. Показовий - блок із п'ятьма свіжими вакансіями на сторінці компанії:

'vacancies' => $this->company->publishedVacancies()
    ->with(['tags', 'company.media'])
    ->latest('published_at')
    ->take(5)
    ->get(),

Спокусливо обгорнути це в кеш. Але порахуймо. Для гостя - а це майже весь трафік - сторінка віддається з edge Cloudflare, тож PHP до цього запиту навіть не доходить. Сам запит дешевий: take(5) по індексу. Ключ кешу був би на кожну компанію окремо - багато дрібних записів у Redis заради рідкісних рендерів. А інвалідація тега vacancies часта, тож такий кеш протухав би частіше, ніж читався.

Дешевий запит + рідкісний рендер (для гостя - взагалі 0) + часта інвалідація = кеш тут лише додасть навантаження на Redis і складності, майже нічого не заощадивши. Кожен шар кешу - це запис, який треба десь тримати й вчасно скидати; він виправданий, лише коли економить більше, ніж коштує. Тому вакансії компанії ми свідомо не кешуємо: дешевше зробити дешевий запит, ніж тримати й інвалідувати ще один запис у кеші.

Головні висновки

  1. Eager-load'те ланцюг звернень, а не «модель». with(['author']) замало, якщо в'юха бере аватар: зв'язок media схований за хелпером пакета. Явно вкладайте author.media (і глибше, якщо потрібно).
  2. Аватари Spatie MediaLibrary - типове джерело прихованого N+1. Звернення до медіа не схоже на запит, тож його легко проґавити. Перевіряйте профайлером саме сторінки зі списками людей.
  3. Лічильники - через withCount/loadCount, не в циклі. База порахує все одним проходом; ручний count() на рядок - той самий N+1 в іншій обгортці.
  4. Дешевий запит не треба кешувати. Кеш виправданий економією, а не рефлексом. Якщо запит дешевий, рендер рідкісний, а інвалідація часта - кеш лише додає навантаження й складності.
  5. «Не роби зайвого» - наскрізний принцип усієї серії. Спершу приберіть зайві запити (eager loading, лічильники, а далі й приховані N+1 у списках), і лише те, що лишилося дорогим і частим, віддавайте кешу - на потрібному рубежі.

Цей перший вид зайвого - N+1 у зв'язках - лікується eager loading'ом просто, бо він на видноті. Але є підступніший різновид, який ховається в самій архітектурі фронтенду. У наступній частині - прихований N+1, коли кожен компонент у списку тихо робить власний запит, і як його прибрати реєстром на час запиту.

1 83

Ця стаття - частина серії

Як будувався laravelukraine.com

1 / 2

  1. 1 Найдешевший запит - той, якого немає: eager loading і лічильники
  2. 2 Прихований N+1 у Livewire: реєстри на час запиту

Коментарі (1)

Увійдіть, щоб залишити коментар

Oleksandr Lehotskyy
Oleksandr Lehotskyy 1 тиждень тому

В проєктах, де дуже багато звязків, я зазвичай блокую lazyloading на рівні сервіспровайдера, Model::preventLazyLoading(! $this->app->isProduction());. Після цього, якщо забуду про eager loading, ріспонс видасть 500. Це також дисциплінує, не забувати про eager loading, там де це потрібно.

Читайте також

Прихований N+1 у Livewire: реєстри на час запиту Рекомендовано
Посібники 28 липня 2026
Як будувався laravelukraine.com

Прихований N+1 у Livewire: реєстри на час запиту

Найпідступніше джерело зайвих запитів - список із однаковою кнопкою на кожній картці, де кожна тихо робить свій SELECT. Розбираємо, як per-request реєстр збирає сотні однакових перевірок в один запит, чому саме scoped(), і той самий патерн поза базою.

Вакансії за темою

Foxstone
6 дн. тому

Senior PHP Backend Developer (AI-First) - Laravel

Senior PHP Backend Developer з фокусом на AI-інструменти. Розробка масштабованих Laravel backend-додатків з TDD підходом (Pest/PHPUnit), розгортання на GCP (Cloud Run, Cloud SQL), оптимізація бази даних та написання високобезпечного коду. Вимагається 5+ років досвіду PHP/Laravel, hands-on GCP, практичний досвід AI-агентів (Gemini, Copilot, Cursor) для прискорення розробки.

Cargofy Inc.
9 дн. тому

Backend Engineer (PHP/Laravel)

Backend Engineer для AI-платформи логістики на PHP/Laravel. Розробка високонавантажених сервісів, API, event-driven пайплайнів для обробки сотень тисяч транзакцій щодня. Стек: PHP 8.x, Laravel, PostgreSQL, Redis, Horizon. Вимоги: 3+ років досвіду, глибоке розуміння Laravel, оптимізація високонавантажених систем, роботи з AI модулями. Самостійність і проактивність обов'язкові.

ConveyThis Нова
Сьогодні

Middle PHP Developer

Middle PHP розробник для SaaS стартапу розробляє та оптимізує веб-додатки на Laravel, SQL/NoSQL, JavaScript та Bootstrap. Вимагається 3+ років досвіду з PHP/Laravel, глибоке розуміння баз даних, навички OOP та чистого коду. Повна віддаленість (часовий пояс Нью-Йорка), робота в швидкому стартап-середовищі з фокусом на масштабованість та якість кода.

Пакети за темою

Bagisto

bagisto/bagisto

Bagisto — це платформа для електронної комерції, побудована на Laravel. Вона надає готове рішення для створення та управління інтернет-магазинами з підтримкою каталогу товарів, замовлень, платежів та клієнтів.

27,769 v2.4.8 12 15

Lang

laravel-lang/lang

Список 126 мов для Laravel Framework, Laravel Jetstream, Laravel Fortify, Laravel Breeze, Laravel Cashier, Laravel Nova, Laravel Spark та Laravel UI.

7,779 15.33.1 8

Відео за темою