Опирается на правила: R-CACHE-STAMP-1R-CACHE-STAMP-3 и R-CACHE-STAMP-X1R-CACHE-STAMP-X2 из Caching Style Guide → раздел 7. Cache stampede.

Важно знать

  • Cache stampede — параллельные запросы на истёкший ключ: каждый видит cache miss, все уходят в БД одновременно, DB получает N одинаковых тяжёлых запросов.
  • singleflight (golang.org/x/sync/singleflight) коллапсирует дублирующиеся in-flight запросы внутри одного процесса — идиоматичная замена Java sync = true.
  • sync.Mutex / sync.Map для защиты Redis-кеша бесполезны: lock виден только внутри одного пода.
  • Distributed lock через Redis SetNX — единственный способ защитить multi-instance деплой без сторонних зависимостей.
  • Refresh-ahead — фоновая горутина перезаливает кеш до истечения TTL; stampede исключён по дизайну.
  • Probabilistic refresh — перезапись с возрастающей вероятностью по мере приближения к expiry; размазывает нагрузку.
  • Нагрузка >100 RPS + нет защиты + рестарт Redis = каскадный инцидент (R-CACHE-STAMP-X1).
  • Ошибка загрузки в singleflight разделяется между всеми ожидающими — обрабатывай как обычную ошибку-значение.

Cache stampede — типичная причина каскадного инцидента после рестарта Redis, после FLUSHDB, после первого деплоя сервиса. UCP определяет три уровня защиты: для single-instance (in-process), для distributed (Redis, multi-pod), для hot-ключей с известным трафиком.

Что такое stampede

Ключ order-summaries:top-100 истёк.

T=0    goroutine-1  GET → miss → SELECT top 100 orders... (тяжёлый, 300ms)
T=5ms  goroutine-2  GET → miss → SELECT top 100 orders...
T=10ms goroutine-3  GET → miss → SELECT top 100 orders...
...
T=200ms goroutine-1 → SET в Redis
T=205ms goroutine-2 → SET в Redis (поверх)
T=210ms goroutine-3 → SET в Redis (поверх)

При нагрузке 200 RPS и TTL 30 секунд — после рестарта Redis за первые 300ms БД получает ~60 идентичных тяжёлых запросов. Latency растёт у всех downstream-сервисов.

Single-instance: singleflight

R-CACHE-STAMP-1: для in-process защиты — golang.org/x/sync/singleflight. Он коллапсирует параллельные вызовы с одним ключом: первый идёт в БД, остальные получают тот же результат.

// adapters/out/cache/singleflight_wrapper.go
import "golang.org/x/sync/singleflight"

type SingleflightOrderCache struct {
    inner OrderCache
    group singleflight.Group
}

func (c *SingleflightOrderCache) GetOrLoad(
    ctx context.Context,
    orderID string,
    loader func() (*OrderSummary, error),
) (*OrderSummary, error) {
    v, err, _ := c.group.Do(orderID, func() (any, error) {
        if cached, err := c.inner.GetSummary(ctx, orderID); err == nil && cached != nil {
            return cached, nil
        }
        return loader()
    })
    if err != nil {
        return nil, err
    }
    return v.(*OrderSummary), nil
}

Третий возврат Do — флаг shared bool: true означает, что этот вызов получил результат от другой уже выполнявшейся горутины, а не вычислил сам. Полезно для метрик.

Ограничение: singleflight работает только внутри одного процесса. При 10 подах каждый под всё равно выполнит один запрос в БД — итого 10 вместо 1. Для multi-instance нужен distributed lock.

Distributed cache — Redis SetNX

R-CACHE-STAMP-2: для Redis multi-instance — distributed lock через SetNX.

// adapters/out/cache/order_cache.go
func (c *RedisOrderCache) GetOrLoadLocked(
    ctx context.Context,
    orderID string,
    loader func() (*OrderSummary, error),
    ttl time.Duration,
) (*OrderSummary, error) {
    if cached, _ := c.GetSummary(ctx, orderID); cached != nil {
        return cached, nil
    }

    lockKey := "lock:order-summaries:" + orderID
    lockVal := uuid.New().String()
    acquired, err := c.redis.SetNX(ctx, lockKey, lockVal, 10*time.Second).Result()
    if err != nil {
        return nil, fmt.Errorf("acquire lock for order %s: %w", orderID, err)
    }

    if !acquired {
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(50 * time.Millisecond):
        }
        return c.GetSummary(ctx, orderID)
    }
    defer c.releaseLock(ctx, lockKey, lockVal)

    if cached, _ := c.GetSummary(ctx, orderID); cached != nil {
        return cached, nil
    }

    summary, err := loader()
    if err != nil {
        return nil, err
    }
    if err := c.SetSummary(ctx, orderID, summary, ttl); err != nil {
        slog.WarnContext(ctx, "cache set after lock", "order_id", orderID, "error", err)
    }
    return summary, nil
}

releaseLock — Lua-скрипт: снимает lock только если значение совпадает с UUID, выданным при захвате. Без этого один под может снять lock другого.

