Инвалидация кеша в Laravel: где теряются миллисекунды
Flush всего кеша при каждом обновлении товара кладёт Redis под нагрузкой. Разбираем, чем тегированная инвалидация отличается от точечного forget и сколько это стоит в реальных цифрах.

Каталог на Laravel 11 с Redis в качестве драйвера кеша. После обновления одной карточки товара админ вызывает Cache::flush() — и на графике Redis виден провал: hit rate падает с 94% до нуля на 30–40 секунд, пока кеш прогревается заново. При 300 RPS это сотни запросов, которые уходят в базу вместо кеша. Проблема не в Redis и не в железе, а в стратегии инвалидации: вы выбрасываете весь кеш, чтобы обновить один ключ.
Разберём, чем платит каждый способ инвалидации в Laravel, где Cache::tags() помогает, а где создаёт новую проблему, и что мерить, чтобы понять, какой подход выбрать под вашу нагрузку.
Из чего складывается цена инвалидации
Инвалидация — это не просто удаление ключа. У неё три составляющие, и каждая стоит времени:
- Стоимость самой операции удаления в Redis:
DELодного ключа — O(1),FLUSHDB— O(N) по числу ключей,SCANпо паттерну — тоже O(N), но с блокировкой не на всё разом. - Стоимость промаха после инвалидации: сколько запросов пойдёт в базу, пока данные не окажутся в кеше снова. Это и есть тот провал hit rate.
- Риск cache stampede1: когда популярный ключ протух, десятки параллельных запросов одновременно бросаются пересчитывать одно и то же.
Дешёвая на вид операция Cache::flush() убивает первую составляющую (одна команда), но взрывает вторую и третью на весь кеш сразу.
Четыре подхода и чем каждый платит
1. Точечный forget по ключу
Самый предсказуемый вариант. Вы точно знаете, какой ключ протухает при изменении сущности:
// Laravel 11.x
Cache::forget("product:{$product->id}");
Cache::forget("product:{$product->id}:related");Стоимость удаления минимальна — один-два DEL. Промах затрагивает только затронутые ключи. Плата — за это надо помнить все производные ключи руками. Забыли ключ витрины категории, где мелькает эта карточка, — получили устаревшие данные до истечения TTL.
2. Инвалидация по паттерну через SCAN
Соблазн — удалить всё по префиксу: product:*. В Laravel нет метода «forget по маске» в фасаде Cache, и это не случайно. Через нижележащий клиент Redis такое делается вручную:
// Laravel 11.x, драйвер redis (phpredis или predis)
$redis = Cache::getRedis();
$prefix = config('cache.prefix');
// SCAN вместо KEYS: не блокирует Redis на весь проход
foreach (new RedisIterator($redis, "{$prefix}product:*") as $key) {
$redis->del($key);
}Важно: имя итератора и способ обхода зависят от того, phpredis у вас или predis — сверьтесь с документацией вашего клиента, единого RedisIterator в стандартной поставке нет, это схематичный пример. Команда KEYS в проде запрещена: она блокирует Redis на весь проход по кейспейсу. SCAN идёт итерациями, но по-прежнему O(N) — на кейспейсе в миллион ключей полный проход занимает заметные доли секунды и грузит CPU Redis.
3. Тегированная инвалидация
Laravel поддерживает теги для драйверов, которые их умеют — Redis и Memcached. Файловый и database драйверы теги не поддерживают, вызов упадёт с исключением.
// Запись с тегами
Cache::tags(['products', "category:{$categoryId}"])
->put("product:{$id}", $product, now()->addHour());
// Инвалидация: всё, что помечено этим тегом
Cache::tags(["category:{$categoryId}"])->flush();Выглядит идеально: обновили категорию — сбросили только её кеш. Но за тегами скрыта механика. Laravel хранит для каждого тега множество (set) с версией, и flush тега на самом деле сбрасывает версию, а не удаляет ключи физически сразу. Старые записи остаются в Redis мёртвым грузом, пока не истечёт их TTL или не сработает maxmemory-policy. На больших объёмах это раздувает память.
Теги в Laravel — это не «умное удаление», а версионирование пространств имён. Вы не удаляете данные, вы делаете их недостижимыми. Разница видна только тогда, когда кончается память на Redis.
4. Событийная инвалидация через модель
Вместо ручных вызовов forget разбросанных по коду — один слушатель на событие модели:
// app/Observers/ProductObserver.php, Laravel 11.x
class ProductObserver
{
public function saved(Product $product): void
{
Cache::forget("product:{$product->id}");
Cache::tags(["category:{$product->category_id}"])->flush();
}
public function deleted(Product $product): void
{
Cache::forget("product:{$product->id}");
}
}Регистрируется в модели через атрибут #[ObservedBy(ProductObserver::class)] (Laravel 11.x) или в boot() сервис-провайдера. Плата — событие saved срабатывает и на массовых операциях, а Model::update() через query builder события не поднимает вовсе. Массовый апдейт цен через Product::where(...)->update([...]) пройдёт мимо observer, и кеш останется устаревшим.
Сравнение подходов
| Подход | Стоимость операции | Промах после | Главный риск |
|---|---|---|---|
| forget по ключу | минимальная (DEL) | только затронутые ключи | забыть производный ключ |
| SCAN по паттерну | O(N) по кейспейсу | вся группа ключей | нагрузка на CPU Redis |
| теги (flush) | минимальная (сброс версии) | вся группа тега | мёртвые ключи в памяти |
| Cache::flush() | O(N), блокирует | весь кеш | обвал hit rate |
Что мерить, а не гадать
Цифры зависят от размера кейспейса, TTL и профиля нагрузки, поэтому переносить чужие бенчмарки бессмысленно. Снимайте свои показатели:
- Hit rate до и после инвалидации. В Redis:
redis-cli info stats, поляkeyspace_hitsиkeyspace_misses. Постройте отношение по времени и посмотрите провалы после деплоя или массового обновления. - Латентность операций инвалидации. Включите
redis-cli --latencyна время массового апдейта и посмотрите, растёт ли задержка приSCANпо большому паттерну. - Рост памяти.
redis-cli info memory, полеused_memory. Если используете теги — сравните память до и после серии flush по тегам: мёртвые ключи будут видны как медленно не спадающая память при живом трафике. - Число запросов к базе на инвалидацию. Через Laravel Telescope или
DB::listen()посчитайте, сколько запросов уходит в СУБД в первые секунды после сброса кеша.
Ориентир для решения: если провал hit rate после обновления длится дольше нескольких секунд и база в этот момент заметно нагружается — вы инвалидируете слишком широко. Сужайте область до тегов или точечного forget.
Где ломается каждый подход
- Теги на Memcached ведут себя иначе, чем на Redis: у Memcached нет надёжного версионирования, и вытеснение ключей по LRU может «оживить» логику неожиданно. Для тегов предпочтителен Redis.
- SCAN на реплике: если читаете с реплики, а удаляете на мастере, между ними есть лаг репликации — короткое окно, когда удалённый ключ ещё виден.
- Cache stampede не лечится инвалидацией вообще. Даже точечный forget популярного ключа при 300 RPS даст пачку параллельных пересчётов. Здесь помогает
Cache::flexible()(доступен с Laravel 11.x) или блокировка черезCache::lock()вокруг пересчёта — но это отдельная тема, здесь мы разбираем только выбор области инвалидации.
Что делать дальше
Начните с замера hit rate и его провалов после деплоя — это покажет, есть ли вообще проблема. Если Cache::flush() живёт в коде массовых операций, замените его на теги по затронутой группе. Проверьте, что массовые апдейты через query builder не проходят мимо observer, — для них инвалидацию придётся вызвать явно. Затем на неделю снимите график used_memory: если память при тегах ползёт вверх — настройте maxmemory-policy на allkeys-lru и убедитесь, что TTL проставлен на всех тегированных записях. Дальше стоит смотреть в сторону Cache::flexible() для борьбы со stampede на горячих ключах.
1 Cache stampede (dog-piling) — ситуация, когда популярный ключ протухает и множество параллельных запросов одновременно начинают его пересчитывать, дублируя одну и ту же тяжёлую работу и перегружая базу.
Инфографика для маркетплейса: Три главных преимущества товара короткими фразами, выноски в безопасной зоне превью, точный размер площадки.
Спросить за 60 ₽Источники: Laravel Cache — Official Documentation, Redis SCAN command reference
Частые вопросы
Почему Cache::tags()->flush() не освобождает память в Redis сразу?
А если у меня драйвер кеша file или database — теги заработают?
Массовое обновление через update() не сбрасывает кеш — почему и что делать?
Можно ли удалять ключи по маске product:* безопасно?
У меня Laravel 10, а не 11 — статья применима?
Как понять, что я инвалидирую слишком широко?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.