Кэширование в Redis¶
Правила использования Redis как кэша: когда, какие паттерны, как инвалидировать, как защищаться от cache stampede, как мониторить и чего не делать.
Содержание¶
- Когда кэшируем
- Когда не кэшируем
- Cache-aside — стандарт
- Naming ключей
- TTL
- Инвалидация
- 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.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>— имя сервиса-владельца кэша. Redis-инстанс может быть общим между сервисами, но ключевое пространство разделено префиксом.<entity>— сущность в единственном числе.<id>— первичный ключ.<attr>(опционально) — какой атрибут закэширован, если это не вся сущность.<version>(опционально) — суффикс при breaking-изменении формы значения (...:rating:v2).
Ключи — в helper-функциях, а не inline:
Так проще рефакторить и грепать.
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:
Тогда никакого массового 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:
См. 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(), иначе при откате транзакции кэш будет хранить фантомное значение.
См. также¶
../patterns/api-composition.md— caching-стратегия для composition-endpoint'ов.../patterns/idempotent-consumer.md— дедуп-ключи в Redis.security.md— секреты Redis, PII-ограничения.observability.md— метрики и alert'ы.../how-to/add-metric-and-alert.md— как объявить cache-метрики.