var releaseLockScript = redis.NewScript(`
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
`)

func (c *RedisOrderCache) releaseLock(ctx context.Context, key, val string) {
    if err := releaseLockScript.Run(ctx, c.redis, []string{key}, val).Err(); err != nil {
        slog.WarnContext(ctx, "lock release failed", "key", key, "error", err)
    }
}

Double-check после ожидания обязателен: пока горутина ждала 50ms, держатель lock мог заполнить кеш. Без double-check — лишний запрос в БД.

Probabilistic refresh

Для высокочастотных ключей с предсказуемым TTL — вероятностное обновление до истечения. Чем ближе к expiry, тем выше вероятность, что текущий запрос обновит значение.

// core/product/query_handler.go
type cachedProductCatalog struct {
    Items    []*ProductSummary `json:"items"`
    CachedAt time.Time         `json:"cached_at"`
}

func (h *GetProductCatalogHandler) Handle(ctx context.Context, q GetProductCatalogQuery) ([]*ProductSummary, error) {
    entry, err := h.cache.GetCatalog(ctx, q.CategoryID)
    if err != nil || entry == nil || h.shouldRefresh(entry, h.cfg.Cache.ProductCatalogTTL) {
        items, err := h.repo.GetCatalog(ctx, q.CategoryID)
        if err != nil {
            if entry != nil {
                return entry.Items, nil
            }
            return nil, err
        }
        fresh := &cachedProductCatalog{Items: items, CachedAt: time.Now()}
        if setErr := h.cache.SetCatalog(ctx, q.CategoryID, fresh, h.cfg.Cache.ProductCatalogTTL); setErr != nil {
            slog.WarnContext(ctx, "catalog cache set failed", "category_id", q.CategoryID, "error", setErr)
        }
        return items, nil
    }
    return entry.Items, nil
}

func (h *GetProductCatalogHandler) shouldRefresh(entry *cachedProductCatalog, ttl time.Duration) bool {
    age := time.Since(entry.CachedAt)
    ratio := float64(age) / float64(ttl)
    return rand.Float64() < math.Pow(ratio, 3)
}

При ratio = 0.5 (половина TTL) вероятность refresh = 12.5%. При ratio = 0.9 — 73%. Нагрузка обновления размазывается по времени; нет момента «все одновременно промахнулись».

Hot keys — refresh-ahead

R-CACHE-STAMP-3: для заранее известных hot-ключей (топ заказов Сбера, главная страница продуктового каталога) — фоновая горутина перезаливает кеш до истечения TTL.

// adapters/background/product_cache_refresher.go
type ProductCacheRefresher struct {
    repo  ProductRepository
    cache ProductCache
    cfg   CacheConfig
}

func (r *ProductCacheRefresher) Run(ctx context.Context) {
    interval := r.cfg.ProductCatalogTTL * 80 / 100
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    if err := r.refresh(ctx); err != nil {
        slog.WarnContext(ctx, "initial product cache refresh failed", "error", err)
    }

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            if err := r.refresh(ctx); err != nil {
                slog.WarnContext(ctx, "product cache refresh failed", "error", err)
            }
        }
    }
}

func (r *ProductCacheRefresher) refresh(ctx context.Context) error {
    top, err := r.repo.GetTopProducts(ctx, 100)
    if err != nil {
        return fmt.Errorf("load top products: %w", err)
    }
    return r.cache.SetTopProducts(ctx, top, r.cfg.ProductCatalogTTL)
}

Запуск в main через горутину с отменяемым контекстом:

go refresher.Run(ctx)

Кеш всегда заполнен до того, как клиент попросит данные. Stampede невозможен по дизайну. Подробнее — Паттерны.

Что запрещено

АнтипаттернПравилоЧто взамен
Игнорировать stampede на hot endpoints (>100 RPS)R-CACHE-STAMP-X1singleflight + SetNX-lock или refresh-ahead
sync.Mutex / sync.Map для защиты Redis-кешаR-CACHE-STAMP-X2Redis SetNX с Lua-release (атомарно, кросс-под)
SetNX без TTL на lock-ключеR-CACHE-STAMP-2Всегда передавай lockTTL — иначе deadlock при краше
Снятие lock без проверки UUID (DEL lockKey)R-CACHE-STAMP-2Lua-скрипт с проверкой ARGV[1]
Refresh-ahead для миллионов разных ключейR-CACHE-STAMP-3singleflight или SetNX-lock по ключу
Ждать ответа от lock-holder бесконечноR-CACHE-STAMP-2select с ctx.Done() и таймаутом

Куда дальше

  • Паттерны — refresh-ahead, write-through, cache-aside в Go-идиомах.
  • TTL — короткий TTL увеличивает частоту miss → рост вероятности stampede.
  • Observability — мониторинг hit rate и поиск проблемных ключей.
  • Конфигурация — CacheConfig и envconfig для per-cache TTL.
  • Invalidation — evict на write как альтернатива частому expiry.
  • Ключи — namespace-префиксы и составные ключи.
  • Где кешируем — какие данные кешировать и когда.