Null Object - поведінка за замовчуванням
Замінює null безпечним об'єктом із тим самим інтерфейсом і "порожньою" поведінкою. Прибирає перевірки на null - і де він може приховати справжню проблему.
Почніть вводити, щоб шукати по статтях, вакансіях, компаніях, проєктах, ресурсах, пакетах, подіях і користувачах.
Це перша стаття з циклу про те, як влаштована продуктивність laravelukraine.com. І почнемо з найпростішої, але найважливішої думки: найдешевший запит - той, якого немає. Перш ніж кешувати навантаження, варто спитати, чи створюємо ми його даремно. Найпоширеніше джерело зайвих запитів - N+1 у зв'язках моделей, від якого рятує класичний eager loading. Здавалося б, тема з першого туторіала по Eloquent, та на реальному проєкті вона має пастки, про які там не пишуть.
Список постів у блозі показує для кожного посту автора, категорію, теги й обкладинку. Якщо не подбати про завантаження зв'язків, Eloquent зробить окремий запит на кожен зв'язок кожного рядка: 20 постів × (автор + категорія + теги + медіа) - це десятки запитів там, де мало бути кілька. Ліки відомі - ->with():
$query = Post::query()
->published()
->with(['author', 'author.media', 'category', 'tags', 'media', 'series', 'seriesTopic.series'])
->withCount('comments');
Один запит на пости, і по одному додатковому - на кожен тип зв'язку для всієї сторінки одразу, а не для кожного рядка. Це нецікаво рівно доти, доки не натрапиш на два неочевидні місця.
Найпідступніший зв'язок у цьому списку - 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'ити треба не «модель», а весь ланцюг звернень, які зробить в'юха - включно з тими зв'язками, що ховаються за зручними хелперами пакетів.
Друга поширена помилка - рахувати пов'язані сутності «вручну» в циклі або через окремий запит на рядок. Скільки в цієї компанії підписників? Скільки вакансій? Наївно - $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 і складності, майже нічого не заощадивши. Кожен шар кешу - це запис, який треба десь тримати й вчасно скидати; він виправданий, лише коли економить більше, ніж коштує. Тому вакансії компанії ми свідомо не кешуємо: дешевше зробити дешевий запит, ніж тримати й інвалідувати ще один запис у кеші.
with(['author']) замало, якщо в'юха бере аватар: зв'язок media схований за хелпером пакета. Явно вкладайте author.media (і глибше, якщо потрібно).withCount/loadCount, не в циклі. База порахує все одним проходом; ручний count() на рядок - той самий N+1 в іншій обгортці.Цей перший вид зайвого - N+1 у зв'язках - лікується eager loading'ом просто, бо він на видноті. Але є підступніший різновид, який ховається в самій архітектурі фронтенду. У наступній частині - прихований N+1, коли кожен компонент у списку тихо робить власний запит, і як його прибрати реєстром на час запиту.
1 / 3
Увійдіть, щоб залишити коментар
В проєктах, де дуже багато звязків, я зазвичай блокую lazyloading на рівні сервіспровайдера, Model::preventLazyLoading(! $this->app->isProduction());. Після цього, якщо забуду про eager loading, ріспонс видасть 500. Це також дисциплінує, не забувати про eager loading, там де це потрібно.
Замінює null безпечним об'єктом із тим самим інтерфейсом і "порожньою" поведінкою. Прибирає перевірки на null - і де він може приховати справжню проблему.
Пропускає дані через серію кроків, де кожен трансформує вхід і передає далі. Готовий Illuminate\Pipeline\Pipeline та його зв'язок із middleware.
Full-Stack розробник з глибоким досвідом бекенду. Розробляєте масштабовані веб-платформи з комплексною бізнес-логікою, працюєте з PHP/Laravel, SQL, Vue 3/TypeScript. Основні вимоги: досвід з Laravel, Eloquent, проектування API, оптимізація баз даних (включно з мільйонами записів), фронтенд на Vue 3 з Composition API, TypeScript. Цінується розуміння архітектури, продуктивності й довгострокової підтримуваності коду.
Розробник backend для мобільної RPG з високим навантаженням. Основний стек: PHP Laravel API та TypeScript Nakama Game Server. Відповідальність за повний цикл розробки функцій — від логіки до тестування, code review, оптимізації та документації. Вимоги: 5+ років backend-досвіду, бездоганне знання Laravel/PHP, сильні навички TypeScript, PostgreSQL та асинхронного програмування.
Middle Full-Stack Developer для розробки внутрішніх IT-продуктів. Основний стек: PHP 8.3+, Laravel, PostgreSQL, REST API, Vue.js/React. Розробник займатиметься backend (бізнес-логіка, Eloquent, Jobs/Queues) і frontend (компоненти, інтерфейси), інтеграціями та оптимізацією. Вимоги: 2-3+ років комерційного досвіду з PHP, 2+ років практики з Laravel, впевнене знання PostgreSQL, Git, JavaScript ES6+.