Опирается на правила:
R-CACHE-STAMP-1…R-CACHE-STAMP-3иR-CACHE-STAMP-X1…R-CACHE-STAMP-X2из Caching Style Guide → раздел 7. Cache stampede.
Важно знать
- Cache stampede = multiple parallel requests на холодную ключ → все промахиваются → N одновременных запросов в БД → инцидент.
- Локальный кеш (
ConcurrentMapCacheManager) —@Cacheable(sync = true). Spring блокирует параллельные вызовы на одном ключе.- Distributed cache (Redis) —
sync = trueне помогает (lock только within JVM). Нужен distributed lock через RedisSET NX EXили RedissonRLock.- Hot keys (top-products, dashboard) — refresh-ahead через
@Scheduled. Stampede исключён по дизайну.- Probabilistic refresh — обновление до истечения TTL с возрастающей вероятностью, размазывает нагрузку.
- Игнорировать stampede для >100 RPS endpoints — DB latency-инцидент при холодном старте.
synchronizedдля distributed cache — JVM-lock не виден другим инстансам, бесполезен.
Cache stampede — частая причина каскадных инцидентов после рестарта Redis, после allEntries=true, после deploy с новым cacheNames. UCP формулирует три уровня защиты: для localCache, для distributedCache, для hot keys.
Что такое stampede
Сценарий: cache top-products istёк TTL.
T=0 pod-1 GET → cache MISS → SELECT TOP 100 ... (тяжёлый запрос, 200ms)
T=10ms pod-2 GET → cache MISS → SELECT TOP 100 ... (тоже 200ms, не знает о pod-1)
T=20ms pod-3 GET → cache MISS → SELECT TOP 100 ...
...
T=180ms pod-15 GET → cache MISS → SELECT TOP 100 ...
T=200ms pod-1 → put в cache
T=210ms pod-2 → put в cache (поверх pod-1)
...
DB получила 15 одинаковых тяжёлых запросов вместо одного. Под нагрузкой >100 RPS это легко тысячи параллельных запросов, БД зависает, остальные сервисы получают timeout.
Локальный кеш — sync = true
R-CACHE-STAMP-1: для ConcurrentMapCacheManager Spring предоставляет встроенный lock.
@Cacheable(cacheNames = "currencies", sync = true)
public List<Currency> findAll() {
return currencyRepository.findAll();
}
sync = true блокирует все параллельные вызовы с тем же ключом до завершения первого. Первый идёт в БД, остальные ждут результат и читают из кеша.
Ограничение: lock работает только within JVM. Если 10 pods все одновременно дёрнули — каждый pod внутри себя выполнит один запрос, но между pods stampede остаётся (10 запросов в БД, не 100).
Distributed cache — Redis lock
R-CACHE-STAMP-2: для Redis-backed cache lock делается через Redis.
Вариант 1: Redis SET NX EX
@UseCase
@RequiredArgsConstructor
public class GetTopProductsHandler implements UseCaseHandler<GetTopProductsQuery, List<Product>> {
private final ProductRepository productRepository;
private final CacheManager cacheManager;
private final StringRedisTemplate redis;
private static final Duration LOCK_TTL = Duration.ofSeconds(10);
@Override
public List<Product> handle(GetTopProductsQuery query) {
var cache = cacheManager.getCache("top-products");
var cached = cache.get("global", List.class);
if (cached != null) return (List<Product>) cached;
var lockKey = "lock:top-products:global";
var lockValue = UUID.randomUUID().toString();
var acquired = redis.opsForValue().setIfAbsent(lockKey, lockValue, LOCK_TTL);
if (Boolean.TRUE.equals(acquired)) {
try {
cached = cache.get("global", List.class);
if (cached != null) return (List<Product>) cached;
var products = productRepository.findTop100();
cache.put("global", products);
return products;
} finally {
releaseLock(lockKey, lockValue);
}
} else {
return waitForCacheFill(cache);
}
}
}
SET NX EX — атомарная операция «установить ключ, только если не существует, с TTL». Только один pod получает true, остальные ждут.
releaseLock через Lua script для безопасности (release только если значение совпадает с твоим UUID — иначе ты release-нул чужой lock).
Вариант 2: Redisson RLock
Redisson абстрагирует Redis-based locking, поддерживает re-entrant, fair, multi-instance fault-tolerance.
implementation("org.redisson:redisson-spring-boot-starter:3.27.2")
@RequiredArgsConstructor
public class TopProductsLockedReader {
private final RedissonClient redisson;
private final ProductRepository productRepository;
private final CacheManager cacheManager;
public List<Product> getTopProducts() {
var cache = cacheManager.getCache("top-products");
var cached = cache.get("global", List.class);
if (cached != null) return (List<Product>) cached;
var lock = redisson.getLock("lock:top-products:global");
try {
if (lock.tryLock(100, 30, TimeUnit.MILLISECONDS)) {
try {
cached = cache.get("global", List.class);
if (cached != null) return (List<Product>) cached;
var products = productRepository.findTop100();
cache.put("global", products);
return products;
} finally {
lock.unlock();
}
}
Thread.sleep(50);
return cache.get("global", List.class) != null
? (List<Product>) cache.get("global", List.class).get()
: productRepository.findTop100();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
}
Вариант 3: Probabilistic refresh
Для часто читаемых ключей — обновлять до истечения TTL с вероятностью, возрастающей по мере приближения к expiry.
public List<Product> getTopProductsProbabilistic() {
var entry = cache.get("global", CachedEntry.class);
if (entry == null || shouldRefresh(entry)) {
var products = productRepository.findTop100();
cache.put("global", new CachedEntry(products, Instant.now()));
return products;
}
return entry.products();
}
private boolean shouldRefresh(CachedEntry entry) {
var age = Duration.between(entry.cachedAt(), Instant.now());
var ttl = Duration.ofMinutes(10);
var ratio = (double) age.toMillis() / ttl.toMillis();
return Math.random() < Math.pow(ratio, 3);
}
При ratio = 0.5 вероятность refresh = 12.5%, при ratio = 0.9 — 73%. Это размазывает нагрузку refresh по времени, нет момента «все одновременно».
Hot keys — refresh-ahead
R-CACHE-STAMP-3: для известных hot keys лучшая защита — фоновое обновление.
@Component
@RequiredArgsConstructor
public class TopProductsRefreshJob {
private final ProductRepository productRepository;
private final CacheManager cacheManager;
@Scheduled(fixedDelay = 30_000)
public void refresh() {
var products = productRepository.findTop100();
cacheManager.getCache("top-products").put("global", products);
}
}
Cache всегда заполнен → stampede невозможен по дизайну. Подробнее — Паттерны.
Что запрещено
Игнорировать stampede на hot endpoints
R-CACHE-STAMP-X1: для endpoints с нагрузкой >100 RPS обычный @Cacheable без защиты — таймбомба.
Сценарий: рестарт Redis в 14:00. Все ключи top-products пропали. 1000 RPS, 10 секунд → 10000 параллельных запросов в БД, БД зависает, latency растёт у всех остальных endpoints.
Защита: хотя бы sync = true для местного слоя + distributed lock для cross-pod. Или refresh-ahead, который заполняет cache до того, как первый клиент попросит.
synchronized для distributed cache
R-CACHE-STAMP-X2: JVM-lock не виден другим pod-ам.
// БЕСПОЛЕЗНО для multi-pod
private static final Object LOCK = new Object();
public List<Product> getTopProducts() {
synchronized (LOCK) {
// ...
}
}
10 pods — 10 независимых JVM-lock, каждый разрешает одному thread'у внутри своего pod-а. Cross-pod stampede не предотвращён. Нужен Redis-based lock.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Игнорировать stampede на hot endpoints | R-CACHE-STAMP-X1 | sync = true + distributed lock, или refresh-ahead |
synchronized для distributed cache | R-CACHE-STAMP-X2 | Redis SET NX EX или Redisson RLock |
sync = true для Redis-cache | R-CACHE-STAMP-1 | distributed lock (sync только in-JVM) |
| Refresh-ahead для миллионов ключей | R-CACHE-STAMP-3 | distributed lock или probabilistic |
| Lock без TTL (риск deadlock) | R-CACHE-STAMP-2 | EX обязательно для SET NX |
release без проверки owner-UUID | R-CACHE-STAMP-2 | Lua script с проверкой |
Куда дальше
- Caching → раздел 7. Cache stampede — нормативные формулировки.
- Паттерны — refresh-ahead для hot keys.
- TTL — короткий TTL → больше miss-ов → больше stampede.
- Observability — мониторинг hit rate, поиск проблемных ключей.
- PG runtime → advisory locks — PG-side альтернатива Redis lock.
- Resilience → bulkhead — параллельный механизм защиты от перегрузки.