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

Важно знать

  • На каждом write-методе того же ресурса — await cache.del(key) с явным ключом.
  • Несколько кешейawait Promise.all([cache.del(...), cache.del(...)]).
  • Доменные события@OnEvent('order.confirmed') делает evict независимо от того, какой use case вызвал изменение.
  • Distributed invalidation в Redis backend — встроено: общий Redis на все инстансы; pod-2 видит DEL pod-1 немедленно.
  • cache.reset() — только при админских операциях (rebuild). В продакшн-handlers — никогда.
  • Только TTL для consistency — приемлемо для feature-flags, неприемлемо для money/orders.
  • Eventual consistency без декларации в OpenAPI (@ApiOperation) — нарушение контракта.

Invalidation — единственная защита от stale-data. TTL даёт верхнюю границу задержки; invalidation делает задержку нулевой. Без invalidation кеш — «обещаем устаревшую правду на N минут».

cache.del на write-методе

R-CACHE-INV-1: каждый write — await cache.del соответствующего ключа.

// adapters/out/cache/order-cache.port.ts
export const ORDER_CACHE_PORT = Symbol('ORDER_CACHE_PORT');

export interface OrderCachePort {
  getOrderSummary(orderId: string): Promise<OrderSummaryDto | null>;
  setOrderSummary(orderId: string, dto: OrderSummaryDto): Promise<void>;
  evictOrderSummary(orderId: string): Promise<void>;
}
// adapters/out/cache/order-cache.adapter.ts
@Injectable()
export class OrderCacheAdapter implements OrderCachePort {
  private readonly ns = 'order-summaries';

  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly cfg: CacheConfig,
  ) {}

  private key(orderId: string): string {
    return `${this.ns}:${orderId}`;
  }

  async getOrderSummary(orderId: string): Promise<OrderSummaryDto | null> {
    return this.cache.get<OrderSummaryDto>(this.key(orderId)) ?? null;
  }

  async setOrderSummary(orderId: string, dto: OrderSummaryDto): Promise<void> {
    await this.cache.set(this.key(orderId), dto, this.cfg.orderSummariesTtlMs);
  }

  async evictOrderSummary(orderId: string): Promise<void> {
    await this.cache.del(this.key(orderId));
  }
}
// use-cases/confirm-order.handler.ts
@Injectable()
export class ConfirmOrderHandler implements UseCaseHandler<ConfirmOrderCommand, void> {
  constructor(
    private readonly repo: OrderRepository,
    @Inject(ORDER_CACHE_PORT) private readonly orderCache: OrderCachePort,
  ) {}

  async handle(cmd: ConfirmOrderCommand): Promise<void> {
    const order = await this.repo.findById(cmd.orderId);
    order.confirm();
    await this.repo.save(order);
    await this.orderCache.evictOrderSummary(cmd.orderId);   // R-CACHE-INV-1
  }
}

Ключ eviction обязан совпадать с ключом, по которому set клал данные; иначе invalidation промахивается мимо реального entry.

cache.del вызывается после успешного сохранения. Если repo.save() бросает исключение — evict не происходит, кеш остаётся с прежним (корректным) значением.

Promise.all для нескольких кешей

R-CACHE-INV-2: одно изменение может затрагивать несколько проекций.

// use-cases/update-product.handler.ts
@Injectable()
export class UpdateProductHandler implements UseCaseHandler<UpdateProductCommand, void> {
  constructor(
    private readonly repo: ProductRepository,
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly cfg: CacheConfig,
  ) {}

  async handle(cmd: UpdateProductCommand): Promise<void> {
    const product = await this.repo.findById(cmd.productId);
    product.applyUpdate(cmd);
    await this.repo.save(product);

    await Promise.all([                                                          // R-CACHE-INV-2
      this.cache.del(`product-summaries:${cmd.productId}`),
      this.cache.del(`product-catalog:category:${product.categoryId}`),
    ]);
  }
}

Promise.all параллелит оба DEL-запроса к Redis — меньше задержка, чем последовательно.

@OnEvent — invalidation как side-effect события

R-CACHE-INV-3: паттерн «invalidation as side-effect of domain event».

// adapters/in/events/customer-cache-invalidation.listener.ts
@Injectable()
export class CustomerCacheInvalidationListener {
  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
  ) {}

  @OnEvent('customer.updated')
  async onCustomerUpdated(event: CustomerUpdatedEvent): Promise<void> {
    await this.cache.del(`customer-profiles:${event.customerId}`);
  }

  @OnEvent('customer.deleted')
  async onCustomerDeleted(event: CustomerDeletedEvent): Promise<void> {
    await Promise.all([
      this.cache.del(`customer-profiles:${event.customerId}`),
      this.cache.del(`customer-payment-methods:${event.customerId}`),
    ]);
  }
}

