Перейти к содержанию

Кэширование в Redis

Правила использования Redis как кэша: когда, какие паттерны, как инвалидировать, как защищаться от cache stampede, как мониторить и чего не делать.

Содержание

Когда кэшируем

Кэш оправдан, когда чтение из БД дороже допустимой латентности или когда повторные запросы дают одинаковый результат в пределах окна TTL. Стандартные кейсы:

  • Тяжёлые read-запросы. JOIN по 3+ таблицам, aggregate (средний рейтинг), выборки с сортировкой по вычисляемому полю.
  • API-composition. Downstream-сервис отвечает 200 ms, кэш — 2 ms. См. ../patterns/api-composition.md.
  • External calls. Ответы сторонних API с собственным rate-limit.
  • Rate-limit данные. Счётчики запросов per user/IP, denylist, сессии.
  • Idempotency keys / дедупликация. Хранилище ключей дедупа для Watermill middleware. См. ../patterns/idempotent-consumer.md.

Когда не кэшируем

  • Write-heavy данные. Цена инвалидации превышает выигрыш от чтения.
  • Актуальность критична (<1 с). Баланс на счёте, инвентарь — читай напрямую из БД или используй event-driven invalidation с fast-path.
  • Объём > 1 ГБ на сервис. Redis-память дорогая — рассмотри другую стратегию (Postgres materialized view, projection-table).
  • Простые запросы < 10 ms. Overhead Redis-вызова съест выигрыш.
  • Сложная консистентность. Если в одной операции нужно прочитать связные данные из разных ключей — в Redis ты теряешь ACID.

Cache-aside — стандарт

Базовый паттерн для всех read-путей. Сервис сначала смотрит в кэш, потом в БД, потом кладёт в кэш.

func (s *Service) GetPlace(ctx context.Context, id int64) (*domain.Place, error) {
    if cached, err := s.cache.GetPlace(ctx, id); err == nil && cached != nil {
        metrics.CacheHit.WithLabelValues("place").Inc()
        return cached, nil
    }
    metrics.CacheMiss.WithLabelValues("place").Inc()

    place, err := s.repo.GetPlace(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("repo.GetPlace: %w", err)
    }

    // best-effort: ошибку кэша не пробрасываем
    if err := s.cache.SetPlace(ctx, id, place, 5*time.Minute); err != nil {
        log.FromCtx(ctx).Warn("cache set", "err", err, "key", "place", "id", id)
    }
    return place, nil
}

Правила:

  • Cache error не блокирует сервис (fail-open). Если Redis упал — сервис продолжает отдавать данные из БД, только медленнее.
  • cache.Set — best-effort. Логируй WARN, не ERROR. Инкрементируй cache_errors_total.
  • Не кэшируй отрицательные ответы как ошибки. Если repo.GetPlace вернул ErrNotFound — решай по-месту: кэшировать sentinel-значение «нет такого» с коротким TTL (против «не найдено» в тугом цикле) или не кэшировать вовсе.

Naming ключей

Единый формат:

<service>:<entity>:<id>[:<attr>][:<version>]

Примеры:

review:place:42:rating
review:user:1001:feed:page:0
user:session:01HZABC...
media:photo:7:thumbnail

Правила:

  • <service> — имя сервиса-владельца кэша. Redis-инстанс может быть общим между сервисами, но ключевое пространство разделено префиксом.
  • <entity> — сущность в единственном числе.
  • <id> — первичный ключ.
  • <attr> (опционально) — какой атрибут закэширован, если это не вся сущность.
  • <version> (опционально) — суффикс при breaking-изменении формы значения (...:rating:v2).

Ключи — в helper-функциях, а не inline:

func placeRatingKey(placeID int64) string {
    return fmt.Sprintf("review:place:%d:rating", placeID)
}

Так проще рефакторить и грепать.

TTL

Данные TTL Почему
Stable (имя места, адрес) 1–24 ч Меняется редко
Volatile (средний рейтинг) 30–300 с Обновляется часто
Session / JWT payload = TTL JWT Не переживает токен
Dedup keys (idempotency) 24 ч Перекрывает окно retry
Feature flag 60 с Компромисс между свежестью и нагрузкой на config-store

