Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама
Прихований N+1 у Livewire: реєстри на час запиту
Посібники 28 липня 2026

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

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

Найпідступніше джерело зайвого навантаження в нашому стеку - навіть не важкий запит, а тихий N+1: коли рендер сторінки непомітно робить по одному дрібному запиту на кожен елемент списку. Кожен окремо - копійчаний, разом - десятки зайвих походів у базу на одну сторінку. І найгірше, що його майже не видно: тести зелені, локально швидко, а на бойовій сторінці зі списком - раптово 100+ запитів.

Звідки береться прихований N+1

Наш фронтенд - Livewire, тож інтерактивні елементи (кнопка «в обране», «підписатися», лічильник голосів) - це окремі компоненти. Список пакетів рендерить по картці на пакет, у кожній картці - кнопка-закладка, а кожна кнопка на mount() питає: «а користувач уже додав цей пакет в обране?». Наївна відповідь - окремий запит:

// BookmarkButton, наївна версія
$this->favorited = auth()->user()->favorites()
    ->where('favoritable_type', $this->model->getMorphClass())
    ->where('favoritable_id', $this->model->getKey())
    ->exists();

Один SELECT EXISTS на кнопку. На сторінці з 30 картками - 30 запитів, кожен повертає один біт «так/ні». Причому запит цілком коректний: логіка правильна, індекси на місці. Проблема не в самому запиті, а в тому, що їх тридцять там, де мало бути один.

Класична порада - eager loading - тут не рятує напряму: кнопка не знає про сусідні кнопки, а favoritable_type/id пари розкидані по різних моделях (пакети, проєкти, компанії). Потрібен не eager load зв'язку, а спільна пам'ять на весь запит.

Реєстр на час запиту

Рішення - маленький сервіс, який один раз вантажить усі обрані поточного користувача й відповідає на будь-яку перевірку членства з пам'яті. Ось FavoriteRegistry цілком:

class FavoriteRegistry
{
    /** @var array<string, true>|null Набір "{morphClass}:{id}" ключів. */
    private ?array $keys = null;

    private ?int $loadedForUserId = null;

    public function has(Model $model): bool
    {
        $user = auth()->user();

        if (! $user instanceof User) {
            return false; // гість нічого не обирав
        }

        $this->ensureLoaded($user);

        return isset($this->keys[$this->key($model->getMorphClass(), $model->getKey())]);
    }

    private function ensureLoaded(User $user): void
    {
        if ($this->keys !== null && $this->loadedForUserId === $user->getKey()) {
            return; // уже завантажено в цьому запиті
        }

        $this->keys = [];
        $this->loadedForUserId = $user->getKey();

        $user->favorites()
            ->get(['favoritable_type', 'favoritable_id']) // ОДИН запит на всі перевірки
            ->each(function ($favorite): void {
                $this->keys[$this->key($favorite->favoritable_type, $favorite->favoritable_id)] = true;
            });
    }

    public function flush(): void
    {
        $this->keys = null;
        $this->loadedForUserId = null;
    }

    private function key(string $type, int|string $id): string
    {
        return $type . ':' . $id;
    }
}

Ідея проста: перша перевірка вантажить усі пари (type, id) обраного одним запитом і будує з них хеш-набір; кожна наступна кнопка на сторінці відповідає звіркою по ключу в пам'яті. 30 запитів згортаються в 1. Кнопка тепер лише питає реєстр:

$this->favorited = app(FavoriteRegistry::class)->has($this->model);

Чому саме scoped(), а не статичний кеш

Реєстр зареєстрований у контейнері як scoped, а не singleton:

// AppServiceProvider::register()
$this->app->scoped(FavoriteRegistry::class);
$this->app->scoped(EdgeCacheTags::class);

scoped означає «один екземпляр на життєвий цикл запиту (або джоби), і свіжий - на наступний». Це рівно та тривалість життя, яка нам потрібна: у межах одного рендера набір обраного незмінний, тож його можна тримати; але наступний запит - інший користувач, і тягти його стан крізь запити було б помилкою. singleton жив би між запитами (у чергових воркерах - катастрофа: чуже обране), а статична властивість мала б ту саму ваду в довгоживучих процесах на кшталт Octane. scoped - правильна за замовчуванням тривалість життя для «пам'яті на один запит».

Пастка: інвалідація в межах запиту

Один нюанс. Якщо на тій самій сторінці користувач клікнув кнопку - додав чи прибрав обране, - набір у реєстрі застарів. Тому кожна дія, що змінює обране, скидає реєстр:

public function toggleFavorite(): void
{
    $added = auth()->user()->toggleFavorite($this->model);

    // Набір змінився; скидаємо кеш, щоб інші кнопки й перевірки в цьому
    // ж запиті побачили новий стан.
    app(FavoriteRegistry::class)->flush();
    // ...
}

Дешево (наступна перевірка просто перевантажить набір одним запитом) і рятує від неузгодженості, коли на сторінці кілька кнопок для однієї сутності.

Одне правило робить цей патерн надійним: усі перевірки членства йдуть одним спільним шляхом. Кожна кнопка - закладка, голос, підписка - питає реєстр, а не пише власний exists(). Варто одному компоненту піти в обхід - і N+1 тихо повертається саме там, де його найважче помітити. Тому корисний тест-вартовий, що рахує запити на список: він ловить такий регрес раніше за профайлер на проді.

