NNODE LOOP LABruntime observatoryNNEON · Статьи на 90 языках
CONNECTING
v24.18.0linux/x64
Key → hit/miss → invalidation
21

Кэширование в Node.js, NestJS, Redis и HTTP

Разберите, где cache убирает повторную работу и как stale data, stampede, неверные keys и неограниченная память создают инциденты.

PROCESS IDтекущий сервер
UPTIMEпосле запуска
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Временная шкала

ГОТОВ
0 ms
События появятся здесьЗапустите выбранный сценарий
#ВРЕМЯИСТОЧНИКСОБЫТИЕ
Ожидаю запуск эксперимента…
ГЛАВА 21
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Кэширование в Node.js, NestJS, Redis и HTTP

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

Кэш похож на маленькую полку рядом с рабочим столом. Часто нужную книгу выгодно держать на полке, а не каждый раз идти в архив. Но полка ограничена, копия может устареть, а после перестановки архива нужно знать, какую книгу с полки убрать.

ТЕХНИЧЕСКАЯ ОСНОВА

Cache хранит производную копию данных или результата вычисления ближе к consumer-у. Hit экономит обращение к медленному primary source, miss добавляет cache lookup перед обычной загрузкой. Корректный дизайн определяет key, lifetime, size bound, invalidation, concurrency behavior, source of truth и допустимую staleness.

Зачем это знатьКэш снижает latency, database/network load и стоимость повторяемых вычислений, но создаёт вторую версию состояния. Senior-разработчик должен уметь доказать пользу через hit ratio и latency, не допустить stale security data, memory leak, stampede и превращение Redis в обязательную точку отказа.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01CLIENT / CDNCache-Control · ETag
02NODE / NESTMap · CacheModule
03DISTRIBUTEDRedis · shared keys
04PRIMARYPostgreSQL · external API
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

Cache hit

Key найден, и результат возвращается из cache без обращения к primary source.

02

Cache miss

Key отсутствует или истёк; приложение загружает значение из primary source и обычно сохраняет копию.

03

Hit ratio

Доля чтений, обслуженных cache: hits / (hits + misses). Высокое число бесполезно, если кэшируются неправильные или опасно устаревшие данные.

04

TTL

Time To Live — срок жизни entry, после которого она становится miss и должна быть загружена заново.

05

Eviction

Удаление entries из ограниченного cache по TTL, LRU/LFU или другой policy, чтобы память не росла бесконечно.

06

Invalidation

Явное удаление или обновление cached copy после изменения source of truth.

07

Cache-aside

Application сначала делает GET cache, при miss читает primary и делает SET; при write обновляет primary и удаляет key.

08

Cache stampede

Множество callers одновременно получают miss популярного key и повторяют один дорогой loader.

09

Single-flight

Concurrent misses одного key разделяют один in-flight Promise или distributed lock вместо запуска одинаковой работы.

10

Stale data

Cached value больше не совпадает с source of truth, но ещё доступно consumer-у.

11

Cache key

Стабильный identity результата, включающий все параметры, влияющие на данные: entity, id, tenant, locale, filters и version.

12

CDN

Content Delivery Network — распределённый shared HTTP cache ближе к пользователям.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Найдите повторяемую дорогую работу

    Измерьте database queries, external calls или CPU computation. Не добавляйте cache к дешёвому уникальному запросу.

  2. 02
    Назовите source of truth

    PostgreSQL, upstream API или immutable artifact остаётся authoritative; cache должен быть восстановим из primary.

  3. 03
    Спроектируйте key

    Key включает все dimensions ответа. Пропущенный tenantId или permission scope способен смешать данные пользователей.

  4. 04
    Обработайте hit и miss

    Hit сразу возвращает copy; miss вызывает loader и сохраняет результат только после успешной загрузки.

  5. 05
    Ограничьте lifetime и size

    TTL ограничивает время staleness, а max entries/bytes и eviction защищают память независимо от TTL.

  6. 06
    Определите write policy

    Cache-aside обычно сначала commit-ит primary, затем удаляет key. Write-through обновляет cache синхронно; write-behind требует durable queue и сложнее.

  7. 07
    Защитите hot miss

    Single-flight, lock, stale-while-revalidate или TTL jitter не дают всем replicas одновременно атаковать primary.

  8. 08
    Выберите уровень

    Local memory быстрее, Redis разделяется replicas, HTTP cache/CDN может вообще не довести request до Node.

  9. 09
    Измерьте результат

    Наблюдайте hits, misses, hit ratio, load latency, evictions, memory, Redis errors, stale age и primary load.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

