Опирается на правила:
R-CACHE-CFG-1…R-CACHE-CFG-5иR-CACHE-CFG-X1…R-CACHE-CFG-X4из Caching Style Guide → раздел 2. Конфигурация.
Важно знать
- Redis в проде через
@nestjs/cache-manager+cache-manager-ioredis-yetstore; дефолтный in-memory store молча уезжает в K8s, где каждый pod имеет свой кеш.- JSON-сериализация —
JSON.stringify/JSON.parseна plain DTO;v8.serializeиeval-подобное — аналог Java native-сериализации, security risk.- Класс-инстанс с методами в кеше неработоспособен: после round-trip через Redis это plain object без прототипа.
- Per-cache TTL — каждый именованный кеш получает TTL из
CacheConfig, не из единогоttlмодуля.CacheModule.register()(inline-конфиг) в проде нельзя — store не берётся из конфига; толькоCacheModule.registerAsync().- Fail-fast без Redis при старте: если Redis недоступен, сервис не поднимается — silent skip кеша хуже, чем явный сбой.
- В тестах —
@testcontainers/redisили in-memory store; мок кеш-порта теряет поведение TTL и eviction.cache.set(key, val)без TTL трактуется как infinite; Redis при достижении max-memory выкидывает по LRU без вашего контроля.
Redis вместо in-memory в проде
R-CACHE-CFG-1: prod-backend — Redis через cache-manager-ioredis-yet.
Почему не дефолтный in-memory store:
- Multi-instance. 8 реплик
order-serviceв K8s = 8 локальных кешей.OrderSummaryдляorder-42записана в pod-1, pod-3 её не знает. Пользователь, попавший на pod-3 после write, читает значение до изменения. - Invalidation race.
cache.del('order-summaries:order-42')на pod-1 сбрасывает только локальный кеш. Остальные поды держат устаревшие значения до истечения TTL. - Observability. In-memory кеш — чёрный ящик; Redis — Redis Exporter для Prometheus,
redis-cli, keyspace notifications.
// src/infrastructure/cache/cache.module.ts
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-ioredis-yet';
import { CACHE_CONFIG, CacheConfig } from './cache.config';
CacheModule.registerAsync({
isGlobal: true,
inject: [CACHE_CONFIG],
useFactory: (cfg: CacheConfig) => ({
store: redisStore,
host: cfg.host,
port: cfg.port,
// per-cache TTL выставляется в кеш-порте через cache.set(key, val, ttlMs)
}),
})
// AVOID: CacheModule.register({ store: ... }) — inline конфиг, store не из env
// AVOID: CacheModule.register() — дефолтный in-memory store молча уезжает в прод
isGlobal: true позволяет инжектировать CACHE_MANAGER в любой модуль без явного импорта CacheModule.
JSON-сериализация — никогда v8.serialize
R-CACHE-CFG-2: значения в Redis — plain JSON.
cache-manager-ioredis-yet по умолчанию передаёт значения через JSON.stringify / JSON.parse. Важно не нарушать это:
// PREFER: plain DTO — JSON.stringify справится
interface OrderSummaryDto {
orderId: string;
status: string;
totalRub: number;
}
// AVOID: класс-инстанс с методами
class OrderSummary {
getTotal() { return this.totalRub; } // после round-trip метод исчезнет
}
Почему не v8.serialize / бинарные форматы:
- Security.
v8.deserializeисполняет недоверенный буфер из Redis — если attacker может записать blob в Redis, это remote code execution. - Читаемость.
redis-cli GET order-summaries:order-42возвращает читаемый JSON при отладке. Binary — нет. - Forward-compat.
JSON.parseигнорирует неизвестные поля. Добавили поле вOrderSummaryDto→ старые закешированные значения читаются без нового поля.v8при изменении структуры бросает ошибку.
Класс-инстанс с методами в кеше — отдельная ловушка: JSON.stringify(instance) сохраняет поля, JSON.parse вернёт Object, метод getTotal() исчезнет. Кешируй только plain DTO-интерфейсы.
Per-cache TTL через CacheConfig
R-CACHE-CFG-3 + R-CACHE-CFG-4: каждый именованный кеш — свой TTL; TTL берётся из конфига, не хардкодится в вызовах.
// src/infrastructure/cache/cache.config.ts
import { IsString, IsInt, Min, Max, IsPositive } from 'class-validator';
export class CacheConfig {
@IsString()
host: string;
@IsInt() @Min(1) @Max(65535)
port: number;
/** ms — TTL профиля клиента */
@IsPositive()
customerProfileTtlMs: number;
/** ms — TTL каталога продуктов */
@IsPositive()
productCatalogTtlMs: number;
/** ms — TTL feature-flags */
@IsPositive()
featureFlagsTtlMs: number;
/** ms — TTL баланса (money-данные, короткий) */
@IsPositive()
balanceTtlMs: number;
/** ms — TTL сводки заказа */
@IsPositive()
orderSummaryTtlMs: number;
}
# config/default.yaml
cache:
host: localhost
port: 6379
customerProfileTtlMs: 900000 # 15m
productCatalogTtlMs: 21600000 # 6h
featureFlagsTtlMs: 60000 # 60s
balanceTtlMs: 15000 # 15s
orderSummaryTtlMs: 300000 # 5m
Использование TTL в кеш-порте:
// src/infrastructure/cache/order-summary.cache.ts
@Injectable()
export class OrderSummaryCache {
private readonly prefix = 'order-summaries';
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
@Inject(CACHE_CONFIG) private readonly cfg: CacheConfig,
) {}
async get(orderId: string): Promise<OrderSummaryDto | null> {
return this.cache.get<OrderSummaryDto>(`${this.prefix}:${orderId}`) ?? null;
}
async set(orderId: string, dto: OrderSummaryDto): Promise<void> {
await this.cache.set(`${this.prefix}:${orderId}`, dto, this.cfg.orderSummaryTtlMs);
}
async del(orderId: string): Promise<void> {
await this.cache.del(`${this.prefix}:${orderId}`);
}
}
Почему TTL — поле CacheConfig, а не константа в методе:
- SRE регулирует TTL переменной окружения без пересборки образа.
- Одно место декларирует все TTL сервиса — легко сравнить
balanceTtlMs: 15000иorderSummaryTtlMs: 300000. @IsPositive()на каждом поле даёт ошибку при старте, если значение не передано или равно нулю.
R-CACHE-CFG-X3: единый TTL ttl: 900000 в CacheModule.registerAsync для customerProfile, balance и productCatalog одновременно — либо баланс устаревает на 15 минут, либо каталог пересчитывается каждые 15 секунд.
Структура кеш-порта
В NestJS нет декларативного @Cacheable; кеш — явный cache-aside через порт в core/:
// src/core/order/ports/order-summary-cache.port.ts
export interface OrderSummaryCachePort {
get(orderId: string): Promise<OrderSummaryDto | null>;
set(orderId: string, dto: OrderSummaryDto): Promise<void>;
del(orderId: string): Promise<void>;
}
export const ORDER_SUMMARY_CACHE_PORT = Symbol('OrderSummaryCachePort');
// src/infrastructure/cache/order-summary.cache.ts
@Injectable()
export class OrderSummaryCacheAdapter implements OrderSummaryCachePort {
// реализация выше
}
// src/order/order.module.ts
@Module({
providers: [
{ provide: ORDER_SUMMARY_CACHE_PORT, useClass: OrderSummaryCacheAdapter },
GetOrderSummaryHandler,
],
})
export class OrderModule {}
Порт живёт в core/ — domain-слой не зависит от @nestjs/cache-manager. Реализация в infrastructure/cache/ подключается через DI. Соответствует R-HEX-PORT-1.
Fail-fast без Redis
R-CACHE-CFG-X4: если backend недоступен, сервис не поднимается молча.
// src/infrastructure/cache/cache.module.ts
CacheModule.registerAsync({
isGlobal: true,
inject: [CACHE_CONFIG],
useFactory: async (cfg: CacheConfig) => {
const store = await redisStore({ host: cfg.host, port: cfg.port });
// redisStore бросит при недоступном Redis — NestJS не запустится
return { store };
},
})
В dev-окружении, если Redis необязателен, — явный NullOrderSummaryCache с предупреждением при инициализации:
// src/infrastructure/cache/null-order-summary.cache.ts
@Injectable()
export class NullOrderSummaryCache implements OrderSummaryCachePort {
constructor() {
console.warn('[NullOrderSummaryCache] Redis unavailable — caching disabled (dev only)');
}
async get(_orderId: string): Promise<OrderSummaryDto | null> { return null; }
async set(_orderId: string, _dto: OrderSummaryDto): Promise<void> {}
async del(_orderId: string): Promise<void> {}
}
null-имплементация без предупреждения — silent skip: hit rate 0%, никто не замечает до Grafana.
В тестах — Testcontainers или in-memory store
R-CACHE-CFG-5: два режима тестирования кеша.
Unit-тест handler'а (проверить «второй вызов не пошёл в репозиторий»):
// test/order/get-order-summary.handler.spec.ts
describe('GetOrderSummaryHandler', () => {
let handler: GetOrderSummaryHandler;
let cachePort: jest.Mocked<OrderSummaryCachePort>;
let repoPort: jest.Mocked<OrderRepoPort>;
beforeEach(() => {
cachePort = { get: jest.fn(), set: jest.fn(), del: jest.fn() };
repoPort = { findSummary: jest.fn() };
handler = new GetOrderSummaryHandler(cachePort, repoPort);
});
it('returns cached value without hitting repo', async () => {
const dto: OrderSummaryDto = { orderId: 'order-42', status: 'CONFIRMED', totalRub: 5000 };
cachePort.get.mockResolvedValue(dto);
const result = await handler.handle({ orderId: 'order-42' });
expect(result).toEqual(dto);
expect(repoPort.findSummary).not.toHaveBeenCalled();
});
});
Мок порта здесь правомерен — проверяется поведение handler'а, а не реализация кеша. Мок Cache-интерфейса @nestjs/cache-manager напрямую — нет: теряется TTL и eviction.
Integration-тест кеш-адаптера (проверить реальный Redis):
// test/infrastructure/order-summary.cache.spec.ts
import { RedisContainer } from '@testcontainers/redis';
describe('OrderSummaryCacheAdapter', () => {
let container: StartedRedisContainer;
let adapter: OrderSummaryCacheAdapter;
beforeAll(async () => {
container = await new RedisContainer('redis:7-alpine').start();
const store = await redisStore({ url: container.getConnectionUrl() });
const cache = cacheManager.create({ store });
const cfg: CacheConfig = { ...defaultCacheConfig, orderSummaryTtlMs: 5000 };
adapter = new OrderSummaryCacheAdapter(cache as Cache, cfg);
});
afterAll(() => container.stop());
it('stores and retrieves OrderSummaryDto', async () => {
const dto: OrderSummaryDto = { orderId: 'order-sber-42', status: 'PAID', totalRub: 12900 };
await adapter.set(dto.orderId, dto);
const result = await adapter.get(dto.orderId);
expect(result).toEqual(dto);
});
it('returns null after TTL expires', async () => {
const dto: OrderSummaryDto = { orderId: 'order-ttl-test', status: 'PENDING', totalRub: 100 };
await adapter.set(dto.orderId, dto);
await new Promise(r => setTimeout(r, 6000));
const result = await adapter.get(dto.orderId);
expect(result).toBeNull();
}, 10_000);
});
TTL-тест с мок-интерфейсом бессмысленен — мок не умеет «забывать» значение через 5 секунд.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
CacheModule.register() (in-memory store) в multi-instance проде | R-CACHE-CFG-X2 | CacheModule.registerAsync() с redisStore |
v8.serialize / бинарная десериализация значений из Redis | R-CACHE-CFG-X1 | JSON.stringify / JSON.parse через cache-manager-ioredis-yet |
| Класс-инстанс с методами в кеше | R-CACHE-CFG-2 | plain DTO-интерфейс |
Единый TTL в CacheModule.registerAsync для всех кешей | R-CACHE-CFG-X3 | отдельное поле CacheConfig на каждый кеш |
cache.set(key, val) без TTL | R-CACHE-TTL-X1 | cache.set(key, val, this.cfg.orderSummaryTtlMs) |
TTL-литерал 900_000 прямо в вызове | R-CACHE-CFG-4 | поле CacheConfig с @IsPositive() |
null-имплементация кеш-порта без предупреждения | R-CACHE-CFG-X4 | явный NullOrderSummaryCache с console.warn при инициализации |
Мок Cache-интерфейса @nestjs/cache-manager в тестах | R-CACHE-CFG-5 | OrderSummaryCachePort-мок в handler-тестах + Testcontainers в адаптер-тестах |
Куда дальше
- Где кешируем — какие данные кешировать, а какие нет.
- Ключи — namespace-префикс, explicit-ключ, хеширование sensitive-данных.
- TTL — типовые значения по характеру данных.
- Invalidation —
cache.delна write,@OnEvent-handler как триггер инвалидации. - Паттерны — cache-aside, write-through, refresh-ahead на NestJS-идиомах.
- Cache stampede — single-flight
Map<string, Promise>иredlockдля distributed-кеша. - Observability —
prom-clientcounters в кеш-порте, alert на hit rate.