Опирается на правила: R-CACHE-PATTERN-1R-CACHE-PATTERN-3 и R-CACHE-PATTERN-X1R-CACHE-PATTERN-X2 из Caching Style Guide → раздел 6. Паттерны.

Важно знать

  • Cache-aside (lazy load + evict на write) — дефолтный паттерн UCP. Явные cache.get / cache.set / cache.del в сервисе через кеш-порт.
  • Write-through — явный cache.set на write вместо cache.del: кеш сразу актуален после write, следующий read получает из кеша без промаха.
  • Refresh-ahead через @Interval (@nestjs/schedule) — для критичных hot keys (главная страница, top-100 продуктов). Фоновое обновление до истечения TTL, cache miss исключён по дизайну.
  • Write-behind (запись в кеш, async flush в БД) — запрещён для money/critical: crash сервиса до flush = потеря данных.
  • Один cache-namespace = один паттерн. Cache-aside и write-through в разных методах на одном namespace = непредсказуемая invalidation logic.
  • В NestJS нет @Cacheable/@CacheEvict — паттерн реализуется явно в сервисе через кеш-порт (CACHE_PORT), не через CacheInterceptor (тот только для простых HTTP-GET).
  • CacheInterceptor ставит cache-aside автоматически по URL — для доменной логики кеш-порт читаемее и тестируемее.

Три паттерна — это три точки на спектре «свежесть vs производительность». Cache-aside — простой компромисс; write-through — больше кода, но кеш всегда актуален; refresh-ahead — максимальная защита от cache miss ценой фоновой нагрузки.

Cache-aside — дефолт

R-CACHE-PATTERN-1: read через cache.get → miss → load → cache.set с TTL; write — cache.del.

GET → cache.get(key)
         │
         ├── HIT  → return cached value
         │
         └── MISS → load from DB → cache.set(key, dto, ttl) → return

POST/PATCH/DELETE → DB write → cache.del(key) → следующий read загрузит свежее

Реализация через кеш-порт:

// core/ports/out/cache.port.ts
export const CACHE_PORT = Symbol('CACHE_PORT');

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>;
}
// core/use-cases/get-order-summary.handler.ts
@Injectable()
export class GetOrderSummaryHandler {
  constructor(
    private readonly orderRepo: OrderRepository,
    @Inject(CACHE_PORT) private readonly cache: CachePort,
    private readonly cfg: CacheConfig,
  ) {}

  async handle(query: GetOrderSummaryQuery): Promise<OrderSummaryDto> {
    const key = `order-summaries:${query.orderId}`;
    const hit = await this.cache.get<OrderSummaryDto>(key);
    if (hit) return hit;

    const summary = await this.orderRepo.findSummaryById(query.orderId);
    await this.cache.set(key, summary, this.cfg.orderSummariesTtlMs);
    return summary;
  }
}
// core/use-cases/confirm-order.handler.ts
@Injectable()
export class ConfirmOrderHandler {
  constructor(
    private readonly orderRepo: OrderRepository,
    @Inject(CACHE_PORT) private readonly cache: CachePort,
  ) {}

  async handle(cmd: ConfirmOrderCommand): Promise<void> {
    await this.orderRepo.confirm(cmd.orderId);
    await this.cache.del(`order-summaries:${cmd.orderId}`);
  }
}

Свойства:

  • Читаемый код. Явный get/set/del без магии декораторов.
  • Кеш отстаёт от БД на TTL — write делает evict, но между write одного экземпляра и read другого возможна гонка. Для большинства сценариев это приемлемо.
  • Холодный старт затратный. После рестарта или массового del все read'ы — cache miss.
  • Graceful degradation при отказе Redis. Если Redis недоступен — сервис продолжает работу напрямую через БД с ухудшением latency.

Подходит для подавляющего большинства сценариев. Если нет конкретной причины выбрать другой паттерн — cache-aside.

Write-through

R-CACHE-PATTERN-2: явный cache.set на write вместо cache.del — кеш обновляется значением, которое только что записано.

// core/use-cases/update-product.handler.ts
@Injectable()
export class UpdateProductHandler {
  constructor(
    private readonly productRepo: ProductRepository,
    @Inject(CACHE_PORT) private readonly cache: CachePort,
    private readonly cfg: CacheConfig,
  ) {}

  async handle(cmd: UpdateProductCommand): Promise<ProductDto> {
    const product = await this.productRepo.update(cmd);
    const dto = ProductDto.from(product);
    await this.cache.set(
      `product-catalog:${product.id}`,
      dto,
      this.cfg.productCatalogTtlMs,
    );
    return dto;
  }
}

Когда применять:

  • High read + write ratio одного значения с continuation. После updateProduct тот же пользователь сразу вызывает getProduct — write-through даёт результат мгновенно без DB-round-trip.
  • Метод возвращает финальный DTO, который нужно закешировать — метод не void, не id.