Почему так:

  • Decoupling. UpdateCustomerHandler не знает, какие кеши существуют. Listener — единое место сброса для всего, что относится к Customer.
  • Множественные источники. CustomerUpdatedEvent может прийти от UpdateCustomerHandler, VerifyKycHandler, SberIdLinkHandler — listener срабатывает всегда.
  • Kafka / внешний брокер. Если событие приходит из Kafka-consumer — тот же EventEmitter2 или прямой вызов listener'а; логика eviction не дублируется.
// adapters/in/kafka/customer-events.consumer.ts
@Injectable()
export class CustomerEventsConsumer {
  constructor(
    private readonly eventEmitter: EventEmitter2,
  ) {}

  @MessagePattern('customer.events')
  async handle(message: CustomerEvent): Promise<void> {
    if (message.type === 'UPDATED') {
      await this.eventEmitter.emitAsync('customer.updated', new CustomerUpdatedEvent(message.customerId));
    }
  }
}

Distributed cache invalidation — встроено

R-CACHE-INV-4: Redis — общий backend для всех инстансов.

pod-1: confirmOrder → cache.del('order-summaries:ord-789') → Redis DEL
pod-2: next read  → cache.get('order-summaries:ord-789') → miss → load from DB → cache.set

Когда pod-1 удаляет ключ, pod-2 видит miss немедленно при следующем запросе. Никакого Redis pub/sub для синхронизации invalidation между подами не нужно — общий Redis решает задачу сам.

Исключение: L1+L2 multi-tier кеш (in-memory Map в pod + Redis). В этом случае DEL в Redis не обнулит локальный Map другого пода — нужен отдельный pub/sub канал инвалидации L1. В UCP-сервисах этот случай не является стандартным.

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

cache.reset() в продакшн-handlers

R-CACHE-INV-X1: сброс всего кеша без причины.

// ОПАСНО
async updateProduct(cmd: UpdateProductCommand): Promise<void> {
  await this.repo.save(/* ... */);
  await this.cache.reset();   // сбрасывает ВСЁ
}

Сценарий: 50 000 записей в кеше, нагрузка 2000 RPS. Один write очищает всё → следующие 50 000 уникальных reads — cache miss → 50 000 запросов в БД спайком → перегрузка → cascading failures.

cache.reset() допустим только при административных операциях:

  • Принудительный rebuild кеша после миграции данных.
  • DEV-окружение, ручная отладка.

В продакшн-handlers — всегда cache.del(key) с явным ключом.

Только TTL для money/orders

R-CACHE-INV-X2: «подождёт минуту и само обновится».

ДанныеТолько TTL — ок?
Feature flagsДа — задержка 60 сек терпима
Справочники валютДа — данные меняются редко
Профиль пользователяСпорно — пользователь видит старые данные после сохранения
Order summariesНет — покупатель видит старый статус заказа
Баланс счёта SberНет — money, любое stale = инцидент

Для money/orders — cache.del обязателен, TTL — только резервный барьер.

Eventual consistency без декларации

R-CACHE-INV-X3: если endpoint может вернуть stale-data — это часть контракта, документируется в OpenAPI.

// orders.controller.ts
@Get(':orderId/summary')
@ApiOperation({
  summary: 'Получить сводку заказа',
  description:
    'Возвращает проекцию из кеша. ' +
    '**Eventual consistency**: после обновления заказа возможна задержка до 30 секунд ' +
    '(TTL кеша) до отражения изменений.',
})
async getOrderSummary(@Param('orderId') orderId: string): Promise<OrderSummaryDto> {
  return this.orderQueryService.getOrderSummary(orderId);
}

Без декларации разработчик на стороне клиента пишет тест POST /orders + immediate GET /orders/:id/summary, получает stale и открывает инцидент — хотя это архитектурный выбор, не баг.

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

АнтипаттернПравилоЧто взамен
cache.reset() в продакшн-handlerR-CACHE-INV-X1cache.del(key) точечно
Только TTL для money/ordersR-CACHE-INV-X2cache.del на каждом write
Eventual consistency без @ApiOperation декларацииR-CACHE-INV-X3description: 'Возможна задержка...'
Ключ eviction не совпадает с ключом setR-CACHE-INV-1один билдер ключа на set и del
Последовательный await cache.del для нескольких кешейR-CACHE-INV-2Promise.all([cache.del(...), cache.del(...)])
Дублирование evict-логики в каждом handlerR-CACHE-INV-3@OnEvent-listener как единое место
Redis pub/sub для инвалидации между подамиR-CACHE-INV-4встроено в общий Redis-backend

Куда дальше

  • Cache stampede — single-flight и redlock при cache miss.
  • Конфигурация — CacheModule.registerAsync, per-cache TTL из конфига.
  • Ключи — explicit namespace-ключи, хеширование sensitive.
  • Observability — hit/miss-метрики через prom-client.
  • Паттерны — cache-aside, write-through, refresh-ahead.
  • TTL — TTL как резервный барьер под invalidation.
  • Где кешируем — read-проекции, не агрегаты; money — короткий TTL + evict.