Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Кэш в приложении и защита от шторма запросов: быстрее API и меньше нагрузки на базу

Разработка и технологии7 февраля 2026 г.
Кэш помогает ускорить ответы и разгрузить базу, но без защиты от шторма запросов он способен положить инфраструктуру в момент истечения TTL. Разбираем практичные паттерны — от single-flight до мягкого TTL и обновления в фоне — с готовыми примерами на Go и Python, метриками и чек‑листом внедрения.
Кэш в приложении и защита от шторма запросов: быстрее API и меньше нагрузки на базу

Оглавление

  • Зачем бизнесу кэш в приложении
  • Базовые паттерны кэширования: когда какой использовать
  • Что такое «шторм запросов» и почему он опасен
  • Паттерны защиты от шторма
    • Single-flight: один запрос генерирует данные, остальные ждут
    • Мягкий TTL и stale-while-revalidate
    • Случайный TTL (jitter)
    • Негативный кэш
    • Версии ключей (generation)
    • Горячие ключи: локальный LRU, шардинг, лимиты
  • Архитектура: локальный + распределённый кэш (two-tier)
  • Наблюдаемость: какие метрики и алерты нужны
  • Экономика: как посчитать выгоду для бизнеса
  • Чек‑лист внедрения
  • Примеры кода
    • Go: single-flight + джиттер TTL и двойная проверка
    • Python: Redis, распределённая блокировка и мягкий TTL
    • Негативный кэш: короткий TTL на «пустые» ответы
  • Частые ошибки и как их избежать
  • Мини‑практика: как ввести кэш для каталога товаров за 1–2 спринта
  • Заключение

Зачем бизнесу кэш в приложении

  • Быстрее пользователи. Типичный выигрыш 30–70% по p95 за счёт уменьшения походов в базу и внешние сервисы.
  • Дешевле инфраструктура. Меньше RPS в БД и сторонние API — экономия на более слабых экземплярах и меньшем масштабе.
  • Стабильнее SLA. Резкие всплески трафика прожимаются кэшем, а не добивают «источник истины».

Важно: кэш даёт пользу только при контролируемой инвалидизации и защите от «шторма запросов». Без этого он превращается в источник аварий ровно в момент, когда TTL у популярных ключей истекает.

Базовые паттерны кэширования: когда какой использовать

  • Cache-aside (ленивый кэш): приложение сначала читает из кэша, при промахе — из БД и кладёт в кэш. Простой и прозрачный, подходит для большинства чтений.
  • Write-through: запись идёт и в БД, и сразу в кэш. Полезно, если «горячие» данные часто читаются сразу после записи.
  • Write-back (write-behind): запись в кэш с отложенной записью в БД. Даёт высокую скорость, но сложнее обеспечить надёжность и порядок.

Для продуктовых API в 90% случаев достаточно cache-aside + продуманная стратегия инвалидизации и защиты от шторма.

Что такое «шторм запросов» и почему он опасен

Шторм запросов (cache stampede) — ситуация, когда TTL у популярного ключа истёк, кэша нет, и десятки/сотни инстансов одновременно идут в БД за одним и тем же значением. Итог:

  • Многократная нагрузка на базу вместо одной регенерации.
  • Всплеск латентности p95/p99.
  • Риск лавинообразного отказа: пока БД отвечает медленно, все ждут, воркеры забиваются, очереди растут.

Причины:

  • Единый жёсткий TTL без джиттера на «горячих» ключах.
  • Нет координации между инстансами (каждый решает «пойду-ка я в БД»).
  • Долгая регенерация значения (сложные запросы, агрегации, внешние API).

Паттерны защиты от шторма

Single-flight: один запрос генерирует данные, остальные ждут

Идея простая: одновременно только один поток/инстанс генерирует значение для ключа, остальные ждут результат.

  • Внутри процесса: мьютекс/группа для ключа.
  • Между инстансами: распределённая блокировка в Redis (SET key value NX PX ...), аккуратный релиз по токену.

Плюсы: резко снижает дублирующую работу. Минусы: нужно следить за таймаутами и дедлоками, не держать лок долго.

Мягкий TTL и stale-while-revalidate

  • Мягкий TTL (soft TTL): храним «время устаревания» и «жёсткий TTL». Если значение устарело по soft TTL, мы можем отдать его пользователю как «слегка несвежее», но параллельно запустить обновление в фоне. Жёсткий TTL ограничивает абсолютный срок жизни ключа.
  • Stale-while-revalidate: отдаём устаревшее значение быстро, а актуализацию делаем асинхронно, не задерживая пользователя.

Плюсы: нет пиков на истечении TTL, пользователи не видят провалов производительности. Минусы: нужно мириться с кратковременной «несвежестью» данных там, где это допустимо бизнесом.

Случайный TTL (jitter)

Добавляем к TTL случайный сдвиг ±20–30%. Разные ключи и разные инстансы перестают истекать синхронно. Это дешёвая страховка даже без прочих паттернов.

Негативный кэш

Кэшируем «пустой» ответ (например, «товар не найден») на короткое время. Это защищает от частых повторов одного и того же бессмысленного запроса, которые иначе бьют по БД.

Версии ключей (generation)

Ключи привязываем к версии данных: product:v42

. Смена версии (v43) инвалидирует все старые ключи за один приём, без сканирования Redis. Полезно для широких обновлений (прайс, флаг, глобальная настройка).

