
Важно: кэш даёт пользу только при контролируемой инвалидизации и защите от «шторма запросов». Без этого он превращается в источник аварий ровно в момент, когда TTL у популярных ключей истекает.
Для продуктовых API в 90% случаев достаточно cache-aside + продуманная стратегия инвалидизации и защиты от шторма.
Шторм запросов (cache stampede) — ситуация, когда TTL у популярного ключа истёк, кэша нет, и десятки/сотни инстансов одновременно идут в БД за одним и тем же значением. Итог:
Причины:
Идея простая: одновременно только один поток/инстанс генерирует значение для ключа, остальные ждут результат.
Плюсы: резко снижает дублирующую работу. Минусы: нужно следить за таймаутами и дедлоками, не держать лок долго.
Плюсы: нет пиков на истечении TTL, пользователи не видят провалов производительности. Минусы: нужно мириться с кратковременной «несвежестью» данных там, где это допустимо бизнесом.
Добавляем к TTL случайный сдвиг ±20–30%. Разные ключи и разные инстансы перестают истекать синхронно. Это дешёвая страховка даже без прочих паттернов.
Кэшируем «пустой» ответ (например, «товар не найден») на короткое время. Это защищает от частых повторов одного и того же бессмысленного запроса, которые иначе бьют по БД.
Ключи привязываем к версии данных: product:v42
. Смена версии (v43) инвалидирует все старые ключи за один приём, без сканирования Redis. Полезно для широких обновлений (прайс, флаг, глобальная настройка).Стратегия: читаем LRU → Redis → источник (БД). Пишем в обратном порядке. Single-flight реализуем на обоих уровнях: сначала локально, при промахе используем распределённую блокировку.
Алерты: падение hit ratio на X% за 5–10 минут, рост p99, скачок промахов на «горячих» ключах, ошибки SET NX/скриптов Lua.
Пример прикидки: 10k RPS чтений каталога, кэш даёт 80% попаданий, Redis стоит $400/мес, экономия 2 vCPU на Postgres и 1 нода приложений ≈ $600/мес. Плюс прирост конверсии на 1% — часто покрывает всё многократно.
Ниже — два фрагмента: single-flight на Go и распределённая блокировка + мягкий TTL на Python с Redis.
package cacheexample
import (
"context"
"encoding/json"
"math/rand"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
type Product struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
type Store interface {
LoadProduct(ctx context.Context, id string) (Product, error)
}
var (
rdb = redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
g singleflight.Group
)
func randomTTL(base time.Duration, jitterPct int) time.Duration {
if jitterPct <= 0 {
return base
}
// jitter в диапазоне [-jitterPct, +jitterPct]
delta := float64(jitterPct) / 100.0
f := 1 + (rand.Float64()*2*delta - delta)
return time.Duration(float64(base) * f)
}
func GetProduct(ctx context.Context, s Store, id string) (Product, error) {
key := "product:" + id
// 1) Локальный single-flight и двойная проверка кэша
if b, err := rdb.Get(ctx, key).Bytes(); err == nil {
var p Product
if e := json.Unmarshal(b, &p); e == nil {
return p, nil
}
}
v, err, _ := g.Do(key, func() (any, error) {
// Double-check: пока мы шли к генерации, другой поток мог уже прогреть кэш
if b, err := rdb.Get(ctx, key).Bytes(); err == nil {
var p Product
if e := json.Unmarshal(b, &p); e == nil {
return p, nil
}
}
// 2) Грузим из источника
p, err := s.LoadProduct(ctx, id)
if err != nil {
return nil, err
}
// 3) Кладём в Redis с джиттером TTL
ttl := randomTTL(5*time.Minute, 25)
if b, e := json.Marshal(p); e == nil {
_ = rdb.Set(ctx, key, b, ttl).Err()
}
return p, nil
})
if err != nil {
return Product{}, err
}
return v.(Product), nil
}
Комментарии:
import json
import time
import uuid
from typing import Callable, Tuple, Any
import redis
r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)
# Lua-скрипт безопасного освобождения блокировки (освобождаем только если токен совпадает)
RELEASE_SCRIPT = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""")
def acquire_lock(key: str, ttl_ms: int) -> Tuple[bool, str]:
token = str(uuid.uuid4())
# SET key value NX PX ttl
ok = r.set(key, token, nx=True, px=ttl_ms)
return (ok is True), token
def release_lock(key: str, token: str) -> None:
try:
RELEASE_SCRIPT(keys=[key], args=[token])
except redis.RedisError:
pass
def with_jitter(seconds: int, jitter_pct: int = 25) -> int:
import random
delta = seconds * jitter_pct / 100.0
return int((seconds + random.uniform(-delta, delta)))
def get_with_soft_ttl(cache_key: str,
regen: Callable[[], Any],
soft_ttl_s: int = 60,
hard_ttl_s: int = 300) -> Any:
"""
Схема хранения:
- Значение: JSON: {"data": ..., "stale_at": <unix_ts>}
- Ключ в Redis живёт hard_ttl_s (жёсткий TTL)
- Если сейчас < stale_at -> отдаём сразу
- Если сейчас >= stale_at -> пытаемся захватить блокировку и обновить в фоне;
при неудаче отдаём «устаревшее», пока другой инстанс регенерирует.
"""
raw = r.get(cache_key)
now = int(time.time())
if raw:
try:
payload = json.loads(raw)
data = payload.get('data')
stale_at = int(payload.get('stale_at', 0))
except Exception:
data, stale_at = None, 0
else:
data, stale_at = None, 0
if data is not None and now < stale_at:
# Свежо — быстрый ответ
return data
# Здесь либо промах, либо устарело по soft TTL
lock_key = f"lock:{cache_key}"
got, token = acquire_lock(lock_key, ttl_ms=5000) # 5с на регенерацию
if got:
try:
# Двойная проверка: вдруг кто-то уже успел обновить
raw2 = r.get(cache_key)
if raw2:
try:
p2 = json.loads(raw2)
if int(p2.get('stale_at', 0)) > now:
return p2.get('data')
except Exception:
pass
# Генерация
new_data = regen()
payload = {
'data': new_data,
'stale_at': now + with_jitter(soft_ttl_s, 25)
}
r.set(cache_key, json.dumps(payload), ex=with_jitter(hard_ttl_s, 25))
return new_data
finally:
release_lock(lock_key, token)
else:
# Блок не наш — кто-то другой регенерирует
if data is not None:
# Отдаём устаревшее (stale-while-revalidate)
return data
# Совсем промах — придётся подождать немного и попробовать ещё раз/упасть на источник
time.sleep(0.05)
raw3 = r.get(cache_key)
if raw3:
try:
p3 = json.loads(raw3)
return p3.get('data')
except Exception:
pass
# Фолбэк: идём в источник напрямую (риск дублирования, но редкий кейс)
return regen()
Комментарии:
def get_user_or_none(user_id: str):
key = f"user:{user_id}"
raw = r.get(key)
if raw is not None:
if raw == "__none__":
return None
return json.loads(raw)
# запрос в БД
user = db_get_user(user_id)
if user is None:
r.set(key, "__none__", ex=with_jitter(30, 20)) # 30с на промахи
return None
r.set(key, json.dumps(user), ex=with_jitter(300, 20))
return user
Реальные результаты в проектах: снижение нагрузки на БД в 3–8 раз на «горячих» ручках, p95 падает на 40–60%, инциденты из‑за всплесков практически исчезают.
Кэш — это не только про «быстрее». Это про предсказуемость, стабильность и экономию. Добавьте три элемента — single-flight, мягкий TTL со stale-while-revalidate и джиттер — и ваш кэш перестанет быть миной замедленного действия. Дальше — метрики, версии ключей и работа с «горячими» данными. Небольшие инженерные усилия дают бизнесу ощутимый и измеримый эффект.