Опирается на правила:
R-CACHE-TTL-1…R-CACHE-TTL-4иR-CACHE-TTL-X1…R-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 при достиженииmaxmemoryevict-ит по 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 / preferences | 15-30 мин | UserProfileDto, CustomerSettingsDto |
| Feature flags | 30-60 сек | FeatureFlagSetDto, A/B-buckets |
| Configuration overrides | 60 сек | TenantConfigDto, runtime-настройки |
| Heavy aggregations | 5-10 мин | OrderReportDto, TopProductsDto |
| Money-related | 5-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) без TTL | R-CACHE-TTL-X1 | cache.set(key, value, ttlMs) — всегда с третьим аргументом |
| TTL > 24 часов для бизнес-данных | R-CACHE-TTL-X2 | TTL ≤ 24h или version-suffix в ключе (user-profiles-v2:) |
| Money без TTL / TTL > 60s без strict invalidation | R-CACHE-TTL-X3 | TTL ≤ 30s + явный cache.del на каждом write |
| TTL-литерал в коде сервиса | R-CACHE-TTL-3 | TTL из CacheConfig, значение в application.yml |
Один TTL для всех кешей в CacheModule.register({ ttl }) | R-CACHE-TTL-1 | per-cache TTL в CacheConfig |
| TTL без учёта характера данных | R-CACHE-TTL-2 | таблица типовых значений |
| Long TTL без invalidation event | R-CACHE-TTL-4 | short 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.