Паттерн использования Redis как распределённого кэша для ответов API. Применим к любому бэкенду с несколькими процессами/инстансами.

Двухуровневый кэш (L1 + L2)

СлойХранилищеЛатентностьScopeКогда использовать
L1In-process (Map, LRU)~0msОдин процессГорячие данные, справочники
L2Redis~2-5msВсе процессы/инстансыДорогие запросы, shared state

L1 проверяется первым. Промах L1 → L2. Промах L2 → вычисление + запись в оба слоя.

Преимущество двухуровневой схемы: при горизонтальном масштабировании (несколько worker-ов или Pod-ов) L2 обеспечивает единый кэш, а L1 снимает нагрузку с Redis для самых частых запросов.

Cache-aside с inflight deduplication

Базовый паттерн — cache-aside: проверь кэш → промах → вычисли → запиши. Проблема: при одновременных запросах к одному ключу все параллельно идут в БД (cache stampede).

Решение — inflight Map: пока factory выполняется, все последующие запросы к тому же ключу получают тот же Promise:

inflight = Map<key, Promise>

cached(key, ttl, factory):
  hit = redis.GET(key)       // L2 lookup
  if hit → return parse(hit)

  if inflight.has(key)       // кто-то уже считает
    return inflight.get(key) // ждём тот же результат

  promise = factory()
    .then(value → redis.SET(key, value, EX ttl))
    .finally(→ inflight.delete(key))

  inflight.set(key, promise)
  return promise

Ограничение: inflight dedup работает per-process. При нескольких инстансах возможен параллельный вызов factory для одного ключа. Для кластерного решения нужен distributed lock (Redlock), но при малом масштабе это избыточно.

Graceful degradation

Redis как кэш должен быть опциональным — приложение работает без него, просто медленнее. Все Redis-операции оборачиваются в try/catch и не прерывают основной flow:

  • GET failed → вызвать factory напрямую
  • SET failed → вернуть результат без записи в кэш
  • DEL (инвалидация) failed → TTL удалит ключ позже

При недоступности Redis каждый запрос идёт в БД. L1 in-memory смягчает удар для горячих данных.

Проектирование ключей

Ключ должен включать все параметры, влияющие на ответ:

{entity}:{scope}:{filter1}:{filter2}
leaders:region42:month
routes:all:rating:false

Правила:

  • Scope isolation — если API фильтрует по региону/tenant, ключ должен это отражать. Запрос без фильтра → отдельный ключ (например all)
  • Whitelist параметров — не включать произвольные пользовательские значения в ключ. Иначе открытое пространство ключей, которое невозможно полностью инвалидировать. Кэшировать только для известного набора значений, остальные — мимо кэша
  • Per-user данные отдельно — кэшировать shared часть ответа, per-user данные (is_member, my_rank) мёрджить после cache hit через дешёвые запросы

Обработка corrupt данных

JSON.parse() может упасть на повреждённых данных в кэше. Важно изолировать эту ошибку от ошибок соединения:

try:
  hit = redis.GET(key)         // connection error → catch outer
  if hit:
    try:
      return parse(hit)        // parse error → catch inner
    catch:
      redis.DEL(key)           // удалить битый ключ
      increment error(op=parse)
catch:
  increment error(op=get)      // connection failure — другая метрика

Без изоляции corrupt entry выглядит как “Redis down” в метриках — ложная картина.

Инвалидация: race condition с inflight

При вызове invalidate(key) нужно также очищать inflight Map. Иначе возможен race:

  1. Запрос A: cache miss → factory запущен (5 секунд)
  2. Мутация: invalidate(key) → удаляет из Redis
  3. Запрос A factory завершается → записывает устаревшие данные обратно в Redis

Решение: invalidate() должен удалять ключ и из Redis, и из inflight Map.

Observability

Минимальный набор метрик:

МетрикаТипLabelsЗачем
cache_hits_totalCounterprefixHit rate = hits/(hits+misses)
cache_misses_totalCounterprefixЭффективность по типу данных
cache_errors_totalCounterop (get/set/del/parse)Отличить connection errors от data corruption
cache_operation_duration_secondsHistogramopp95 латентность, обнаружение деградации

Алерты:

  • Cache down — нет hits и misses при наличии предыдущей активности (не путать с “Redis не настроен”)
  • Low hit rate — < 30% за 30 минут (кэш неэффективен)
  • High error rate — > 0.1/s за 5 минут
  • High latency — p95 > 100ms

Методология выбора TTL

Два режима определяют подход:

РежимTTLОбоснование
С explicit инвалидациейДлинный (1-2ч)TTL — safety net. Данные обновляются мгновенно через invalidate(). Длинный TTL уменьшает нагрузку на БД
Только TTL (без инвалидации)Короткий (5-30м)TTL — единственная защита от stale data. Баланс между свежестью и нагрузкой

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

Связанные заметки