
Кэш — это быстрые ответы без покупки лишних серверов. Хороший кэш снижает задержки, разгружает базу, сглаживает пики трафика и уменьшает расходы. Плохой кэш делает наоборот: во время распродажи всё валится из‑за «шторма кэша», пользователи видят старые данные, команда жжёт бюджет на оверхед и расследования.
Ниже — набор практик, которые приносят прямую пользу:
Классика: срок хранения ключа истёк, сотни запросов одновременно идут в базу/микросервис за обновлением. Ресурс не выдерживает, начинается лавина сбоев.
Боты или легитимные запросы часто спрашивают несуществующие сущности. Если 404 не кэшировать, каждый запрос ударит в БД/поисковик.
У популярных ключей TTL часто истекает одновременно (напр. на «00» минут). В момент истечения — пик запросов в источник правды.
Пишем в БД, забываем обновить кэш — пользователи видят старое. Или наоборот: чистим кэш слишком рано — читаем из БД с повышенной задержкой.
Добавляйте случайный разброс к TTL. В итоге разные ключи и даже однотипные ключи истекают не одновременно — меньше пиков:
Два срока:
Пользователь почти всегда получает быстрый ответ из кэша. Если soft TTL прошёл, система запускает фоновое обновление и возвращает старое значение, не дожидаясь источника данных.
Если кэш промахнулся, объединяем конкурирующие запросы к одному ключу в один полёт: первый запрос идёт к источнику, остальные ждут его результат. Это режет лавину в разы.
Поверх singleflight внутри процесса используйте короткую блокировку в Redis (SET NX EX), чтобы и на нескольких репликах сервиса не началась гонка за обновление одного и того же ключа.
Если сущность не найдена — кэшируйте «пустой» результат коротким TTL (например, 30–60 секунд). Это защищает БД от повторяющихся 404.
Если Redis умер или сеть глючит — не надо умирать вместе с ним. Примите решение заранее:
Отслеживайте и алертите по:
Простой порог: если hit ratio падает ниже 85–90% у горячей выборки — это повод искать причины (массовые инвалидации, рассинхрон, неверный TTL).
Ниже — готовый пример: cache-aside с джиттером TTL, stale‑while‑revalidate, коалесцированием запросов и отрицательным кэшированием. Код экономит походы в БД и избегает штормов.
Перед запуском поднимите Redis (локально):
docker run --rm -p 6379:6379 redis:7-alpine
Код (Go 1.21+):
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math/rand"
"time"
redis "github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
// Модель домена
type Product struct {
ID int64 `json:"id"`
Name string `json:"name"`
PriceCents int64 `json:"price_cents"`
}
// Обёртка для хранения в Redis: поддерживаем soft/hard TTL и «пустые» значения.
type cacheEnvelope struct {
Data *Product `json:"data,omitempty"`
SoftExpire int64 `json:"soft_expire_unix"`
HardExpire int64 `json:"hard_expire_unix"`
Nil bool `json:"nil,omitempty"`
}
var (
rdb *redis.Client
group singleflight.Group
// Бизнес‑ошибки
ErrNotFound = errors.New("not found")
)
// Настройки TTL
const (
baseHardTTL = 5 * time.Minute // жёсткий срок годности
baseSoftTTL = 1 * time.Minute // мягкий срок: после него отдаём stale и фоном обновляем
negCacheTTL = 30 * time.Second // TTL для Not Found
ttlJitterSpread = 0.20 // ±20% разброс TTL
lockTTL = 30 * time.Second // TTL для распределённой блокировки
)
func main() {
// Инициализируем Redis
rdb = redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
// Пример нагрузки: параллельные запросы одного и того же ключа
id := int64(42)
concurrency := 20
ch := make(chan struct{}, concurrency)
for i := 0; i < concurrency; i++ {
ch <- struct{}{}
go func(i int) {
defer func() { <-ch }()
ctxReq, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
p, err := GetProduct(ctxReq, id)
if err != nil {
log.Printf("worker %d: error: %v", i, err)
return
}
log.Printf("worker %d: got product: %+v", i, p)
}(i)
}
// дождаться завершения всех горутин
for i := 0; i < concurrency; i++ {
ch <- struct{}{}
}
log.Println("done")
}
func cacheKeyProduct(id int64) string {
return fmt.Sprintf("product:%d", id)
}
func lockKeyProduct(id int64) string {
return fmt.Sprintf("lock:product:%d", id)
}
// GetProduct — чтение с многоуровневой защитой от штормов.
func GetProduct(ctx context.Context, id int64) (*Product, error) {
key := cacheKeyProduct(id)
now := time.Now()
// 1) Пробуем Redis
bs, err := rdb.Get(ctx, key).Bytes()
if err == nil {
env := cacheEnvelope{}
if e := json.Unmarshal(bs, &env); e == nil {
// Хард‑TTL истёк — считаем промахом
if now.Unix() >= env.HardExpire {
// промах — пойдём загружать ниже
} else {
// Not Found — сразу возвращаем
if env.Nil {
return nil, ErrNotFound
}
// Если soft TTL прошёл — триггерим фоновой рефреш, но возвращаем старые данные быстро
if now.Unix() >= env.SoftExpire {
go func() {
ctxBg, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = refreshProduct(ctxBg, id)
}()
}
return env.Data, nil
}
}
} else if err != redis.Nil {
// Redis недоступен — логируем и деградируем на источник данных
log.Printf("redis get error: %v", err)
}
// 2) Промах: коалесцируем запросы, чтобы один полёт сходил в БД
v, err, _ := group.Do("load:"+key, func() (any, error) {
ctxLoad, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
p, e := loadProductFromDB(ctxLoad, id)
if e != nil {
if errors.Is(e, ErrNotFound) {
// Отрицательное кэширование
_ = saveEnvelope(ctx, key, cacheEnvelope{
Nil: true,
SoftExpire: now.Add(jitter(negCacheTTL, ttlJitterSpread)).Unix(),
HardExpire: now.Add(jitter(negCacheTTL, ttlJitterSpread)).Unix(),
})
return nil, ErrNotFound
}
return nil, e
}
// Сохраняем с мягким/жёстким TTL
_ = saveEnvelope(ctx, key, cacheEnvelope{
Data: p,
SoftExpire: now.Add(jitter(baseSoftTTL, ttlJitterSpread)).Unix(),
HardExpire: now.Add(jitter(baseHardTTL, ttlJitterSpread)).Unix(),
})
return p, nil
})
if err != nil {
return nil, err
}
return v.(*Product), nil
}
// refreshProduct — фоновое обновление с распределённой блокировкой.
func refreshProduct(ctx context.Context, id int64) error {
lkey := lockKeyProduct(id)
ok, err := rdb.SetNX(ctx, lkey, "1", lockTTL).Result()
if err != nil {
return err
}
if !ok {
return nil // кто-то уже обновляет
}
defer func() { _ = rdb.Del(context.Background(), lkey).Err() }()
p, e := loadProductFromDB(ctx, id)
now := time.Now()
key := cacheKeyProduct(id)
if e != nil {
if errors.Is(e, ErrNotFound) {
return saveEnvelope(ctx, key, cacheEnvelope{
Nil: true,
SoftExpire: now.Add(jitter(negCacheTTL, ttlJitterSpread)).Unix(),
HardExpire: now.Add(jitter(negCacheTTL, ttlJitterSpread)).Unix(),
})
}
return e
}
return saveEnvelope(ctx, key, cacheEnvelope{
Data: p,
SoftExpire: now.Add(jitter(baseSoftTTL, ttlJitterSpread)).Unix(),
HardExpire: now.Add(jitter(baseHardTTL, ttlJitterSpread)).Unix(),
})
}
func saveEnvelope(ctx context.Context, key string, env cacheEnvelope) error {
bs, err := json.Marshal(env)
if err != nil {
return err
}
// Истекаем по жёсткому сроку
ttl := time.Until(time.Unix(env.HardExpire, 0))
if ttl <= 0 {
// На всякий пожарный, не записываем уже истёкшее
return nil
}
return rdb.Set(ctx, key, bs, ttl).Err()
}
// Имитируем источник правды (БД): быстрый NotFound, небольшая задержка на success.
func loadProductFromDB(ctx context.Context, id int64) (*Product, error) {
// Для примера: каждый пятый ID отдаёт NotFound
if id%5 == 0 {
return nil, ErrNotFound
}
select {
case <-time.After(120 * time.Millisecond):
return &Product{
ID: id,
Name: fmt.Sprintf("Product-%d", id),
PriceCents: 1999,
}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func jitter(d time.Duration, spread float64) time.Duration {
if spread <= 0 {
return d
}
factor := 1 + (rand.Float64()*2-1)*spread // [1-spread, 1+spread]
return time.Duration(float64(d) * factor)
}
Что делает код:
Подключите метрики (время ответа, попадания/промахи, долю stale), и вы почти полностью уберёте «штормы кэша».
Кэш — не просто «поставим Redis и готово». Чтобы кэш действительно экономил деньги и держал SLA, устраните шторм кэша, размажьте TTL, кэшируйте Not Found, коалесцируйте промахи и обновляйте данные фоном. Добавьте метрики, тёплый старт и деградацию при сбоях — и ваши ответы останутся быстрыми даже под пиковыми нагрузками, без лишних серверов и ночных дежурств.