.NET Tutorial
Senior7 мин чтенияВопрос 7 из 10

Cache invalidation стратегии: TTL, eviction, distributed cache consistency?

Стратегии

Значение в кэше

TTL истёк
(время)

Явная инвалидация
(данные изменились)

Вытеснение
(нехватка памяти, LRU)

Смена версии ключа
(v2:... — старые ключи никто не читает)

Значение в кэше

TTL истёк
(время)

Явная инвалидация
(данные изменились)

Вытеснение
(нехватка памяти, LRU)

Смена версии ключа
(v2:... — старые ключи никто не читает)

Когда значение уходит из кэша
Стратегия Свежесть Сложность Когда
Только TTL Устаревание до TTL Минимальная Данные, где небольшое устаревание допустимо: каталог, курсы, рейтинги
Инвалидация при записи Почти сразу Средняя: знать все ключи, зависящие от данных Профили, настройки, корзина
Теги / групповая Почти сразу Средняя Списки, агрегаты, страницы, зависящие от многих сущностей
Версионирование ключей Сразу для новых чтений Низкая Деплой новой схемы, «сброс всего» раздела
Событийная (Pub/Sub, CDC) Почти сразу во всех экземплярах Выше: инфраструктура Микросервисы, L1-кэши в нескольких экземплярах

TTL и jitter

C#
var ttl = TimeSpan.FromMinutes(10) + TimeSpan.FromSeconds(Random.Shared.Next(-60, 60));   // 9–11 минут
await cache.SetAsync(key, value, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl });

Как выбирать TTL: какая задержка устаревания допустима бизнесу («цена может обновиться через 5 минут» — ок; «баланс счёта» — нет, не кэшировать или инвалидировать явно) и какая нагрузка на источник при промахах.

Инвалидация при записи: delete, а не update

КэшБДЗапрос B (записьцены 130)Запрос A (записьцены 120)в БД 130, в кэше 120 —навсегда (до TTL)при DELETE вместо SET следующий чтение возьмёт актуальное 130 из БДUPDATE price = 120UPDATE price = 130SET price = 130SET price = 120 (опоздал)
КэшБДЗапрос B (записьцены 130)Запрос A (записьцены 120)в БД 130, в кэше 120 —навсегда (до TTL)при DELETE вместо SET следующий чтение возьмёт актуальное 130 из БДUPDATE price = 120UPDATE price = 130SET price = 130SET price = 120 (опоздал)
Почему лучше удалять ключ, а не обновлять значение
C#
public async Task UpdatePriceAsync(int productId, decimal price, CancellationToken ct)
{
    await db.Products.Where(p => p.Id == productId).ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, price), ct);
    await cache.RemoveAsync($"product:{productId}", ct);        // после успешной записи в БД
    await cache.RemoveByTagAsync($"category:{categoryId}", ct);  // зависимые списки (HybridCache)
}

Гонка «устаревшее значение возвращается в кэш»

КэшБДПисательЧитателькэш сновасодержитустаревшие 100 доистечения TTLGET — промахSELECT → старое значение 100UPDATE → 120DELETE ключSET 100 (прочитанное ДО обновления)
КэшБДПисательЧитателькэш сновасодержитустаревшие 100 доистечения TTLGET — промахSELECT → старое значение 100UPDATE → 120DELETE ключSET 100 (прочитанное ДО обновления)
Race condition при cache-aside

Смягчение:

  • TTL всегда — ограничивает время жизни ошибки.
  • Delayed double delete: удалить сразу и ещё раз через небольшую задержку (сотни мс).
  • Версии: хранить в кэше вместе с version/updated_at и не перезаписывать более новую версию более старой (Lua-скрипт в Redis с проверкой).
  • Для критичных данных — не кэшировать или читать из источника.

Групповая инвалидация

Версия пространства ключей
// Ключи включают «поколение» категории; инвалидация = увеличить поколение
var gen = await redis.StringGetAsync($"gen:category:{categoryId}") ;
var key = $"category:{categoryId}:g{gen}:page:{page}";

// При изменении любого товара категории
await redis.StringIncrementAsync($"gen:category:{categoryId}");   // старые ключи больше не читаются, истекут по TTL

HybridCache (.NET 9+) и Output Cache поддерживают теги «из коробки»: RemoveByTagAsync("category:5").

Eviction

Политика Что удаляется при нехватке памяти
LRU (Least Recently Used) Дольше всего не использовавшиеся
LFU (Least Frequently Used) Реже всего использовавшиеся
TTL-based (volatile-ttl) С ближайшим истечением
Random Случайные
noeviction Ничего — запись вернёт ошибку
Redis: redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru        # для чистого кэша; volatile-lru — вытеснять только ключи с TTL

IMemoryCache с SizeLimit вытесняет по приоритету (CacheItemPriority) и давности использования при компактизации.

Паттерны записи

Cache-aside: самый распространённый

DELETE ключа

Запись

БД

Кэш

Write-through: кэш актуален, запись медленнее

Запись

Кэш

БД

Write-behind: быстро, но риск потери при падении кэша

асинхронно, пачками

Запись

Кэш

БД

Cache-aside: самый распространённый

DELETE ключа

Запись

БД

Кэш

Write-through: кэш актуален, запись медленнее

Запись

Кэш

БД

Write-behind: быстро, но риск потери при падении кэша

асинхронно, пачками

Запись

Кэш

БД

Кто пишет в кэш и в БД
Паттерн Плюсы Минусы
Cache-aside (lazy loading) Просто, кэш — только то, что читают, отказ кэша не критичен Промах = задержка; гонки при инвалидации
Read-through Логика загрузки в слое кэша (HybridCache.GetOrCreate) Та же семантика, что cache-aside
Write-through Кэш согласован с записью Каждая запись дороже; кэшируются и нечитаемые данные
Write-behind Очень быстрые записи, батчинг Риск потери данных, сложность
Refresh-ahead Горячие ключи обновляются до истечения — нет промахов Лишняя нагрузка на обновление редко читаемых

Несколько экземпляров и локальные кэши

PUBLISH invalidate product:42

альтернатива: CDC (Debezium) →
событие изменения → инвалидация

Pod 1: изменил товар 42

БД

Redis Pub/Sub

Pod 1: удалить из L1

Pod 2: удалить из L1

Pod 3: удалить из L1

PUBLISH invalidate product:42

альтернатива: CDC (Debezium) →
событие изменения → инвалидация

Pod 1: изменил товар 42

БД

Redis Pub/Sub

Pod 1: удалить из L1

Pod 2: удалить из L1

Pod 3: удалить из L1

Рассылка инвалидации всем экземплярам
C#
var sub = redis.GetSubscriber();
await sub.SubscribeAsync(RedisChannel.Literal("cache:invalidate"), (_, key) => memoryCache.Remove(key.ToString()));

// после записи
await sub.PublishAsync(RedisChannel.Literal("cache:invalidate"), $"product:{id}");

Pub/Sub в Redis — «fire and forget»: отключённый в момент публикации экземпляр сообщение пропустит. Поэтому короткий TTL для L1 остаётся страховкой. В микросервисах инвалидацию часто делают по доменным событиям из брокера (ProductPriceChanged).

Вопросы на засыпку

Почему инвалидация кэша считается сложной?

Нужно знать все места, где кэшированы данные, зависящие от изменения (карточка, списки, агрегаты, поиск, CDN), учитывать гонки параллельных чтений и записей, несколько экземпляров и уровней кэша, отказы сети при рассылке. Ошибка проявляется тихо — пользователи видят устаревшие данные.

Что выбрать: короткий TTL или явную инвалидацию?

Часто — оба: явная инвалидация для быстрой реакции на изменения плюс TTL как страховка от пропущенных инвалидаций и гонок. Только TTL — если бизнес допускает устаревание на время TTL.

Как инвалидировать CDN?

Через API CDN (purge по URL или тегу — Surrogate-Key/Cache-Tag), либо версионирование URL (/assets/app.3f9a.js, ?v=2) — старые URL просто перестают запрашиваться. Для статики — хэш в имени файла и «вечный» кэш.

Чем eviction отличается от expiration?

Expiration — запланированное удаление по времени (данные могли устареть). Eviction — вынужденное удаление из-за нехватки места (данные могли быть вполне актуальны). Частые eviction-ы — сигнал увеличить память кэша или пересмотреть, что кэшируется.

Итог

Инвалидация — это баланс свежести и нагрузки. TTL с jitter — базовая страховка, удаление ключей после записи (а не обновление) — быстрая реакция, теги и версии — групповая инвалидация, Pub/Sub, события или CDC — согласованность нескольких экземпляров. Вытеснение (LRU и др.) — управление памятью, а не свежестью. Помните о гонках cache-aside и не кэшируйте то, что должно быть строго актуальным.