Загрузка...

Мемоизированный тегированный кэш в Laravel: разбираем Cache::memo()->tags()

Мемоизированный тегированный кэш в Laravel: разбираем Cache::memo()->tags()

В типичном приложении на Laravel с развитой ролевой моделью Blade-шаблон страницы легко генерирует десятки вызовов @can, директив проверок прав или условий политик (Gate/Policy). Даже когда права пользователя закэшированы в Redis или Memcached через Cache::tags(), каждый вызов проверки обычно уходит отдельным запросом в хранилище кэша.

Кажется: ну Redis же работает по локальному сокету или в быстрой локалке, какие там задержки? Но на практике 50–100 обращений за один HTTP-запрос выливаются в ощутимый оверхед: сериализация, десериализация, чтение из сокета и переключение контекста. Очевидное решение — прочитать значение из внешнего кэша один раз за запрос и сложить в локальную память PHP. Начиная с релиза Laravel 13.33 фреймворк поддерживает для этого лаконичный синтаксис: Cache::memo()->tags(...).

В чем была проблема раньше

В Laravel уже существовал метод Cache::memo(), появившийся ранее для обычного (нетегированного) кэша. Он оборачивает репозиторий кэша во внутреннее хранилище MemoizedStore, которое сохраняет прочитанные ключи в оперативной памяти текущего процесса.

Однако при попытке вызвать Cache::memo()->tags(...) разработчик получал BadMethodCallException: класс MemoizedStore просто не реализовывал интерфейс тегирования. В течение некоторого времени функционал тегов в документации Laravel находился в подвешенном состоянии, но после их официального возвращения в документацию ветки 13.x возникла потребность вернуть единое поведение и для мемоизации.

До релиза 13.33 для решения этой проблемы приходилось выстраивать многослойный кэш вручную — поверх тегированного хранилища натягивали драйвер array:

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use App\Models\User;

public function getPermissions(User $user): Collection
{
    $cacheKey = "permissions:{$user->id}";

    // Приходилось вручную заворачивать теги в array-хранилище
    return Cache::store('array')->tags('permission_cache')->rememberForever(
        $cacheKey,
        function () use ($user, $cacheKey) {
            return Cache::tags($this->cacheTags($user))->remember(
                $cacheKey,
                config('auth.permissions.default_cache_time'),
                fn () => $this->loadPermissionsFromDatabase($user)
            );
        }
    );
}

Код получался избыточным, шумным и требовал помнить о двух уровнях ключей и синхронизации их инвалидации.

Как работает Cache::memo()->tags()

В PR #61593 автор Joost de Bruijn реализовал метод tags() непосредственно в MemoizedStore. Теперь связка работает «из коробки» ровно так, как от нее ждешь:

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use App\Models\User;

public function getPermissions(User $user): Collection
{
    return Cache::memo()
        ->tags(['permissions', "user:{$user->id}"])
        ->remember(
            "permissions:{$user->id}",
            now()->addHour(),
            fn () => $this->loadPermissionsFromDatabase($user)
        );
}

Жизненный цикл чтения выглядит прозрачно:

  • Первый вызов: Laravel обращается к основному хранилищу (например, Redis). Если там пусто, выполняется переданное замыкание и результат записывается и в Redis, и в локальную память PHP.
  • Повторные вызовы в рамках того же запроса: значение отдается напрямую из массива в памяти PHP (O(1)), вообще не трогая сетевое соединение с Redis.

Что под капотом

Архитектурно реализация аккуратно вписана в существующие абстракции кэша Laravel:

  1. При вызове $store->tags($names) создается (или достается из реестра по сериализованному списку тегов) экземпляр нового класса MemoizedTaggedCache, наследующего стандартный TaggedCache.
  2. Метод get($key) вычисляет префикс ключа с учетом тегов ($this->itemKey($key)). Если ключ уже есть в локальном массиве $this->cache, он возвращается мгновенно. Если нет — дергается исходный тегированный репозиторий, и результат оседает в массиве.
  3. Корректная инвалидация при записи: методы put(), putMany(), add(), increment(), decrement() и forget() при выполнении вызывают unset($this->cache[$prefixedKey]). Это гарантирует, что если внутри одного запроса вы обновили или удалили ключ, мемоизированная копия не отдаст старые данные.
  4. При вызове flush() на уровне хранилища запускается очистка локальных копий всех зарегистрированных тегированных кэшей.

Поведение в Octane и фоновых воркерах

В классическом окружении PHP-FPM изолированность обеспечивается архитектурой самого PHP: по завершении HTTP-запроса вся память процесса сбрасывается, поэтому риск оставить «хвосты» нулевой.

Для долгоживущих процессов (Laravel Octane на базе FrankenPHP, RoadRunner или Swoole, а также очередей Horizon) критично, чтобы локальная память не протекала между разными запросами или задачами. В Laravel экземпляры MemoizedStore привязаны к жизненному циклу контейнера запроса и сбрасываются между итерациями через события фреймворка, поэтому эффект мемоизации строго изолирован одним запросом.

Когда использовать

Мемоизация тегированного кэша дает максимальный профит в следующих сценариях:

  • Проверка прав и ролей: списки разрешений пользователя запрашиваются десятками компонентов интерфейса за один рендер.
  • Настройки тенанта или глобальные конфигурации: данные, читаемые из БД/Redis во множестве сервисов и middleware.
  • Флаги фич (Feature Flags): частые вызовы вида Features::active('beta-ui') внутри циклов или Blade-условий.

При этом не стоит оборачивать в Cache::memo() огромные наборы данных (например, тяжелые отчеты или каталоги на десятки мегабайт): если такое значение читается всего один раз за запрос, локальное дублирование в памяти PHP создаст лишь лишнюю нагрузку на сборщик мусора.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *