Новаком
REDIS

Cache Stampede: почему Redis ломает PostgreSQL в час пик и как это пофиксить

Cache stampede — когда популярный ключ истекает и 100 параллельных запросов одновременно идут в БД. Анализ проблемы, single-flight, probabilistic early expiration, Caffeine + Redis. С кодом и метриками.

Н
Новаком
2026-05-23 · 11 минут чтения

История одного 18:00

Раз в день, ровно в 18:00, в e-commerce приложении клиента происходила одна и та же история:

  • CPU PostgreSQL мгновенно прыгал до 100%.
  • p99 latency пилило с 80 ms до 4 секунд.
  • Через 90-120 секунд всё успокаивалось.
  • На следующий день в 18:00 повторялось.

Объяснение пришлось искать долго. Не пик трафика, не DDoS, не плановый job. Бизнес-метрики ничего не показывали — продажи шли как обычно.

Виновник оказался прозаичен: кэш каталога в Redis истекал по TTL ровно в 18:00. И 50 параллельных нод приложения одновременно видели cache miss, одновременно шли в PostgreSQL восстанавливать кэш, и одновременно делали SELECT * FROM products WHERE active = true ORDER BY... на 200k строк.

Это cache stampede (он же thundering herd). Классическая проблема распределённых систем, о которой все слышали, но в коде у себя не замечают.

В этом гайде:

  • как диагностировать stampede (специфические симптомы);
  • три стратегии mitigation: single-flight, probabilistic early expiration, stale-while-revalidate;
  • реализация на Spring Boot с Caffeine + Redis (мульти-уровень);
  • грабли, на которые мы наступали в банковских проектах.

Симптомы — как отличить cache stampede от других проблем

Cache stampede имеет характерные признаки:

  1. Периодичность — точно совпадает с TTL кэша (10 мин / час / 24 часа).
  2. CPU PostgreSQL → 100% при отсутствии всплеска нагрузки на ваш бэкенд.
  3. Все ноды одновременно делают один и тот же запрос (видно в pg_stat_statements — мгновенный спайк calls для одного query).
  4. Длительность пика — 30-180 секунд, потом резко возвращается в норму.
  5. Latency прыгает у всех endpoint-ов, не только у тех, что трогают кэшированные данные — потому что общий пул соединений к Postgres забит.

Диагностический трюк: посмотрите в pg_stat_statements запросы с самым высоким соотношением max_time / mean_time. Если у одного запроса средний mean_time = 50ms, а max_time = 4000ms — это запрос-жертва, который страдает от concurrent contention. Дальше ищите его кэш.


Базовая (наивная) реализация

@Service
class CatalogService(
    private val productRepo: ProductRepository,
    private val redis: RedisTemplate<String, Catalog>,
) {
    fun getCatalog(): Catalog {
        val cached = redis.opsForValue().get("catalog")
        if (cached != null) return cached

        val fresh = productRepo.loadActiveCatalog()  // тяжёлый запрос
        redis.opsForValue().set("catalog", fresh, Duration.ofMinutes(10))
        return fresh
    }
}

50 нод × этот метод параллельно при cache miss = 50 × productRepo.loadActiveCatalog(). Это и есть stampede.


Стратегия 1: Single-Flight через распределённый лок

Идея: только один инстанс перезаписывает кэш. Остальные ждут результат.

