Опирается на правила:
R-CACHE-STAMP-1…R-CACHE-STAMP-3иR-CACHE-STAMP-X1…R-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 lock —
redlockповерх 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 endpoints | R-CACHE-STAMP-X1 | Single-flight + redlock или refresh-ahead |
Локальный Map-lock как защита multi-instance кеша | R-CACHE-STAMP-X2 | redlock поверх ioredis |
redlock.acquire без double-check после получения lock | R-CACHE-STAMP-2 | Double-check cache.get сразу после lock |
| Refresh-ahead для миллионов ключей | R-CACHE-STAMP-3 | Distributed lock или probabilistic |
| Lock без TTL на ключе (риск deadlock) | R-CACHE-STAMP-2 | ttlMs обязателен в 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.
- Где кешируем — какие данные кешировать, какие нет.