Опирается на правила: R-CACHE-TTL-1R-CACHE-TTL-4 и R-CACHE-TTL-X1R-CACHE-TTL-X3 из Caching Style Guide → раздел 4. TTL.

Важно знать

  • Каждый cache.set — с явным TTL: третий аргумент ttlMs обязателен; без него Redis хранит ключ бесконечно.
  • Типовые значения по характеру: static reference — часы; профиль пользователя — 15-30 мин; feature-flags — 30-60 сек; heavy aggregations — 5-10 мин; money — 5-30 сек.
  • TTL — из CacheConfig, не хардкод-литерал в сервисе; SRE меняет без redeploy.
  • Если есть естественный invalidation event — TTL длиннее; invalidation делает основную работу.
  • cache.set(key, value) без TTL — Redis при достижении maxmemory evict-ит по LRU вне вашего контроля.
  • TTL > 24 часов — кеш переживает деплой со старой структурой DTO; десериализация ломается на проде.
  • Money без TTL — главный антипаттерн: деньги списались, баланс в кеше старый → инцидент.

TTL — страховочная сетка под invalidation. Если evict надёжный — TTL длиннее, он резервный; если нет — TTL короткий, чтобы ограничить ущерб от stale-данных.

Explicit TTL на каждом кеше

R-CACHE-TTL-1: ноль infinite-кешей в проде.

В @nestjs/cache-manager TTL передаётся третьим аргументом cache.set(key, value, ttlMs) — в миллисекундах. Без него ключ живёт вечно (или пока Redis не evict-нет по памяти).

// cache-port.ts (core/)
export interface CachePort {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T, ttlMs: number): Promise<void>;
  del(key: string): Promise<void>;
}
// product-cache.adapter.ts
@Injectable()
export class ProductCacheAdapter implements CachePort {
  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly cfg: CacheConfig,
  ) {}

  async getProductSummary(productId: string): Promise<ProductSummaryDto | null> {
    return this.cache.get<ProductSummaryDto>(`product-summaries:${productId}`);
  }

  async setProductSummary(productId: string, dto: ProductSummaryDto): Promise<void> {
    await this.cache.set(
      `product-summaries:${productId}`,
      dto,
      this.cfg.caches['product-summaries'].ttlMs,
    );
  }
}

Конфиг в одном месте — порт не знает конкретных значений TTL:

// cache.config.ts
export interface PerCacheSettings {
  ttlMs: number;
}

export interface CacheConfig {
  host: string;
  port: number;
  caches: Record<string, PerCacheSettings>;
}
# application.yml
cache:
  host: redis
  port: 6379
  caches:
    user-profiles:
      ttl-ms: 900000       # 15m
    currencies:
      ttl-ms: 21600000     # 6h
    feature-flags:
      ttl-ms: 60000        # 60s
    user-balances:
      ttl-ms: 30000        # 30s
    order-summaries:
      ttl-ms: 300000       # 5m
    top-products:
      ttl-ms: 600000       # 10m

Типовые значения по характеру данных

R-CACHE-TTL-2: ориентируемся на природу данных, не на «короче-лучше».

Тип данныхTTLПример
Static referenceчасыcurrencies, countries, доступные страны доставки
User profile / preferences15-30 минUserProfileDto, CustomerSettingsDto
Feature flags30-60 секFeatureFlagSetDto, A/B-buckets
Configuration overrides60 секTenantConfigDto, runtime-настройки
Heavy aggregations5-10 минOrderReportDto, TopProductsDto
Money-related5-30 секCustomerBalanceDto (только с явным evict)

Логика:

  • Short TTL (секунды) — данные часто меняются, stale = ощутимая проблема для пользователя или бизнеса.
  • Medium TTL (минуты) — изменения редкие; задержка в 15 минут приемлема.
  • Long TTL (часы) — справочники, практически не меняющиеся; редкие изменения закрываются ручным evict.

TTL из CacheConfig, не хардкод

R-CACHE-TTL-3: хардкод TTL-литерала в сервисе — антипаттерн.

// ПЛОХО: TTL захардкожен, нельзя поменять без redeploy
await this.cache.set(`order-summaries:${orderId}`, dto, 300_000);

// ХОРОШО: TTL из конфига
await this.cache.set(
  `order-summaries:${orderId}`,
  dto,
  this.cfg.caches['order-summaries'].ttlMs,
);