@Service
class CatalogService(
    private val productRepo: ProductRepository,
    private val redis: RedisTemplate<String, Catalog>,
) {
    fun getCatalog(): Catalog {
        val cached = redis.opsForValue().get("catalog")
        if (cached != null) return cached

        val lockKey = "lock:catalog"
        val lockValue = UUID.randomUUID().toString()
        val acquired = redis.opsForValue().setIfAbsent(
            lockKey, lockValue, Duration.ofSeconds(30)
        ) ?: false

        if (acquired) {
            try {
                // мы единственный — перезаписываем
                val fresh = productRepo.loadActiveCatalog()
                redis.opsForValue().set("catalog", fresh, Duration.ofMinutes(10))
                return fresh
            } finally {
                releaseLock(lockKey, lockValue)
            }
        } else {
            // кто-то уже грузит — подождать и попробовать кэш ещё раз
            return waitForCache("catalog", maxWait = Duration.ofSeconds(5))
                ?: productRepo.loadActiveCatalog()  // fallback если ожидание не сработало
        }
    }

    private fun releaseLock(key: String, expectedValue: String) {
        // Lua-скрипт для атомарного check-and-delete
        val script = """
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('DEL', KEYS[1])
            else
                return 0
            end
        """.trimIndent()
        redis.execute(
            DefaultRedisScript(script, Long::class.java),
            listOf(key), expectedValue,
        )
    }

    private fun waitForCache(key: String, maxWait: Duration): Catalog? {
        val deadline = System.currentTimeMillis() + maxWait.toMillis()
        while (System.currentTimeMillis() < deadline) {
            Thread.sleep(100)  // poll interval
            val cached = redis.opsForValue().get(key)
            if (cached != null) return cached
        }
        return null
    }
}

Подводные камни single-flight:

  • Lock leakage: если процесс с локом упадёт без releaseLock — лок будет висеть до TTL (30s). Это ОК, но дольше TTL не должно быть.
  • Polling wait: ноды без лока поллят кэш — нагрузка на Redis × N. Можно заменить на pub/sub-сигнал (Redis publishes на готовность ключа).
  • Lock TTL должен быть > времени обновления кэша. Если query тяжёлый и идёт 60 секунд, а TTL лока 30 — параллельно начнут запускаться другие. Ставьте TTL = 2-3× от ожидаемого времени.

Стратегия 2: Probabilistic Early Expiration (XFetch)

Идея: вместо того, чтобы все клиенты видели cache miss в один момент TTL — рандомизированно «прогревать» кэш заранее, когда до истечения осталось мало времени.

Формула (упрощённый XFetch):

shouldRefresh = (now + β * ttl_remaining * log(random())) >= expiry_time

Где β — фактор агрессивности (обычно 1.0). Чем ближе ttl_remaining к нулю, тем больше вероятность «прогреть» досрочно.

data class CachedValue<T>(
    val value: T,
    val expiresAt: Instant,
    val ttl: Duration,
)

fun <T> getWithProbabilisticRefresh(
    key: String,
    loader: () -> T,
    ttl: Duration,
    beta: Double = 1.0,
): T {
    val cached = redis.get<CachedValue<T>>(key)
    val now = Instant.now()

    if (cached == null || now.isAfter(cached.expiresAt)) {
        // полный miss — стандартная загрузка
        val fresh = loader()
        redis.set(key, CachedValue(fresh, now.plus(ttl), ttl), ttl)
        return fresh
    }

    // вероятностно решаем, обновить досрочно или нет
    val remainingMs = Duration.between(now, cached.expiresAt).toMillis()
    val xfetchValue = beta * remainingMs * -ln(Random.nextDouble())

    if (xfetchValue >= cached.ttl.toMillis()) {
        // обновляем досрочно
        val fresh = loader()
        redis.set(key, CachedValue(fresh, now.plus(ttl), ttl), ttl)
        return fresh
    }

    return cached.value
}

Преимущества:

  • Не нужен лок.
  • Кэш почти всегда «горячий» — никто не успевает увидеть пустоту.
  • Нагрузка на БД размазана по времени.

Недостатки:

  • Чуть больше вызовов loader() суммарно (на 5-15% обычно).
  • Сложнее интуитивно понять, что происходит.

XFetch — это алгоритм Vavlas & Vattani 2015 (статья «Optimal Probabilistic Cache Stampede Prevention»). Используется в Facebook Memcached и Redis Sentinel.


Стратегия 3: Stale-While-Revalidate (SWR)

Идея HTTP-кэширования, но применённая внутри приложения. Кэш помечается «свежим» / «устаревшим» / «истёкшим»:

  • Свежий — возвращаем сразу.
  • Устаревший (stale) — возвращаем сразу, но асинхронно запускаем refresh.
  • Истёкший — блокирующий refresh.