Нет cache — не всегда ошибка

Редкий запрос с высокой cardinality не даст повторных hits, зато cache lookup, serialization и invalidation добавят стоимость.

02

Local cache не общий

Каждый Node process, Worker или Kubernetes Pod имеет свою память. Два Pods могут вернуть разные revisions до expiration/invalidation.

03

TTL не ограничивает количество keys

За TTL можно создать миллионы уникальных entries. Нужен отдельный maximum size и eviction policy.

04

0 и false являются валидными values

Miss проверяют через undefined/null согласно cache API, а не через if (!value), иначе falsy result загружается повторно.

05

Negative caching бывает полезен

Короткое кеширование «не найдено» защищает БД от повторных запросов несуществующего id, но мешает увидеть только что созданный object без invalidation.

06

TTL jitter распределяет expiry

Небольшая случайная добавка не даёт тысячам keys истечь в одну секунду и создать synchronized load spike.

07

Cache errors часто можно пережить

Для производной копии timeout Redis обычно ведёт к bounded fallback в primary, а не к падению всего request. Но fallback нужно ограничить, иначе outage cache перегрузит БД.

08

Authorization требует особой строгости

Долгий TTL permission data может сохранить отозванный доступ. Нужны короткий lifetime, versioned key или надёжная invalidation.

09

HTTP cache видит представление

Cache-Control управляет freshness, ETag валидирует version, Vary разделяет варианты. Private personalized response нельзя случайно отдать shared cache.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/demos.js · учебный фрагментJavaScript
@Injectable()
export class ProductsService {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cache: Cache,
    private readonly products: ProductsRepository,
  ) {}

  async findOne(id: string) {
    const key = `product:v1:${id}`;
    const hit = await this.cache.get<Product>(key);
    if (hit !== undefined && hit !== null) return hit;

    const product = await this.products.findById(id);
    await this.cache.set(key, product, 30_000);
    return product;
  }
}
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.

src/cache-lab.js
cache218 строк
import 'reflect-metadata';
import {
  Dependencies,
  Injectable,
  Module,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
  CACHE_MANAGER,
  CacheModule,
} from '@nestjs/cache-manager';
import { performance } from 'node:perf_hooks';

const CACHE_TRACE = Symbol('CACHE_TRACE');
const PRODUCT_TTL_MS = 120;

