Опирается на правила: R-CACHE-STAMP-1R-CACHE-STAMP-3 и R-CACHE-STAMP-X1R-CACHE-STAMP-X2 из Caching Style Guide → раздел 7. Cache stampede.

Важно знать

  • Cache stampede (thundering herd) — все параллельные запросы видят cache miss на истёкшем ключе и одновременно уходят в БД, которая получает N одинаковых тяжёлых запросов вместо одного.
  • Single-flight (Map<string, Promise<T>>) — Node-идиома для одного инстанса: повторные запросы одного ключа подключаются к уже запущенному Promise, не запускают второй.
  • Single-flight не виден другим процессам: в multi-instance деплое каждый pod запустит свой запрос. Нужен distributed lockredlock поверх ioredis.
  • Hot keys (главная, top-100, курсы валют) — лучшая защита — refresh-ahead через @Interval/@Cron (@nestjs/schedule): cache всегда заполнен, stampede невозможен по дизайну.
  • Probabilistic refresh — обновление до истечения TTL с вероятностью, растущей по мере приближения к expiry. Размазывает момент обновления, убирает единый пик.
  • Игнорировать stampede для hot endpoints (>100 RPS) — гарантированный DB-инцидент при рестарте Redis или массовом evict.
  • R-CACHE-STAMP-X2: локальный Map-lock не защищает distributed cache — используй redlock.

Cache stampede — частая причина каскадных отказов после рестарта Redis, после cache.reset() по ошибке или после деплоя с новыми именами ключей. UCP формулирует три уровня защиты под Node: single-flight для одного процесса, redlock для multi-instance и refresh-ahead для известных hot keys.

Что такое stampede

Сценарий: ключ top-products:global истёк в 14:00.

T=0    pod-1 GET → cache MISS → SELECT TOP 100 ... (тяжёлый запрос, 250ms)
T=15ms pod-2 GET → cache MISS → SELECT TOP 100 ... (не знает о pod-1)
T=30ms pod-3 GET → cache MISS → SELECT TOP 100 ...
...
T=200ms pod-12 GET → cache MISS → SELECT TOP 100 ...
T=250ms pod-1 → cache.set('top-products:global', ...)
T=260ms pod-2 → cache.set('top-products:global', ...) — поверх pod-1

БД получила 12 одинаковых тяжёлых запросов вместо одного. При 500 RPS в момент expiry это легко сотни параллельных запросов — БД начинает отказывать, latency растёт у всех downstream.

Один инстанс — single-flight

R-CACHE-STAMP-1: Node-идиома для одного процесса — Map<string, Promise<T>> in-flight загрузок. Параллельные вызовы одного ключа подключаются к единственному запущенному Promise.