val cache: AsyncLoadingCache<String, Catalog> = Caffeine.newBuilder()
    .expireAfterWrite(Duration.ofMinutes(10))      // полное истечение
    .refreshAfterWrite(Duration.ofMinutes(8))      // refresh за 2 мин до истечения
    .buildAsync { key, executor ->
        CompletableFuture.supplyAsync(
            { productRepo.loadActiveCatalog() },
            executor,
        )
    }

fun getCatalog(): CompletableFuture<Catalog> = cache.get("catalog")

refreshAfterWrite < expireAfterWrite — это даёт SWR. Когда возраст значения превышает refreshAfterWrite, следующий get возвращает старое значение и в фоне запускает loader. Stampede не происходит, потому что Caffeine изнутри single-flight-ит этот refresh.

Это самый простой способ. Если у вас single-instance кэш (Caffeine in-memory) — берите его сразу.


Производственный паттерн: Caffeine + Redis (мульти-уровень)

В реальности у вас несколько инстансов сервиса — каждому нужен быстрый локальный кэш (Caffeine), но между ними нужна синхронизация (Redis). Это «multi-tier cache» или «cache-aside pattern».

@Service
class CatalogCacheService(
    private val productRepo: ProductRepository,
    private val redis: RedisTemplate<String, ByteArray>,
    private val mapper: ObjectMapper,
) {
    private val localCache: AsyncLoadingCache<String, Catalog> = Caffeine.newBuilder()
        .expireAfterWrite(Duration.ofMinutes(2))   // локальный TTL короче
        .refreshAfterWrite(Duration.ofMinutes(1))  // SWR refresh
        .buildAsync { key, executor ->
            CompletableFuture.supplyAsync({ loadFromRedisOrDb(key) }, executor)
        }

    fun getCatalog(): CompletableFuture<Catalog> =
        localCache.get("catalog")

    private fun loadFromRedisOrDb(key: String): Catalog {
        // L2: Redis
        val cached = redis.opsForValue().get("cache:$key")
        if (cached != null) {
            return mapper.readValue(cached, Catalog::class.java)
        }

        // L3: DB (с probabilistic refresh для дальнейшей защиты)
        val fresh = productRepo.loadActiveCatalog()
        redis.opsForValue().set(
            "cache:$key",
            mapper.writeValueAsBytes(fresh),
            Duration.ofMinutes(10),
        )
        return fresh
    }
}

Архитектура:

┌─────────────┐
│  Request    │
│  on node A  │
└──────┬──────┘
       │ get("catalog")
       ▼
┌──────────────────┐
│ L1: Caffeine     │  ≤ 1 ms, в JVM
│ (per-instance)   │
└──────┬───────────┘
       │ miss
       ▼
┌──────────────────┐
│ L2: Redis        │  ~ 5 ms, между нодами
│ (shared)         │
└──────┬───────────┘
       │ miss
       ▼
┌──────────────────┐
│ L3: PostgreSQL   │  ~ 200 ms
│                  │
└──────────────────┘

Преимущества:

  • L1 поглощает 99% трафика — sub-ms latency.
  • L2 защищает от instance-local stampede на старте.
  • SWR на L1 защищает от истечения «в один момент».

Реальные метрики из e-commerce кейса

Catalog endpoint, e-commerce платформа, 200 RPS sustained, peak 800 RPS, 50 нод.

До mitigation

Stampede frequencyКаждые 10 мин (при TTL = 10 мин)
PostgreSQL CPU при stampede100%
p99 latency при stampede4200 ms
Длительность пика90-120 секунд
Влияние на другие endpoint-ыВсе шли в timeout

После Caffeine + Redis + SWR

Stampede frequency0
PostgreSQL CPU baseline35%
PostgreSQL CPU peak45%
p99 latency baseline28 ms
p99 latency peak65 ms
Влияние на другие endpoint-ыНет

DBA счастлив, поддержка перестала просыпаться по ночам с pager-ом «база на 100%».


Микро-CTA