const wait = (milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

class ProductRepository {
  constructor() {
    this.readCount = 0;
    this.products = new Map([
      [
        'book-42',
        {
          id: 'book-42',
          name: 'Node.js Runtime',
          price: 4200,
          revision: 1,
        },
      ],
    ]);
  }

  async findById(id) {
    this.readCount += 1;
    await wait(35);
    const product = this.products.get(id);
    return product ? structuredClone(product) : null;
  }

  async updatePrice(id, price) {
    const current = this.products.get(id);
    const updated = {
      ...current,
      price,
      revision: current.revision + 1,
    };
    this.products.set(id, updated);
    return structuredClone(updated);
  }
}
Injectable()(ProductRepository);

class ProductCacheService {
  constructor(cache, products, trace) {
    this.cache = cache;
    this.products = products;
    this.trace = trace;
    this.inFlight = new Map();
  }

  key(id) {
    return `product:v1:${id}`;
  }

  async getById(id) {
    const key = this.key(id);
    const cached = await this.cache.get(key);
    if (cached !== undefined && cached !== null) {
      this.trace.emit('cache', 'hit', `HIT ${key}: repository не вызывается`);
      return cached;
    }

    if (this.inFlight.has(key)) {
      this.trace.emit(
        'single-flight',
        'join',
        `MISS ${key}: запрос присоединён к уже выполняющемуся loader`,
      );
      return this.inFlight.get(key);
    }

    this.trace.emit(
      'cache',
      'miss',
      `MISS ${key}: выполняется repository.findById`,
    );
    const loading = this.products
      .findById(id)
      .then(async (product) => {
        await this.cache.set(key, product, PRODUCT_TTL_MS);
        return product;
      })
      .finally(() => {
        this.inFlight.delete(key);
      });
    this.inFlight.set(key, loading);
    return loading;
  }

  async updatePrice(id, price) {
    const updated = await this.products.updatePrice(id, price);
    await this.cache.del(this.key(id));
    this.trace.emit(
      'invalidation',
      'delete',
      `UPDATE записан в primary; ключ ${this.key(id)} удалён`,
    );
    return updated;
  }
}
Dependencies(CACHE_MANAGER, ProductRepository, CACHE_TRACE)(
  ProductCacheService,
);
Injectable()(ProductCacheService);

class CacheLabModule {}

function configureCacheLab(trace) {
  Module({
    imports: [
      CacheModule.register({
        ttl: PRODUCT_TTL_MS,
      }),
    ],
    providers: [
      ProductRepository,
      ProductCacheService,
      {
        provide: CACHE_TRACE,
        useValue: trace,
      },
    ],
  })(CacheLabModule);
  return CacheLabModule;
}

export async function cachingStrategies(emit) {
  const trace = { emit };
  const application = await NestFactory.createApplicationContext(
    configureCacheLab(trace),
    { logger: false },
  );

  try {
    const repository = application.get(ProductRepository);
    const service = application.get(ProductCacheService);

    emit(
      'baseline',
      'start',
      'Без cache три одинаковых чтения трижды занимают repository и connection',
    );
    const baselineStarted = performance.now();
    await repository.findById('book-42');
    await repository.findById('book-42');
    await repository.findById('book-42');
    const baselineMs = Math.round(performance.now() - baselineStarted);
    emit(
      'baseline',
      'result',
      `Без cache: reads=3, elapsed≈${baselineMs} мс`,
    );

    const readsBeforeCache = repository.readCount;
    const cachedStarted = performance.now();
    await service.getById('book-42');
    await service.getById('book-42');
    await service.getById('book-42');
    const cachedMs = Math.round(performance.now() - cachedStarted);
    emit(
      'cache',
      'result',
      `Cache-aside: repository reads=${repository.readCount - readsBeforeCache}, elapsed≈${cachedMs} мс`,
    );

    await wait(PRODUCT_TTL_MS + 20);
    const readsBeforeExpiry = repository.readCount;
    await service.getById('book-42');
    emit(
      'ttl',
      'expired',
      `После TTL новый MISS добавил repository reads=${repository.readCount - readsBeforeExpiry}`,
    );

    const cache = application.get(CACHE_MANAGER);
    await cache.del(service.key('book-42'));
    const readsBeforeBurst = repository.readCount;
    await Promise.all(
      Array.from({ length: 5 }, () => service.getById('book-42')),
    );
    emit(
      'single-flight',
      'result',
      `Пять одновременных MISS вызвали loader ${repository.readCount - readsBeforeBurst} раз вместо 5`,
    );

    await service.updatePrice('book-42', 4500);
    const fresh = await service.getById('book-42');
    emit(
      'invalidation',
      'result',
      `После invalidation прочитана revision=${fresh.revision}, price=${fresh.price}`,
    );

    emit(
      'architecture',
      'boundary',
      'In-memory cache принадлежит одному Node process; Redis нужен для общего cache нескольких replicas',
    );
  } finally {
    await application.close();
    emit('cleanup', 'done', 'Nest application context и cache store закрыты');
  }
}

Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.

06 · РЕЦЕПТЫ

Практические шаблоны, которые можно подсмотреть

Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.

01

Node.js: минимальный cache-aside

Не выполнять один тяжёлый loader при каждом последовательном чтении.

const cache = new Map();

async function getProduct(id) {
  const key = `product:${id}`;
  const entry = cache.get(key);

  if (entry && entry.expiresAt > Date.now()) {
    return entry.value;
  }

  const product = await repository.findById(id);
  cache.set(key, {
    value: product,
    expiresAt: Date.now() + 30_000,
  });
  return product;
}
  • Без cache каждое чтение занимает DB connection и платит полную latency.
  • Этот учебный Map всё ещё требует max-size eviction.
  • В multi-process deployment у каждого process будет своя копия.
02

Nest: CacheModule и CACHE_MANAGER

Получить cache store через dependency injection, а не создавать глобальный singleton вручную.

@Module({
  imports: [CacheModule.register({ ttl: 30_000 })],
  providers: [ProductsService],
})
export class ProductsModule {}

@Injectable()
export class ProductsService {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cache: Cache,
  ) {}

  get(id: string) {
    return this.cache.get(`product:v1:${id}`);
  }
}
  • CacheModule управляет provider lifecycle.
  • TTL в актуальном cache-manager задаётся в миллисекундах.
  • Version v1 позволяет сменить schema cached value.
