Опирается на правила:
R-CACHE-INV-1…R-CACHE-INV-4иR-CACHE-INV-X1…R-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() в продакшн-handler | R-CACHE-INV-X1 | cache.del(key) точечно |
| Только TTL для money/orders | R-CACHE-INV-X2 | cache.del на каждом write |
Eventual consistency без @ApiOperation декларации | R-CACHE-INV-X3 | description: 'Возможна задержка...' |
| Ключ eviction не совпадает с ключом set | R-CACHE-INV-1 | один билдер ключа на set и del |
Последовательный await cache.del для нескольких кешей | R-CACHE-INV-2 | Promise.all([cache.del(...), cache.del(...)]) |
| Дублирование evict-логики в каждом handler | R-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.