Опирается на правила:
R-CACHE-PATTERN-1…R-CACHE-PATTERN-3иR-CACHE-PATTERN-X1…R-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/critical | R-CACHE-PATTERN-X1 | write-through или cache-aside + repo.save |
| Mix write-through и cache-aside на одном namespace | R-CACHE-PATTERN-X2 | один namespace = один паттерн |
Refresh-ahead для unbounded keys (customer-profiles) | R-CACHE-PATTERN-3 | cache-aside для всех |
| Cache-aside с критичной latency для hot keys | R-CACHE-PATTERN-3 | refresh-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/DELETE | R-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в кеш-порте.