Бесконечный TTL запрещён. Даже для «неизменных» справочников ставь TTL ≥ 1 сутки — иначе при изменении данных ты не знаешь, какие именно ключи протухли.

Jitter

Если у тысячи ключей одинаковый TTL и они были проставлены одновременно, они истекают одновременно — тысяча cache-миссов в ту же секунду (thundering herd). Добавляй jitter 10–20%:

func withJitter(base time.Duration) time.Duration {
    j := time.Duration(rand.Int63n(int64(base / 5))) // до 20%
    return base + j
}

_ = s.cache.SetPlace(ctx, id, place, withJitter(5*time.Minute))

Инвалидация

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

TTL-based (default)

Самый простой: данные протухают сами. Выбирай когда можно жить с рассинхроном до TTL (обычно до 5 минут). Cache-write = SET key value EX <ttl>.

Event-based

Consumer в сервисе слушает свой же (или чужой) топик и инвалидирует ключ при изменении данных:

func (h *CacheInvalidator) OnReviewUpdated(ctx context.Context, msg *message.Message) error {
    var p events.ReviewUpdated
    if err := json.Unmarshal(msg.Payload, &p); err != nil {
        return err
    }
    if err := h.cache.Del(ctx, placeRatingKey(p.PlaceID)); err != nil {
        log.FromCtx(ctx).Warn("cache del", "err", err)
        return nil  // fail-open: не блокируем consumer-group
    }
    return nil
}

Выбирай для критичных данных, где TTL > 5 с неприемлем. Минус — сложнее: нужна Kafka-подписка и fan-out по ключам.

Write-through

В том же месте, где пишешь в БД, пишешь и в кэш:

func (s *Service) UpdatePlace(ctx context.Context, p domain.Place) error {
    if err := s.repo.UpdatePlace(ctx, p); err != nil {
        return err
    }
    _ = s.cache.SetPlace(ctx, p.ID, &p, withJitter(5*time.Minute))
    return nil
}

Работает только для single-instance writer-сценария. При нескольких репликах есть окно, когда одна реплика обновила БД, другая в кэше увидит старое значение. Для критичных случаев используй event-based.

Cache versioning

Когда меняется форма значения в кэше (добавили поле, переименовали), bump version suffix:

review:place:42:rating:v1   — старый, истекает сам
review:place:42:rating:v2   — новый

Тогда никакого массового flush не нужно — старые ключи просто не читаются новым кодом и истекают по TTL.

Stampede protection

Когда популярный ключ протух и одновременно приходят сотни запросов, все они получают miss и бьют в БД. Два способа защиты.

Singleflight

golang.org/x/sync/singleflight — одновременные промахи на один ключ объединяются в один downstream-вызов:

var group singleflight.Group

func (s *Service) GetPlace(ctx context.Context, id int64) (*domain.Place, error) {
    if cached, _ := s.cache.GetPlace(ctx, id); cached != nil {
        return cached, nil
    }
    v, err, _ := group.Do(fmt.Sprintf("place:%d", id), func() (any, error) {
        p, err := s.repo.GetPlace(ctx, id)
        if err != nil {
            return nil, err
        }
        _ = s.cache.SetPlace(ctx, id, p, withJitter(5*time.Minute))
        return p, nil
    })
    if err != nil {
        return nil, err
    }
    return v.(*domain.Place), nil
}

Работает только внутри одного процесса. Между репликами защиту даёт jitter + допустимая нагрузка на БД.

Probabilistic early expiration

Реплика может «освежить» запись до фактического истечения TTL с вероятностью, растущей по мере приближения к истечению:

// β-probabilistic early refresh
if rand.Float64() < probabilityOfRefresh(remainingTTL, totalTTL) {
    go s.refreshCache(context.Background(), id)
}
return cached, nil

Это сложнее; применяй только для доказанно горячих ключей.

Redis client setup

Стандартный клиент — github.com/redis/go-redis/v9.

import "github.com/redis/go-redis/v9"

