Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти
Інше
Події Про нас
Прихований 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 20

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

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

2 / 2

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

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

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

Briareos Lawrence
Briareos Lawrence 6 годин тому

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

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

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

IDS Ukraine
7 дн. тому

Backend Developer (PHP, Laravel)

Backend розробник на PHP/Laravel для розробки та підтримки Core API для мобільних додатків і веб-сайтів. Потрібен досвід від 3 років з Laravel, Tailwind CSS, Livewire, Alpine.js, ООП та Git. Обов'язки включають вирішення задач back-end розробки, роботу з базами даних та контроль версій.

Volkorn
9 дн. тому

Senior Laravel Developer (Polish-Speaking)

Розроблювати та керувати складними веб-застосунками на Laravel як технічний лідер. Відповідатимете за архітектуру, оцінку проектів, спілкування з клієнтами та повний цикл розробки. Вимоги: 5+ років з PHP/Laravel, B2 польською та англійською, повна технічна незалежність.

Tech4You
28 дн. тому

PHP Full-Stack Developer

Офіс Full-time Middle $1,500 - $2,000

PHP Full-Stack розробник для внутрішньої операційної платформи цифрової реклами. Розробка функціоналу від серверів і БД до аналітичних дашбордів та UI. Потрібен досвід з Laravel 8+, MySQL, Redis, AWS, REST/webhooks, Blade/Livewire/Alpine.js, черги та фонова обробка. Мінімум 2+ років комерційного досвіду в PHP.

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

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

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