История одного 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 имеет характерные признаки:
- Периодичность — точно совпадает с TTL кэша (10 мин / час / 24 часа).
- CPU PostgreSQL → 100% при отсутствии всплеска нагрузки на ваш бэкенд.
- Все ноды одновременно делают один и тот же запрос (видно в
pg_stat_statements— мгновенный спайкcallsдля одного query). - Длительность пика — 30-180 секунд, потом резко возвращается в норму.
- 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 при stampede | 100% |
| p99 latency при stampede | 4200 ms |
| Длительность пика | 90-120 секунд |
| Влияние на другие endpoint-ы | Все шли в timeout |
После Caffeine + Redis + SWR
| Stampede frequency | 0 |
| PostgreSQL CPU baseline | 35% |
| PostgreSQL CPU peak | 45% |
| p99 latency baseline | 28 ms |
| p99 latency peak | 65 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, ищет одновременные истечения, моделирует пик и отдаёт рекомендации. Отчёт ваш в любом случае.