03

Nest: cache-aside с invalidation

Не возвращать старую цену после успешного update.

async findOne(id: string) {
  const key = `product:v1:${id}`;
  const hit = await this.cache.get<Product>(key);
  if (hit !== undefined && hit !== null) return hit;

  const product = await this.products.findById(id);
  await this.cache.set(key, product, 30_000);
  return product;
}

async update(id: string, dto: UpdateProductDto) {
  const product = await this.products.update(id, dto);
  await this.cache.del(`product:v1:${id}`);
  return product;
}
  • Primary update выполняется до eviction.
  • Следующее чтение repopulate-ит cache новой revision.
  • Нужна стратегия для ошибки cache.del после успешного DB commit.
04

Nest CacheInterceptor для простого GET

Кэшировать безопасное representation без ручного get/set в controller.

@Controller('public/catalog')
@UseInterceptors(CacheInterceptor)
export class CatalogController {
  @Get()
  @CacheTTL(15_000)
  findAll() {
    return this.catalog.findPublicItems();
  }
}
  • Официальный interceptor автоматически кэширует GET response.
  • Personalized response требует корректного key/trackBy.
  • Business commands и side effects так кэшировать нельзя.
05

Single-flight против stampede

Пусть пять concurrent misses разделят один database Promise.

const inFlight = new Map<string, Promise<Product>>();

async function loadOnce(key: string) {
  const running = inFlight.get(key);
  if (running) return running;

  const promise = repository
    .findById(key)
    .finally(() => inFlight.delete(key));

  inFlight.set(key, promise);
  return promise;
}
  • finally удаляет только coordination state, не cached value.
  • Local single-flight защищает один process.
  • Для нескольких replicas нужна Redis lock/early refresh или другой distributed mechanism.
06

Redis cache-aside

Разделить горячий cache между несколькими Node/Nest replicas.

async function getProduct(id) {
  const key = `product:v1:${id}`;
  const cached = await redis.get(key);
  if (cached !== null) return JSON.parse(cached);

  const product = await repository.findById(id);
  await redis.set(key, JSON.stringify(product), {
    EX: 30 + Math.floor(Math.random() * 6),
  });
  return product;
}
  • Redis добавляет network hop, но даёт общую copy.
  • EX задаёт seconds; API конкретного client нужно проверять.
  • Random jitter рассинхронизирует expiration.