// adapters/out/cache/product-cache.adapter.ts
import { Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { InjectCache } from '@nestjs/cache-manager';
import { ProductRepository } from '@core/ports/out/product.repository';
import { TopProductsDto } from '@core/dto/top-products.dto';
import { CacheConfig } from '@config/cache.config';

@Injectable()
export class ProductCacheAdapter {
  private readonly inFlight = new Map<string, Promise<TopProductsDto[]>>();

  constructor(
    @InjectCache() private readonly cache: Cache,
    private readonly productRepo: ProductRepository,
    private readonly cfg: CacheConfig,
  ) {}

  async getTopProducts(): Promise<TopProductsDto[]> {
    const key = 'top-products:global';

    const hit = await this.cache.get<TopProductsDto[]>(key);
    if (hit) return hit;

    let load = this.inFlight.get(key);      // R-CACHE-STAMP-1: single-flight
    if (!load) {
      load = this.loadAndSet(key).finally(() => this.inFlight.delete(key));
      this.inFlight.set(key, load);
    }
    return load;
  }

  private async loadAndSet(key: string): Promise<TopProductsDto[]> {
    const products = await this.productRepo.findTop100();
    await this.cache.set(key, products, this.cfg.ttl.topProducts);
    return products;
  }
}

inFlight.delete(key) в .finally() гарантирует очистку map как при успехе, так и при ошибке — следующий вызов получит свежий Promise.

Ограничение: Map живёт в памяти одного Node-процесса. Для multi-instance деплоя каждый pod запустит свой loadAndSet — stampede между pods сохраняется.

Multi-instance — distributed lock через redlock

R-CACHE-STAMP-2: для Redis-backed cache с несколькими инстансами нужен distributed lock. redlock реализует алгоритм Redlock поверх ioredis.

npm install redlock ioredis
// adapters/out/cache/order-summary-cache.adapter.ts
import { Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { InjectCache } from '@nestjs/cache-manager';
import Redlock from 'redlock';
import Redis from 'ioredis';
import { OrderRepository } from '@core/ports/out/order.repository';
import { OrderSummaryDto } from '@core/dto/order-summary.dto';
import { CacheConfig } from '@config/cache.config';

@Injectable()
export class OrderSummaryCacheAdapter {
  private readonly redlock: Redlock;

  constructor(
    @InjectCache() private readonly cache: Cache,
    private readonly orderRepo: OrderRepository,
    private readonly cfg: CacheConfig,
    private readonly redis: Redis,
  ) {
    this.redlock = new Redlock([redis], {
      retryCount: 3,
      retryDelay: 50,
    });
  }

  async getOrderSummary(orderId: string): Promise<OrderSummaryDto> {
    const key = `order-summary:${orderId}`;

    const hit = await this.cache.get<OrderSummaryDto>(key);
    if (hit) return hit;

    // R-CACHE-STAMP-2: distributed lock — виден всем инстансам
    const lockKey = `lock:${key}`;
    const lock = await this.redlock.acquire([lockKey], this.cfg.lock.ttlMs);
    try {
      // double-check после получения lock
      const doubleHit = await this.cache.get<OrderSummaryDto>(key);
      if (doubleHit) return doubleHit;

      const summary = await this.orderRepo.findSummary(orderId);
      await this.cache.set(key, summary, this.cfg.ttl.orderSummary);
      return summary;
    } finally {
      await lock.release();
    }
  }
}

Double-check после lock.acquire критичен: пока этот pod ждал lock, другой pod уже заполнил cache. Без double-check — лишний запрос в БД, хотя lock получен честно.

retryCount: 3 и retryDelay: 50 — pod ждёт lock максимум ~150ms. Если lock не получен за это время, redlock.acquire бросает ExecutionError — upstream обрабатывает как cache miss с fallback на прямой запрос в БД.

Комбинация: single-flight + redlock

Для максимальной защиты — оба слоя вместе: single-flight сокращает внутрипроцессные запросы, redlock защищает от cross-pod stampede.

async getCustomerProfile(customerId: string): Promise<CustomerProfileDto> {
  const key = `customer-profiles:${customerId}`;

  const hit = await this.cache.get<CustomerProfileDto>(key);
  if (hit) return hit;

  let load = this.inFlight.get(key);
  if (!load) {
    load = this.loadWithLock(key, customerId)
      .finally(() => this.inFlight.delete(key));
    this.inFlight.set(key, load);
  }
  return load;
}

private async loadWithLock(key: string, customerId: string): Promise<CustomerProfileDto> {
  const lock = await this.redlock.acquire([`lock:${key}`], this.cfg.lock.ttlMs);
  try {
    const doubleHit = await this.cache.get<CustomerProfileDto>(key);
    if (doubleHit) return doubleHit;

    const profile = await this.customerRepo.findProfile(customerId);
    await this.cache.set(key, profile, this.cfg.ttl.customerProfile);
    return profile;
  } finally {
    await lock.release();
  }
}

Hot keys — refresh-ahead

R-CACHE-STAMP-3: для известных горячих ключей (главная, каталог, курсы Сбера) лучший подход — держать cache всегда заполненным. @Interval из @nestjs/schedule обновляет ключ до истечения TTL.

// adapters/out/cache/product-refresh.job.ts
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { Cache } from 'cache-manager';
import { InjectCache } from '@nestjs/cache-manager';
import { ProductRepository } from '@core/ports/out/product.repository';
import { TopProductsDto } from '@core/dto/top-products.dto';
import { CacheConfig } from '@config/cache.config';

@Injectable()
export class ProductRefreshJob {
  private readonly logger = new Logger(ProductRefreshJob.name);

  constructor(
    @InjectCache() private readonly cache: Cache,
    private readonly productRepo: ProductRepository,
    private readonly cfg: CacheConfig,
  ) {}

  @Interval(30_000)  // каждые 30 секунд, TTL кеша — 60 секунд
  async refreshTopProducts(): Promise<void> {
    const products = await this.productRepo.findTop100();
    await this.cache.set('top-products:global', products, this.cfg.ttl.topProducts);
    this.logger.debug('top-products:global refreshed');
  }
}

Cache всегда заполнен → stampede невозможен по дизайну. Интервал обновления должен быть вдвое меньше TTL, чтобы не возникло окна между expiry и следующим refresh.

Refresh-ahead применим только для ограниченного набора известных ключей — если ключей миллионы (по одному на заказ, на покупателя), фоновое обновление всех невозможно. Для таких случаев — distributed lock или probabilistic refresh.

Probabilistic refresh

Для ключей, которые менее предсказуемы, чем top-products, но всё равно горячие: обновлять до истечения TTL с вероятностью, растущей по мере приближения к expiry.

// adapters/out/cache/sber-rates-cache.adapter.ts
interface CachedEntry<T> {
  value: T;
  cachedAt: number;
}

@Injectable()
export class SberRatesCacheAdapter {
  constructor(
    @InjectCache() private readonly cache: Cache,
    private readonly ratesApi: SberRatesPort,
    private readonly cfg: CacheConfig,
  ) {}

  async getRates(): Promise<RatesDto> {
    const key = 'sber-rates:current';
    const entry = await this.cache.get<CachedEntry<RatesDto>>(key);

    if (!entry || this.shouldRefresh(entry)) {
      const rates = await this.ratesApi.fetchCurrent();
      const next: CachedEntry<RatesDto> = { value: rates, cachedAt: Date.now() };
      await this.cache.set(key, next, this.cfg.ttl.sberRates);
      return rates;
    }
    return entry.value;
  }

  private shouldRefresh(entry: CachedEntry<unknown>): boolean {
    const age = Date.now() - entry.cachedAt;
    const ratio = age / this.cfg.ttl.sberRates;
    return Math.random() < Math.pow(ratio, 3);
  }
}

При ratio = 0.5 (половина TTL прошла) вероятность refresh = 12.5%; при ratio = 0.9 — 73%. Нагрузка размазана по времени, пик при expiry устранён.

Что запрещено

АнтипаттернПравилоЧто взамен
Нет защиты от stampede на hot endpointsR-CACHE-STAMP-X1Single-flight + redlock или refresh-ahead
Локальный Map-lock как защита multi-instance кешаR-CACHE-STAMP-X2redlock поверх ioredis
redlock.acquire без double-check после получения lockR-CACHE-STAMP-2Double-check cache.get сразу после lock
Refresh-ahead для миллионов ключейR-CACHE-STAMP-3Distributed lock или probabilistic
Lock без TTL на ключе (риск deadlock)R-CACHE-STAMP-2ttlMs обязателен в redlock.acquire
cache.reset() при холодном старте как «сброс перед заполнением»R-CACHE-INV-X1Точечный cache.del по ключу

Куда дальше

  • Паттерны — refresh-ahead для hot keys, cache-aside и write-through.
  • TTL — короткий TTL → больше miss-ов → больше stampede; как выбрать порог.
  • Observability — hit rate метрики через prom-client, поиск проблемных ключей.
  • Invalidation — cache.del на write, invalidation через @OnEvent.
  • Конфигурация — CacheModule.registerAsync, per-cache TTL, fail-fast без Redis.
  • Ключи — namespace-префиксы, explicit-ключи, хеширование sensitive.
  • Где кешируем — какие данные кешировать, какие нет.