Опирается на правила: R-CACHE-STAMP-1R-CACHE-STAMP-3 и R-CACHE-STAMP-X1R-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 через Redis SET NX EX или Redisson RLock.
  • 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 endpointsR-CACHE-STAMP-X1sync = true + distributed lock, или refresh-ahead
synchronized для distributed cacheR-CACHE-STAMP-X2Redis SET NX EX или Redisson RLock
sync = true для Redis-cacheR-CACHE-STAMP-1distributed lock (sync только in-JVM)
Refresh-ahead для миллионов ключейR-CACHE-STAMP-3distributed lock или probabilistic
Lock без TTL (риск deadlock)R-CACHE-STAMP-2EX обязательно для SET NX
release без проверки owner-UUIDR-CACHE-STAMP-2Lua 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 — параллельный механизм защиты от перегрузки.