---
title: "Прихований N+1 у Livewire: реєстри на час запиту"
url: https://laravelukraine.com/blog/prixovanii-n1-u-livewire-rejestri-na-cas-zapitu
author: "Олексій Бабінцев"
date: 2026-07-28
---

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

У [попередній частині](/blog/naidesevsii-zapit-toi-iakogo-nemaje-eager-loading-i-licilniki) ми прибирали 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 із автоматичною інвалідацією за тегами.