Той самий патерн не тільки для БД: TagClassCache

Реєстр на час запиту - це не про базу, а про будь-яку дорогу операцію, що повторюється з однаковим входом. Приклад без жодного SQL. Наш компонент <x-tag> використовує TailwindMerge, щоб класи, передані при виклику через атрибут class, коректно перекривали дефолтні класи тега. Це зручно, але коштовно - близько 0.1 мс на виклик, а сторінка-список рендерить сотні тегів. Причому входів насправді жменька: усі теги діляться на кілька комбінацій (розмір, варіант, додатковий клас).

Тож запам'ятовуємо результат для кожного унікального входу:

class TagClassCache
{
    /** @var array<string, string> */
    private static array $cache = [];

    public static function merge(string $key, Closure $resolver): string
    {
        return self::$cache[$key] ??= $resolver();
    }
}

А в компоненті:

$mergeKey = $sizeClasses . '|' . $variantClasses . '|' . (string) $attributes->get('class');
$classes = TagClassCache::merge($mergeKey, fn () => TailwindMerge::merge(
    $sizeClasses, $variantClasses, 'group', (string) $attributes->get('class'),
));

Сотні викликів TailwindMerge згортаються в кілька реальних злиттів; решта - звірка по ключу в пам'яті. Той самий принцип, що й у FavoriteRegistry: порахуй один раз на унікальний вхід, а не щоразу. (Тут кеш статичний, а не scoped, бо результат злиття класів не залежить від користувача чи запиту - однакові входи завжди дають однаковий вихід, тож його безпечно ділити.)

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

  1. Найдешевший запит - той, якого немає. Перш ніж кешувати навантаження, спитайте, чи створюєте ви його даремно. Прихований N+1 у списках - типовий приклад «зайвого, якого можна не робити».
  2. Список однакових перевірок = один запит. Якщо на сторінці N карток питають одне й те саме про різні id, завантажте всю відповідь одним запитом наперед і відповідайте з пам'яті. Це патерн «реєстр на час запиту».
  3. scoped() - правильна тривалість життя для пам'яті на запит. Не singleton (протече між запитами у воркерах) і не статика (та сама вада в Octane). Свіжий екземпляр на кожен запит - саме те, що треба.
  4. Усі перевірки - через один спільний шлях. Абстракція проти N+1 захищає лише ті точки входу, що нею користуються: варто одному компоненту написати власний exists() - і N+1 тихо повертається. Прикрийте це тестом, що рахує запити на список.
  5. Патерн ширший за БД. Будь-яка дорога операція з обмеженою кількістю унікальних входів (злиття класів, форматування, обчислення) виграє від того, щоб запам'ятати результат для кожного унікального входу.

Ми прибрали зайві запити - і в зв'язках, і в списках. Але частину запитів прибрати не можна: важкі блоки на кшталт «ТОП компаній» доведеться таки виконати. Ось із цього місця й починається кеш. У наступній частині - перший його рівень: кеш результатів Eloquent-запитів у Redis із автоматичною інвалідацією за тегами.

1 51

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

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

2 / 3

Читати в документації

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

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

Briareos Lawrence
Briareos Lawrence 1 місяць тому

Ну по суті ви придумали LRU cache через статичні властивості класа, ця штука заслуговує на життя але я б все ж таки зробив окремий шар кешування базових наборів, кешувати фільтри згоден, не має сенсу.

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

Патерн Null Object
Посібники 10 вересня 2026
Патерни проєктування

Null Object - поведінка за замовчуванням

Замінює null безпечним об'єктом із тим самим інтерфейсом і "порожньою" поведінкою. Прибирає перевірки на null - і де він може приховати справжню проблему.

4

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

Bitmotive LLC
10 днів тому

Fullstack Developer (Laravel + React)

Fullstack розробник для роботи над живими клієнтськими проєктами на Laravel API з React Native та Filament. Потрібна глибока експертиза в PHP/Laravel, компетенція в TypeScript/React, досвід роботи з PostgreSQL, AWS. Вимагається англійська B2+, тестова дисципліна, вміння швидко адаптуватися до різних стеків.

Full Stack Developer (PHP, React, Middle, Middle+)

Full Stack розробник для підтримки та розвитку аналітичного продукту перевірки контрагентів. Робота зі складною бізнес-логікою, базами даних та інтеграцією AI-рішень. Стек: PHP 8.x (Laravel/Symfony), React, MySQL, REST API. Вимоги: 3+ років комерційного досвіду, глибоке розуміння SQL, Git, Docker, CI/CD, Linux, OWASP.

Програміст PHP (інтерн)

Вакансія на посаду інтерна PHP розробника для початківців з теоретичною базою ООП та базовими знаннями PHP. Потрібні навички Git/GitHub, власні проєкти. Стажування в офісі під керівництвом менторів з перспективою переходу на посаду Junior разробника.

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

Filament

filament/filament

Найпопулярніша адмін-панель для Laravel: ресурси, таблиці, форми та дашборди описуються PHP-класами поверх Livewire, Alpine і Tailwind. Замінює рутину CRUD-інтерфейсів декларативною конфігурацією.

31,852 v5.7.6 7

Bagisto

bagisto/bagisto

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

28,086 v2.5.0-beta1 13 25