Когда SRE замечает инциденты из-за stale OrderSummaryDto — нужна правка конфига и перезапуск пода, не изменение кода и деплой новой сборки.

TTL и invalidation — два слоя защиты

R-CACHE-TTL-4: если у данных есть естественный invalidation event — TTL может быть длиннее.

Сценарий 1 — есть событие:

// order-events.handler.ts
@OnEvent('order.confirmed')
async onOrderConfirmed(event: OrderConfirmedEvent): Promise<void> {
  await this.cache.del(`order-summaries:${event.orderId}`);
}

TTL для order-summaries — 30 минут. Invalidation срабатывает быстро, TTL — резерв на случай потерянного события.

Сценарий 2 — нет события (внешний справочник, изменения вне нашего контроля):

// Курсы валют обновляются через ночной ETL, события нет
await this.cache.set('currencies:all', currenciesDto, this.cfg.caches['currencies'].ttlMs);
// ttlMs = 21_600_000 (6 часов) — stale до 6 часов приемлемо для отображения курса

Главный принцип: invalidation первичен, TTL вторичен. Если invalidation надёжен — TTL длиннее. Если нет — TTL короче, чтобы ограничить ущерб от stale-данных.

Пример: CustomerBalance с коротким TTL

Money-данные — самый строгий случай (R-CACHE-TTL-X3):

// customer-balance.cache.adapter.ts
@Injectable()
export class CustomerBalanceCacheAdapter {
  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly cfg: CacheConfig,
  ) {}

  private key(customerId: string): string {
    return `customer-balances:${customerId}`;
  }

  async get(customerId: string): Promise<CustomerBalanceDto | null> {
    return this.cache.get<CustomerBalanceDto>(this.key(customerId));
  }

  async set(customerId: string, dto: CustomerBalanceDto): Promise<void> {
    await this.cache.set(
      this.key(customerId),
      dto,
      this.cfg.caches['customer-balances'].ttlMs,  // 5-30s из конфига
    );
  }

  async evict(customerId: string): Promise<void> {
    await this.cache.del(this.key(customerId));
  }
}
// customer-balance.service.ts
async deductBalance(cmd: DeductBalanceCommand): Promise<void> {
  await this.repo.deduct(cmd);
  await this.balanceCache.evict(cmd.customerId);   // evict после каждого write
}

async getBalance(customerId: string): Promise<CustomerBalanceDto> {
  await this.balanceCache.evict(customerId);        // evict перед критичным чтением
  const fresh = await this.repo.findBalance(customerId);
  await this.balanceCache.set(customerId, fresh);
  return fresh;
}

Два слоя: TTL ≤ 30s + явный evict на write + evict перед чтением в критичном flow.

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

АнтипаттернПравилоЧто взамен
cache.set(key, value) без TTLR-CACHE-TTL-X1cache.set(key, value, ttlMs) — всегда с третьим аргументом
TTL > 24 часов для бизнес-данныхR-CACHE-TTL-X2TTL ≤ 24h или version-suffix в ключе (user-profiles-v2:)
Money без TTL / TTL > 60s без strict invalidationR-CACHE-TTL-X3TTL ≤ 30s + явный cache.del на каждом write
TTL-литерал в коде сервисаR-CACHE-TTL-3TTL из CacheConfig, значение в application.yml
Один TTL для всех кешей в CacheModule.register({ ttl })R-CACHE-TTL-1per-cache TTL в CacheConfig
TTL без учёта характера данныхR-CACHE-TTL-2таблица типовых значений
Long TTL без invalidation eventR-CACHE-TTL-4short TTL либо надёжный evict

Куда дальше

  • Cache stampede — защита hot-ключей: single-flight и redlock, чтобы одновременные cache miss не обрушили БД.
  • Конфигурация — CacheModule.registerAsync, CacheConfig, fail-fast без Redis-backend.
  • Invalidation — cache.del на write, @OnEvent-handler, Promise.all для нескольких кешей.
  • Ключи — namespace-префикс, explicit-ключ, sensitive-данные.
  • Observability — hit/miss-счётчики через prom-client, alert на hit rate.
  • Паттерны — cache-aside, write-through, refresh-ahead (@Interval) для hot-ключей.
  • Где кешируем — что кешировать: read-проекции, не агрегаты; money-rules.