client := redis.NewUniversalClient(&redis.UniversalOptions{
    Addrs:        cfg.Redis.Addrs,
    Password:     cfg.Redis.Password,
    DB:           cfg.Redis.DB,
    DialTimeout:  500 * time.Millisecond,
    ReadTimeout:  100 * time.Millisecond,
    WriteTimeout: 100 * time.Millisecond,
    PoolSize:     20,
})
  • UniversalClient абстрагирует single-node и cluster — переключение режима меняет только конфиг.
  • Timeouts обязательны: без них медленный Redis превращается в медленный сервис.
  • Health-проверка — в /readyz:
if err := redis.Ping(ctx).Err(); err != nil {
    return fmt.Errorf("redis: %w", err)
}

См. http-api.md.

Сериализация

  • JSON (encoding/json) — дефолт. Читаемо в redis-cli, удобно отлаживать, работает для 90% случаев.
  • Protobuf / msgpack — только для hot-path с > 10k RPS per cache key, где выигрыш по CPU и объёму окупается.
  • Go interfaces / gob — запрещено. Gob не детерминирован между версиями компилятора; интерфейсы не десериализуются корректно.

Observability

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

Метрика Тип Labels
cache_hits_total Counter cache
cache_misses_total Counter cache
cache_errors_total Counter cache, op, reason
cache_get_duration_seconds Histogram cache, result

Производные — как recording rules в Prometheus:

cache_hit_ratio = rate(cache_hits_total[5m])
               / (rate(cache_hits_total[5m]) + rate(cache_misses_total[5m]))

Alert'ы:

  • cache_hit_ratio < 0.5 в течение 15 минут — кэш неэффективен.
  • rate(cache_errors_total[5m]) > 0.01 — Redis проблемы.
  • histogram_quantile(0.99, cache_get_duration_seconds) > 20ms — сетевая деградация.

См. ../how-to/add-metric-and-alert.md для деталей объявления метрик.

Redis failure modes

  • Redis недоступен. Все GET возвращают ошибку → cache miss → DB. Сервис продолжает работать, просто медленнее. Alert ловит спайк cache_errors_total.
  • Slow Redis. Timeout на GET/SET → fail-open в DB. Важно, чтобы ReadTimeout был меньше request-timeout'а сервиса, иначе medieval Redis добивает весь endpoint.
  • Memory full (maxmemory). Redis начинает eviction по политике. Рекомендованная политика — allkeys-lru (для общего кэша) или volatile-lru (если в Redis есть и нетленные ключи, например rate-limit счётчики).
  • Network partition. Клиент timeout'ится на каждом вызове. Circuit breaker опционален; обычно достаточно короткого timeout'а и fail-open.

Security

  • Auth password — обязателен. Даже в dev. .env.example содержит placeholder (change_me). См. security.md.
  • TLS для удалённого Redis. Для локального в docker-compose.yml допустимо без TLS.
  • PII в кэше — только в зашифрованном виде. Если Redis-снапшот утечёт, в него не должны попасть email/phone/token. Безопаснее не класть PII в кэш вовсе; если кэшируешь — поле должно быть уже хэшировано/зашифровано.
  • Dedup-ключи не несут чувствительной информации: хранится только hash message-id. См. ../patterns/idempotent-consumer.md.

Anti-patterns

  • Бесконечный TTL без инвалидации. Протухшие данные живут вечно.
  • Cache of cache. Несколько слоёв кэша (local LRU → Redis → memcached). Невозможно отлаживать, инвалидация ломается.
  • Бизнес-логика в Redis. WATCH/MULTI/EXEC как distributed transaction — ненадёжно, медленно и не ACID. Используй Postgres.
  • Redis как primary store. Redis — кэш / очередь / pub-sub. Не primary. Persistence-конфиг не даёт ACID-гарантий.
  • Ключ без TTL при записи. Забыли EX → ключ висит вечно, память утекает.
  • Инвалидация через FLUSHDB в prod. Убивает весь кэш, создаёт stampede. Только точечные DEL.
  • Пробрасывание Redis-ошибки наружу. Сервис начинает отдавать 500 при падении Redis — это превращает кэш в SPOF.
  • Кэш внутри транзакции. SET после tx.Commit(), иначе при откате транзакции кэш будет хранить фантомное значение.

См. также