07

External API: короткий cache

Не исчерпать rate limit одинаковыми запросами курсов валют или доставки.

const key = `shipping:v2:${country}:${postalCode}:${weight}`;
const cached = await cache.get(key);
if (cached) return cached;

const quote = await shippingProvider.quote(input);
await cache.set(key, quote, 60_000);
return quote;
  • Без cache spike повторяет network latency и расходует provider quota.
  • Key обязан включать все параметры расчёта.
  • TTL выбирается из допустимой свежести business response.
08

HTTP browser/CDN cache

Не отправлять неизменившийся публичный response через Node для каждого пользователя.

response.setHeader(
  'Cache-Control',
  'public, max-age=60, s-maxage=300, stale-while-revalidate=30',
);
response.setHeader('ETag', versionHash);
  • max-age относится к browser freshness.
  • s-maxage управляет shared cache/CDN.
  • ETag позволяет получить 304 без повторной передачи body.
  • Personalized data обычно использует private/no-store.
09

Что наблюдать

Доказать, что cache помогает и не скрывает инцидент.

cache_requests_total{result="hit"}
cache_requests_total{result="miss"}
cache_load_duration_seconds
cache_evictions_total
cache_entries
cache_stale_served_total
primary_queries_total
  • Hit ratio читают вместе с primary load и end-to-end latency.
  • Entries/memory обнаруживают отсутствие bounds.
  • Miss storm после expiry виден как одновременный рост loaders.
07 · PRODUCTION-КЕЙСЫ

Как учебная ошибка превращается в инцидент

Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.

КЕЙС 01

Главная страница повторяет тяжёлый запрос для каждого посетителя

Nest endpoint популярного каталога выполняет один и тот же JOIN с агрегацией. Данные меняются несколько раз в минуту, но каждый HTTP request снова занимает PostgreSQL connection.

КОНТЕКСТ ИНЦИДЕНТА

Без cache рост трафика почти линейно увеличивает database queries. Сначала растёт pool wait и p95, затем timeouts создают retries и ещё большую нагрузку на primary.

ДОПРОБЛЕМНАЯ РЕАЛИЗАЦИЯ
@Injectable()
export class FeaturedProductsQuery {
  constructor(private readonly db: Database) {}

  async execute(locale: string) {
    return this.db.query(
      `SELECT p.id, t.name, avg(r.score) AS rating
       FROM products p
       JOIN translations t ON t.product_id = p.id
       LEFT JOIN reviews r ON r.product_id = p.id
       WHERE p.featured AND t.locale = $1
       GROUP BY p.id, t.name
       ORDER BY rating DESC NULLS LAST
       LIMIT 24`,
      [locale],
    );
  }
}

Запрос корректен, но одинаковая работа повторяется для тысяч consumers. Даже быстрый plan расходует connection time, CPU и buffers; traffic spike может исчерпать небольшой pool.

ПОСЛЕИСПРАВЛЕННАЯ РЕАЛИЗАЦИЯ
@Injectable()
export class FeaturedProductsQuery {
  private readonly inFlight = new Map<string, Promise<Product[]>>();

  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cache: Cache,
    private readonly repository: ProductsRepository,
  ) {}

  async execute(locale: string) {
    const key = `featured:v2:${locale}`;
    const hit = await this.cache.get<Product[]>(key);
    if (hit !== undefined && hit !== null) return hit;

    const running = this.inFlight.get(key);
    if (running) return running;

    const loading = this.repository
      .findFeatured(locale)
      .then(async (rows) => {
        await this.cache.set(key, rows, 10_000);
        return rows;
      })
      .finally(() => this.inFlight.delete(key));

    this.inFlight.set(key, loading);
    return loading;
  }

  invalidate(locale: string) {
    return this.cache.del(`featured:v2:${locale}`);
  }
}

