Skip to Content
ConventionsКэширование (Redis)

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

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

Содержание

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

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

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

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

  • 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) }

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

Cardinality control кэш-ключей

Ключ review:place:{placeId}:rating:v1 при 10M placeId = 10M ключей в Redis. При maxmemory-policy allkeys-lru это начнёт вытеснять горячие ключи — средний рейтинг по популярным местам пропадает из кэша, а счётчики по давно не используемым placeId занимают память.

Правила:

  • Перед добавлением нового кэш-ключа оценивай expected cardinality по формуле entities × variants × versions. Результат > 1M ключей — обязательный разбор с lead-инженером backend.
  • Метрика per префикс: redis_keys_total{prefix="review:place"} — gauge, собирается из периодического SCAN MATCH prefix:* COUNT 10000
    • count (раз в 5 минут, не чаще, чтобы не нагружать Redis).
  • Alert: рост redis_keys_total{prefix=X} > 20% за сутки без релиза — инцидент cardinality (либо утечка, либо неожиданный всплеск входных данных).

Формат префикса стабильный и начинается с имени сервиса:

<service>:<entity>:<key>[:v<N>]

Нормализация значений в ключе — через helper, чтобы разные реплики не получили разные ключи из-за регистра или пробелов:

type CacheKey struct { Service string Entity string ID string Attr string Version int } func (k CacheKey) String() string { norm := func(s string) string { s = strings.ToLower(strings.TrimSpace(s)) s = strings.ReplaceAll(s, ":", "_") // запрет двоеточий внутри значения s = strings.ReplaceAll(s, " ", "_") return s } parts := []string{norm(k.Service), norm(k.Entity), norm(k.ID)} if k.Attr != "" { parts = append(parts, norm(k.Attr)) } if k.Version > 0 { parts = append(parts, fmt.Sprintf("v%d", k.Version)) } return strings.Join(parts, ":") }

Запрещено класть в ключ:

  • user_id в глобальный (общий между пользователями) кэш — если только это не осознанный per-user shard с контролируемой cardinality.
  • timestamp с миллисекундной точностью — каждый запрос создаёт новый ключ, hit-ratio падает до нуля.
  • Произвольный user input без валидации длины — 10 KB URL-параметр превращается в 10 KB ключ, Redis-память утекает.

TTL

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

Cache penetration (sentinel NOT_FOUND)

Запросы на несуществующий place_id бьют в БД бесконечно: кэш миссует потому, что «нет такого», и в кэш ничего не попадает. Специально подобранный перебор ID превращается в DDoS по БД через кэш.

Решение: после первого DB-miss записывай в Redis sentinel-значение __NOT_FOUND__ с коротким TTL (30–60 секунд).

const sentinelNotFound = "__NOT_FOUND__" func (s *Service) GetPlace(ctx context.Context, id int64) (*domain.Place, error) { key := placeKey(id) val, err := s.cache.Get(ctx, key) if err == nil && val == sentinelNotFound { return nil, domain.ErrNotFound } if err == nil && val != "" { return decodePlace(val) } place, err := s.repo.GetPlace(ctx, id) if errors.Is(err, pkgdb.ErrNotFound) { _ = s.cache.Set(ctx, key, sentinelNotFound, 60*time.Second) return nil, domain.ErrNotFound } if err != nil { return nil, fmt.Errorf("repo.GetPlace: %w", err) } _ = s.cache.SetPlace(ctx, id, place, withJitter(10*time.Minute)) return place, nil }

Правила:

  • TTL sentinel’а значительно короче, чем TTL положительного ответа (60с против 10мин) — чтобы быстро «забыть» о записях, которые только что были созданы.
  • При успешном create/update — invalidate sentinel (DEL key) в том же consumer’е, который обрабатывает outbox-событие о создании сущности (см. Event-based).
  • Защита от прицельного перебора несуществующих ID: rate-limit на уровне endpoint’а по client_ip + user (см. security). Для таблиц > 10M записей — опционально Bloom filter перед походом в Redis.

Stale writes в write-through при нескольких writer’ах

Две реплики сервиса одновременно принимают UPDATE, обе пишут в БД и в кэш. Порядок записи в кэш не связан с порядком commit’а в БД — реплика A коммитит позже, но её SET прилетел в Redis раньше. В кэше оказывается более старое значение.

Правило: write-through не используется для сущностей, которые обновляются из > 1 процесса. Вместо этого:

  • Пишем в БД + outbox в одной транзакции (см. ../patterns/outbox).
  • Consumer события обновляет кэш — single writer в рамках одного consumer-group, partition key = entity ID.
  • Partition key гарантирует, что события одной сущности попадают в одну партицию и обрабатываются в порядке publish’а.

Когда write-through всё-таки нужен (read-after-write от того же пользователя на той же реплике — компенсация eventual consistency в UI):

  • Писать в кэш после успешного commit’а, не до.
  • Использовать SET key value EX ttl XX — обновляет только если ключ уже есть. Между GET и SET пришедший с другой реплики SET не будет перезаписан пустым местом.
  • Либо хранить (value, version) и применять Lua-скрипт с CAS по version: пишем в кэш, только если входящая version > текущей.

Anti-pattern (приводит к инверсии порядка):

// НЕ ДЕЛАЙ ТАК if err := repo.Update(ctx, p); err != nil { return err } if err := cache.Set(ctx, key, p, ttl); err != nil { /* ignore */ } // между commit и Set другой процесс мог сделать то же самое — // кэш может оказаться с более старым значением.

Правильный путь — см. Event-based invalidation + consumer с partition key.

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.

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

  • 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_totalCountercache
cache_misses_totalCountercache
cache_errors_totalCountercache, op, reason
cache_get_duration_secondsHistogramcache, 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 для деталей объявления метрик.

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.
  • TLS для удалённого Redis. Для локального в docker-compose.yml допустимо без TLS.
  • PII в кэше — только в зашифрованном виде. Если Redis-снапшот утечёт, в него не должны попасть email/phone/token. Безопаснее не класть PII в кэш вовсе; если кэшируешь — поле должно быть уже хэшировано/зашифровано.
  • Dedup-ключи не несут чувствительной информации: хранится только hash message-id. См. ../patterns/idempotent-consumer.

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(), иначе при откате транзакции кэш будет хранить фантомное значение.

См. также

Last updated on