Кэширование в Redis
Правила использования Redis как кэша: когда, какие паттерны, как инвалидировать, как защищаться от cache stampede, как мониторить и чего не делать.
Содержание
- Когда кэшируем
- Когда не кэшируем
- Cache-aside — стандарт
- Naming ключей
- Cardinality control кэш-ключей
- TTL
- Инвалидация
- Cache penetration (sentinel NOT_FOUND)
- Stale writes в write-through при нескольких writer’ах
- Stampede protection
- Redis client setup
- Сериализация
- Observability
- Redis failure modes
- Security
- Anti-patterns
- См. также
Когда кэшируем
Кэш оправдан, когда чтение из БД дороже допустимой латентности или когда повторные запросы дают одинаковый результат в пределах окна 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 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.
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_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
для деталей объявления метрик.
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(), иначе при откате транзакции кэш будет хранить фантомное значение.
См. также
../patterns/api-composition— caching-стратегия для composition-endpoint’ов.../patterns/idempotent-consumer— дедуп-ключи в Redis.security— секреты Redis, PII-ограничения.observability— метрики и alert’ы.../how-to/add-metric-and-alert— как объявить cache-метрики.