Если у вас есть Redis-кэширование в Spring Boot и периодические непонятные просадки в БД — это типичный паттерн cache stampede. Мы это находим за час профилирования. Backend Health-Check за 3 дня включает анализ кэш-стратегии и поиск таких симптомов. Senior отдаст отчёт с конкретными точками риска.


Грабли, на которые мы наступаем

Грабля 1: TTL одинаковый для всех ключей

Если у вас 1000 ключей с TTL=10min, и они все были созданы в одно деплоймент-время — все истекут одновременно. Mass stampede в моменте.

Фикс: jitter в TTL. TTL = base + random(0, base × 0.1). 10 минут ± 1 минута распределяет истечения.

fun setWithJitter(key: String, value: Any, baseTtl: Duration) {
    val jitterMs = (baseTtl.toMillis() * 0.1 * Random.nextDouble()).toLong()
    val ttl = baseTtl.plusMillis(jitterMs)
    redis.set(key, value, ttl)
}

Грабля 2: Pessimistic lock на read-path

Видел: «давайте просто на каждый cache miss брать distributed lock». Под нагрузкой это превращает Redis в bottleneck (lock contention) и делает кэш медленнее, чем БД.

Фикс: lock только для write (когда обновляем), не для read.

Грабля 3: @Cacheable со Spring без понимания внутренностей

Spring @Cacheable использует абстракцию Cache (через CacheManager). Из коробки она не делает single-flight. 50 параллельных @Cacheable-вызовов с одинаковым cache miss → 50 параллельных вызовов method body.

Фикс: либо @Cacheable(sync = true) (использует synchronized — но только в пределах одной JVM), либо вообще не полагаться на @Cacheable, а строить кэш вручную как в примерах выше.

Грабля 4: cache invalidation через DEL бьёт массово

Когда вы делаете админский «invalidate cache for all products» — DEL на 1000 ключей сразу = mass cache miss = mass stampede.

Фикс: вместо DEL ставьте короткий TTL (например, 1 секунду). Тогда обновление будет постепенным.

Грабля 5: Caffeine без weakKeys() или softValues() течёт

Если ключи или значения не очищаются GC и не имеют лимита размера — Caffeine растёт бесконечно. Через сутки JVM падает по OOM.

Фикс: обязательно .maximumSize(N) или .maximumWeight(W).weigher(...) для всех кэшей.

Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(...)
    ...

Грабля 6: Redis cluster + multi-key операции

Если у вас Redis Cluster и используете Lua-скрипты с несколькими ключами — нужно, чтобы ключи попадали в одну партицию (через hash tag в {}).

// плохо в кластере — ключи могут быть на разных нодах
redis.eval("local a = redis.call('GET', KEYS[1]); ...", listOf("cache:a", "cache:b"))

// правильно через hash tag
redis.eval(script, listOf("cache:{group1}:a", "cache:{group1}:b"))

Финальный чек-лист

  • Stampede диагностирован — есть метрики, доказывающие именно эту причину (а не general high load)
  • TTL ключей с jitter (±10%)
  • L1 локальный кэш (Caffeine) с refreshAfterWrite < expireAfterWrite
  • L1 кэш с maximumSize (нет утечки)
  • L2 Redis с probabilistic refresh ИЛИ single-flight через атомарный лок
  • Метрика «cache hit rate» в Grafana (цель: > 95% для L1)
  • Алерт на сценарий «массовое истечение» — DEL на > 10 ключей одновременно
  • Lua-скрипты в Redis Cluster используют hash tags
  • Integration test, имитирующий stampede (5+ параллельных потоков на cache miss)

Cache stampede — одна из тех проблем, которая не видна на ровном месте, но становится центральной болью в час пик. Если хочется внешнего ревью кэш-стратегии — это входит в наш Backend Health-Check. Senior смотрит на ваш Redis usage pattern, ищет одновременные истечения, моделирует пик и отдаёт рекомендации. Отчёт ваш в любом случае.

РАЗРАБОТКА

Нужна похожая задача?

Обсудим вашу задачу и предложим решение за 30 минут.

Обсудить проект