Горячие ключи: локальный LRU, шардинг, лимиты

  • Локальный кэш в памяти процесса (LRU) перед Redis снижает сетевые round-trip и давление на Redis для сверхпопулярных ключей.
  • Шардинг «горячего» ключа на N под-ключей (с объединением результата) иногда дешевле, чем одна огромная регенерация.
  • Ограничение параллельной регенерации: не больше K обновлений в единицу времени для одной сущности.

Архитектура: локальный + распределённый кэш (two-tier)

  • Tier 1: локальный LRU (например, 100–500 МБ на инстанс) — быстрые попадания, минимум сетевых походов.
  • Tier 2: Redis — общий слой между инстансами, TTL, координация, блокировки.

Стратегия: читаем LRU → Redis → источник (БД). Пишем в обратном порядке. Single-flight реализуем на обоих уровнях: сначала локально, при промахе используем распределённую блокировку.

Наблюдаемость: какие метрики и алерты нужны

  • cache_hit_ratio (LRU, Redis) — отдельно по слоям и по операциям.
  • redis_ops_by_type: GET/SET/DEL, чтобы видеть стоимость.
  • prevented_stampede_total — сколько регенераций «схлопнуто» single-flight’ом.
  • latency_p95/p99 для основных ручек — сравнивать с и без кэша.
  • ключи-лидеры по QPS и объёму памяти (top-K) — чтобы увидеть «горячие точки».
  • ошибки блокировок/таймаутов — раннее предупреждение о деградации.

Алерты: падение hit ratio на X% за 5–10 минут, рост p99, скачок промахов на «горячих» ключах, ошибки SET NX/скриптов Lua.

Экономика: как посчитать выгоду для бизнеса

  • База сейчас обрабатывает N RPS по цене C за час. Кэш снимает k% чтений → экономия C·k%.
  • Ускорение конверсии. Если p95 ответа критичен для оплаты/поиска, уменьшение с 800 до 300 мс может дать +1–3% к конверсии.
  • Меньше аварий и дежурств — измеряйте в человеко-часах инцидентов до/после.

Пример прикидки: 10k RPS чтений каталога, кэш даёт 80% попаданий, Redis стоит $400/мес, экономия 2 vCPU на Postgres и 1 нода приложений ≈ $600/мес. Плюс прирост конверсии на 1% — часто покрывает всё многократно.

Чек‑лист внедрения

  • Определить горячие ручки и допустимую «несвежесть» (секунды/минуты).
  • Выбрать стратегию: cache-aside + soft TTL + single-flight.
  • Настроить локальный LRU и Redis, включить джиттер TTL.
  • Добавить негативный кэш там, где много «пустых» ответов.
  • Ввести версии ключей для массовых изменений.
  • Прописать метрики, дашборды и алерты.
  • Нагрузочный тест с симуляцией истечения TTL.
  • План инцидента: как быстро отключать кэш/блокировки и откатывать конфиг.

Примеры кода

Ниже — два фрагмента: single-flight на Go и распределённая блокировка + мягкий TTL на Python с Redis.

Go: single-flight + джиттер TTL и двойная проверка

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
}

Комментарии:

  • singleflight гарантирует, что по одному ключу только одна горRoutine действительно ходит в источник.
  • Джиттер по TTL рассинхронизирует истечение ключей.
  • Для межпроцессной координации добавьте блокировку в Redis (см. Python ниже) поверх или вместо singleflight.

Python: Redis, распределённая блокировка и мягкий TTL

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()

Комментарии:

  • Мы храним и данные, и момент устаревания (soft TTL) внутри значения. Сам ключ живёт дольше (hard TTL) — это страховка на случай забытых блокировок/сбоев.
  • Если значение устарело, но мы не смогли взять блокировку — отдаём устаревшее и не тормозим пользователя, пока другой инстанс обновляет.
  • Безопасное освобождение блокировки через Lua исключает удаление чужого лока.

Негативный кэш: короткий TTL на «пустые» ответы

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

Частые ошибки и как их избежать

  • Синхронное истечение TTL у тысяч ключей — добавляйте джиттер.
  • Долгая регенерация под блокировкой — ограничивайте таймауты и объём работы, дробите задачи.
  • «Вечные» блокировки из‑за падения процесса — у блокировок должен быть TTL, релиз — по токену.
  • Переключение на новый формат значения без версий ключей — используйте префиксы/версии.
  • Отсутствие метрик — без hit ratio и p99 вы не заметите деградации до инцидента.

Мини‑практика: как ввести кэш для каталога товаров за 1–2 спринта

  • Выберите 3–5 самых горячих запросов (топ по RPS и суммарному времени в базе).
  • Согласуйте с продуктом допустимую «несвежесть»: 30–120 секунд.
  • Реализуйте cache-aside + single-flight + мягкий TTL, включите негативный кэш на «нет в наличии».
  • Настройте дашборды: hit ratio, латентность p95/p99, топ-ключи по размеру и RPS.
  • Прокатите нагрузочные тесты с одновременным истечением TTL и убедитесь, что пиков нет.

Реальные результаты в проектах: снижение нагрузки на БД в 3–8 раз на «горячих» ручках, p95 падает на 40–60%, инциденты из‑за всплесков практически исчезают.

Заключение

Кэш — это не только про «быстрее». Это про предсказуемость, стабильность и экономию. Добавьте три элемента — single-flight, мягкий TTL со stale-while-revalidate и джиттер — и ваш кэш перестанет быть миной замедленного действия. Дальше — метрики, версии ключей и работа с «горячими» данными. Небольшие инженерные усилия дают бизнесу ощутимый и измеримый эффект.


Redisпроизводительностькэш