Cache key разделяет locale и schema version. TTL ограничивает staleness, invalidation вызывается после изменения featured data, а in-flight Promise не даёт concurrent misses размножить один SQL query внутри process.

ФУНКЦИИ И КОНСТРУКЦИИ

Что делают непривычные вызовы из обоих фрагментов кода.

@Inject(CACHE_MANAGER)
Просит Nest DI container передать cache-manager instance, созданный импортированным CacheModule.
cache.get(key)
Читает derived copy по точному key; undefined/null означают miss, а 0, false и пустая строка могут быть валидным hit.
cache.set(key, value, ttl)
Сохраняет сериализуемую copy на ограниченный срок; текущий Nest cache-manager принимает TTL в миллисекундах.
cache.del(key)
Инвалидирует одну cached copy после изменения primary state, чтобы следующее чтение загрузило новую revision.
inFlight.get(key)
Проверяет, уже выполняется ли loader этого key в текущем process, и позволяет concurrent caller переиспользовать Promise.
finally(() => inFlight.delete(key))
Удаляет coordination Promise после success или error, иначе отклонённый loader навсегда заблокировал бы будущие попытки.
stableHash(dimensions)
Условный helper канонически сериализует все dimensions ответа и получает компактный cache key без raw personal data.
ttlJitter(maxMs)
Условный helper возвращает небольшую случайную добавку к TTL, чтобы популярные keys не истекали одновременно.
singleFlight.do(key, loader)
Условная abstraction объединяет параллельные calls одного key; local реализация хранит Promise, distributed требует coordination store.
featured:v2:${locale}
Versioned key включает locale, потому что разные языки возвращают разные representations одного каталога.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Кэшировать стоит измеримую повторяемую работу. После внедрения сравнивают hit ratio, p95 и primary query rate. Если почти каждый key уникален, этот слой следует удалить, а не продолжать увеличивать TTL.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Одинаковый query fingerprint доминирует в pg_stat_statements.
  • Database pool wait растёт вместе с RPS главной страницы.
  • После expiry возникает узкий burst одинаковых SQL-запросов.
КЕЙС 02

Расчёт доставки на каждый ввод исчерпывает quota внешнего API

Checkout frontend уточняет корзину и повторяет запрос цены доставки. Nest service каждый раз вызывает платного provider-а, хотя country, postal code, weight и cart revision не изменились.

КОНТЕКСТ ИНЦИДЕНТА

Без cache пользователь платит network latency на каждом запросе, а общий traffic быстро достигает provider rate limit. При 429 retries могут синхронно усилить нагрузку.

ДОПРОБЛЕМНАЯ РЕАЛИЗАЦИЯ
@Injectable()
export class ShippingService {
  constructor(private readonly provider: ShippingProvider) {}

  quote(input: ShippingQuoteDto) {
    return this.provider.quote({
      country: input.country,
      postalCode: input.postalCode,
      weight: input.weight,
      items: input.items,
    });
  }
}

Детерминированный для короткого окна result не переиспользуется. Endpoint становится полностью зависим от latency, quota и краткого outage upstream provider-а.

ПОСЛЕИСПРАВЛЕННАЯ РЕАЛИЗАЦИЯ
@Injectable()
export class ShippingService {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cache: Cache,
    private readonly provider: ShippingProvider,
    private readonly singleFlight: SingleFlight,
  ) {}

  async quote(input: ShippingQuoteDto) {
    const dimensions = {
      country: input.country,
      postalCode: input.postalCode.trim().toUpperCase(),
      weight: input.weight,
      cartRevision: input.cartRevision,
    };
    const key = `shipping:v3:${stableHash(dimensions)}`;
    const hit = await this.cache.get<ShippingQuote>(key);
    if (hit !== undefined && hit !== null) return hit;

    return this.singleFlight.do(key, async () => {
      const quote = await this.provider.quote(input);
      await this.cache.set(key, quote, 60_000 + ttlJitter(5_000));
      return quote;
    });
  }
}