Когда не применять:

  • Write меняет состояние через несколько сервисов (saga, Kafka chain). Write-through на одном шаге = неконсистентность с остальными участниками.
  • Метод не возвращает финальное закешированное значение (void / только id).

Refresh-ahead

R-CACHE-PATTERN-3: фоновый job перезаливает hot keys до истечения TTL.

// adapters/out/cache/top-products-refresh.job.ts
@Injectable()
export class TopProductsRefreshJob implements OnApplicationBootstrap {
  constructor(
    private readonly productRepo: ProductRepository,
    @Inject(CACHE_PORT) private readonly cache: CachePort,
    private readonly cfg: CacheConfig,
  ) {}

  onApplicationBootstrap(): void {
    this.refresh().catch(() => {});
  }

  @Interval(30_000)
  async refresh(): Promise<void> {
    const top = await this.productRepo.findTop100Active();
    await this.cache.set(
      'product-catalog:top-100',
      top,
      this.cfg.topProductsTtlMs,
    );
  }
}

onApplicationBootstrap прогревает кеш сразу при старте — первый пользователь не платит за cold miss.

Свойства:

  • No cache miss для hot keys. Кеш всегда заполнен; пользователи никогда не платят latency «load from DB».
  • Постоянная нагрузка на БД. Каждые 30 секунд независимо от read-нагрузки.
  • Подходит для известных hot keys. Главная страница, top-100 продуктов, dashboard — заранее знаем ключи.
  • Не подходит для unbounded keys. Если ключей миллион (customer-profiles) — refresh-ahead невозможен по определению.

Комбо: refresh-ahead для hot + cache-aside для остального — стандартная стратегия для высоконагруженных систем.

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

Write-behind для money/critical

R-CACHE-PATTERN-X1: write в кеш, async flush в БД позже.

1. Write → cache.set (быстро)
2. Background job → flush Redis → DB (eventually)
3. Crash сервиса между шагами 1 и 2 → данные потеряны

Для money это потеря средств. Для orders — OrderConfirmed событие не попало в БД, downstream не уведомлён. Для аналитики (где потеря 1% событий приемлема) — write-behind допустим, но в UCP-сервисах его нет.

// КАТАСТРОФА для денежного баланса
async debitBalance(customerId: number, amount: Money): Promise<void> {
  const updated = computeNewBalance(customerId, amount);
  await this.cache.set(`customer-balances:${customerId}`, updated, 30_000);
  // нет await this.balanceRepo.save(...)
}

Money/orders/critical — всегда write-through или cache-aside с немедленным repo.save.

Mix паттернов на одном namespace

R-CACHE-PATTERN-X2: один namespace customer-profiles обслуживается разными паттернами.

// ПЛОХО — один namespace, разные паттерны
async updateEmail(cmd: UpdateEmailCommand): Promise<CustomerDto> {
  const dto = await this.repo.updateEmail(cmd);
  await this.cache.set(`customer-profiles:${cmd.customerId}`, dto, ttl); // write-through
  return dto;
}

async updatePhone(cmd: UpdatePhoneCommand): Promise<void> {
  await this.repo.updatePhone(cmd);
  await this.cache.del(`customer-profiles:${cmd.customerId}`); // cache-aside evict
}

Проблема: невозможно понять «что в кеше после write на эту запись». После updateEmail — актуальный dto; после updatePhone — miss, следующий read загрузит из БД. Тесты на консистентность ломаются. Один namespace = один паттерн. Если нужны разные — два namespace.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Write-behind для money/criticalR-CACHE-PATTERN-X1write-through или cache-aside + repo.save
Mix write-through и cache-aside на одном namespaceR-CACHE-PATTERN-X2один namespace = один паттерн
Refresh-ahead для unbounded keys (customer-profiles)R-CACHE-PATTERN-3cache-aside для всех
Cache-aside с критичной latency для hot keysR-CACHE-PATTERN-3refresh-ahead для hot
Write-through с void-методомR-CACHE-PATTERN-2вернуть ProductDto, не void
@Interval refresh без cache.set внутриR-CACHE-PATTERN-3явный cache.set(key, value, ttl)
CacheInterceptor на POST/PATCH/DELETER-CACHE-WHERE-X1кеш только на GET-эндпоинтах

Куда дальше

  • Cache stampede — refresh-ahead решает stampede по дизайну; redlock для Redis.
  • Invalidation — cache.del для cache-aside: один write — один del.
  • TTL — refresh-ahead делает TTL «страховкой», а не основным механизмом.
  • Где кешируем — какие данные подходят под какой паттерн.
  • Конфигурация — CacheModule, ioredis-store, per-cache TTL из конфига.
  • Ключи — namespace-префикс, explicit key-builder, kebab-case.
  • Observability — hit/miss-метрики через prom-client в кеш-порте.