Опирается на правила:
R-CACHE-STAMP-1…R-CACHE-STAMP-3иR-CACHE-STAMP-X1…R-CACHE-STAMP-X2из Caching Style Guide → раздел 7. Cache stampede.
Важно знать
- Cache stampede — параллельные запросы на истёкший ключ: каждый видит cache miss, все уходят в БД одновременно, DB получает N одинаковых тяжёлых запросов.
singleflight(golang.org/x/sync/singleflight) коллапсирует дублирующиеся in-flight запросы внутри одного процесса — идиоматичная замена Javasync = 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-X1 | singleflight + SetNX-lock или refresh-ahead |
sync.Mutex / sync.Map для защиты Redis-кеша | R-CACHE-STAMP-X2 | Redis SetNX с Lua-release (атомарно, кросс-под) |
SetNX без TTL на lock-ключе | R-CACHE-STAMP-2 | Всегда передавай lockTTL — иначе deadlock при краше |
Снятие lock без проверки UUID (DEL lockKey) | R-CACHE-STAMP-2 | Lua-скрипт с проверкой ARGV[1] |
| Refresh-ahead для миллионов разных ключей | R-CACHE-STAMP-3 | singleflight или SetNX-lock по ключу |
| Ждать ответа от lock-holder бесконечно | R-CACHE-STAMP-2 | select с 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-префиксы и составные ключи.
- Где кешируем — какие данные кешировать и когда.