Key включает все dimensions результата без персональных raw data. Короткий TTL соответствует допустимой свежести quote, jitter распределяет expiry, а single-flight сокращает параллельные upstream calls.

ФУНКЦИИ И КОНСТРУКЦИИ

Что делают непривычные вызовы из обоих фрагментов кода.

@Inject(CACHE_MANAGER)
Просит Nest DI container передать cache-manager instance, созданный импортированным CacheModule.
cache.get(key)
Читает derived copy по точному key; undefined/null означают miss, а 0, false и пустая строка могут быть валидным hit.
cache.set(key, value, ttl)
Сохраняет сериализуемую copy на ограниченный срок; текущий Nest cache-manager принимает TTL в миллисекундах.
cache.del(key)
Инвалидирует одну cached copy после изменения primary state, чтобы следующее чтение загрузило новую revision.
inFlight.get(key)
Проверяет, уже выполняется ли loader этого key в текущем process, и позволяет concurrent caller переиспользовать Promise.
finally(() => inFlight.delete(key))
Удаляет coordination Promise после success или error, иначе отклонённый loader навсегда заблокировал бы будущие попытки.
stableHash(dimensions)
Условный helper канонически сериализует все dimensions ответа и получает компактный cache key без raw personal data.
ttlJitter(maxMs)
Условный helper возвращает небольшую случайную добавку к TTL, чтобы популярные keys не истекали одновременно.
singleFlight.do(key, loader)
Условная abstraction объединяет параллельные calls одного key; local реализация хранит Promise, distributed требует coordination store.
featured:v2:${locale}
Versioned key включает locale, потому что разные языки возвращают разные representations одного каталога.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Для внешнего API cache одновременно уменьшает latency, стоимость и зависимость от quota. Но tax, inventory, permissions и другие критичные данные требуют отдельной freshness policy; нельзя выдавать старое значение только потому, что upstream упал.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Provider request count значительно выше числа уникальных корзин.
  • 429 и provider p95 напрямую повторяются в checkout p95.
  • Одновременный expiry создаёт burst одинаковых outbound spans.
08 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

Cache всегда ускоряет приложение.

НА САМОМ ДЕЛЕ

При низком hit ratio lookup, serialization, network и invalidation могут сделать путь медленнее.

МИФ

Достаточно положить результат в глобальный Map.

НА САМОМ ДЕЛЕ

Без TTL, maximum size и eviction Map превращается в memory leak и исчезает при restart.

МИФ

TTL полностью решает consistency.

НА САМОМ ДЕЛЕ

TTL лишь задаёт верхнее окно staleness; критичные writes обычно требуют invalidation или versioning.

МИФ

Redis является source of truth.

НА САМОМ ДЕЛЕ

Обычный cache должен быть восстановим; durable business state хранится в предназначенной для этого системе.

МИФ

Один miss означает один database query.

НА САМОМ ДЕЛЕ

При concurrency сотни callers могут одновременно увидеть miss; нужен single-flight или distributed coordination.

МИФ

Можно кэшировать POST для защиты от повторной оплаты.

НА САМОМ ДЕЛЕ

Cache не заменяет idempotency key и database constraints для business side effects.

09 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Когда отсутствие cache действительно создаёт проблему, а когда cache только усложняет путь?
  2. Чем TTL отличается от maximum size и eviction?
  3. Почему local Map не даёт общей copy для двух Kubernetes Pods?
  4. Какие поля должны попасть в cache key personalized endpoint?
  5. Почему if (!cached) неверно для cached value 0?
  6. Как single-flight защищает primary от stampede?
  7. В каком порядке cache-aside обновляет primary и удаляет key?
  8. Чем CacheInterceptor отличается от ручного CACHE_MANAGER?
  9. Когда HTTP/CDN cache полезнее Redis?
  10. Какие метрики доказывают реальную пользу cache?