Кэширование в Node.js, NestJS, Redis и HTTP
Разберите, где cache убирает повторную работу и как stale data, stampede, неверные keys и неограниченная память создают инциденты.
Временная шкала
Разбираем: Кэширование в 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.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Cache hit
Key найден, и результат возвращается из cache без обращения к primary source.
Cache miss
Key отсутствует или истёк; приложение загружает значение из primary source и обычно сохраняет копию.
Hit ratio
Доля чтений, обслуженных cache: hits / (hits + misses). Высокое число бесполезно, если кэшируются неправильные или опасно устаревшие данные.
TTL
Time To Live — срок жизни entry, после которого она становится miss и должна быть загружена заново.
Eviction
Удаление entries из ограниченного cache по TTL, LRU/LFU или другой policy, чтобы память не росла бесконечно.
Invalidation
Явное удаление или обновление cached copy после изменения source of truth.
Cache-aside
Application сначала делает GET cache, при miss читает primary и делает SET; при write обновляет primary и удаляет key.
Cache stampede
Множество callers одновременно получают miss популярного key и повторяют один дорогой loader.
Single-flight
Concurrent misses одного key разделяют один in-flight Promise или distributed lock вместо запуска одинаковой работы.
Stale data
Cached value больше не совпадает с source of truth, но ещё доступно consumer-у.
Cache key
Стабильный identity результата, включающий все параметры, влияющие на данные: entity, id, tenant, locale, filters и version.
CDN
Content Delivery Network — распределённый shared HTTP cache ближе к пользователям.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Найдите повторяемую дорогую работу
Измерьте database queries, external calls или CPU computation. Не добавляйте cache к дешёвому уникальному запросу.
- 02Назовите source of truth
PostgreSQL, upstream API или immutable artifact остаётся authoritative; cache должен быть восстановим из primary.
- 03Спроектируйте key
Key включает все dimensions ответа. Пропущенный tenantId или permission scope способен смешать данные пользователей.
- 04Обработайте hit и miss
Hit сразу возвращает copy; miss вызывает loader и сохраняет результат только после успешной загрузки.
- 05Ограничьте lifetime и size
TTL ограничивает время staleness, а max entries/bytes и eviction защищают память независимо от TTL.
- 06Определите write policy
Cache-aside обычно сначала commit-ит primary, затем удаляет key. Write-through обновляет cache синхронно; write-behind требует durable queue и сложнее.
- 07Защитите hot miss
Single-flight, lock, stale-while-revalidate или TTL jitter не дают всем replicas одновременно атаковать primary.
- 08Выберите уровень
Local memory быстрее, Redis разделяется replicas, HTTP cache/CDN может вообще не довести request до Node.
- 09Измерьте результат
Наблюдайте hits, misses, hit ratio, load latency, evictions, memory, Redis errors, stale age и primary load.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Нет cache — не всегда ошибка
Редкий запрос с высокой cardinality не даст повторных hits, зато cache lookup, serialization и invalidation добавят стоимость.
Local cache не общий
Каждый Node process, Worker или Kubernetes Pod имеет свою память. Два Pods могут вернуть разные revisions до expiration/invalidation.
TTL не ограничивает количество keys
За TTL можно создать миллионы уникальных entries. Нужен отдельный maximum size и eviction policy.
0 и false являются валидными values
Miss проверяют через undefined/null согласно cache API, а не через if (!value), иначе falsy result загружается повторно.
Negative caching бывает полезен
Короткое кеширование «не найдено» защищает БД от повторных запросов несуществующего id, но мешает увидеть только что созданный object без invalidation.
TTL jitter распределяет expiry
Небольшая случайная добавка не даёт тысячам keys истечь в одну секунду и создать synchronized load spike.
Cache errors часто можно пережить
Для производной копии timeout Redis обычно ведёт к bounded fallback в primary, а не к падению всего request. Но fallback нужно ограничить, иначе outage cache перегрузит БД.
Authorization требует особой строгости
Долгий TTL permission data может сохранить отозванный доступ. Нужны короткий lifetime, versioned key или надёжная invalidation.
HTTP cache видит представление
Cache-Control управляет freshness, ETag валидирует version, Vary разделяет варианты. Private personalized response нельзя случайно отдать shared cache.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
@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;
}
}Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
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-поток открытым до завершения сценария.
Практические шаблоны, которые можно подсмотреть
Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.
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 будет своя копия.
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.
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.
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 так кэшировать нельзя.
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.
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.
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.
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.
Что наблюдать
Доказать, что 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.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
Главная страница повторяет тяжёлый запрос для каждого посетителя
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 одного каталога.
Расчёт доставки на каждый ввод исчерпывает 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 одного каталога.
Популярные заблуждения
Миф слева, корректная модель справа.
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.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Когда отсутствие cache действительно создаёт проблему, а когда cache только усложняет путь?
- Чем TTL отличается от maximum size и eviction?
- Почему local Map не даёт общей copy для двух Kubernetes Pods?
- Какие поля должны попасть в cache key personalized endpoint?
- Почему if (!cached) неверно для cached value 0?
- Как single-flight защищает primary от stampede?
- В каком порядке cache-aside обновляет primary и удаляет key?
- Чем CacheInterceptor отличается от ручного CACHE_MANAGER?
- Когда HTTP/CDN cache полезнее Redis?
- Какие метрики доказывают реальную пользу cache?