Опирается на правила:
R-CACHE-KEY-1…R-CACHE-KEY-4иR-CACHE-KEY-X1…R-CACHE-KEY-X4из Caching Style Guide → раздел 3. Ключи.
Важно знать
cache-managerне префиксует сам — namespace-префикс зашивается в кеш-порт или в функцию-билдер ключей вручную.- Имена кешей в kebab-case slug:
user-profiles,order-summaries,feature-flags. Не camelCase, не snake_case.- Ключ всегда explicit: шаблонная строка
`user-profiles:${userId}`, неString(dto)и неJSON.stringify(dto).CacheInterceptorпри HTTP-GET использует URL как ключ — для service-методов ключ указывается явно в коде.JSON.stringify(dto)целого объекта в ключе ненадёжен: порядок полей не гарантирован, ключ нестабилен.- Composite-ключ — строки через
:(Redis-конвенция вложенных namespace):`orders-by-customer:${customerId}:${status}`.- Sensitive данные в ключе (email, phone, токен) — хешировать через
createHash('sha256'), не plain.- Один кеш на один тип данных — общий
shared-cacheломает TTL, метрики и invalidation.
Ключ — адрес, по которому Redis находит значение. Плохой ключ даёт hit rate = 0% (новый ключ на каждый вызов), непредсказуемую invalidation и утечку чувствительных данных в логи. UCP требует, чтобы ключ был predictable, readable, secure.
Namespace через префикс имени кеша
R-CACHE-KEY-1: в отличие от Spring, @nestjs/cache-manager не добавляет префикс автоматически. Namespace реализуется через функцию-билдер ключей в кеш-порте.
// adapters/out/cache/user-profile-cache.adapter.ts
export class UserProfileCacheAdapter implements UserProfileCachePort {
private key(userId: number): string {
return `user-profiles:${userId}`;
}
async get(userId: number): Promise<UserProfileDto | null> {
return this.cache.get<UserProfileDto>(this.key(userId)) ?? null;
}
async set(userId: number, dto: UserProfileDto, ttlMs: number): Promise<void> {
await this.cache.set(this.key(userId), dto, ttlMs);
}
async del(userId: number): Promise<void> {
await this.cache.del(this.key(userId));
}
}
В Redis ключ выглядит так: user-profiles:42. Что это даёт:
- Логическое разделение.
KEYS user-profiles:*— все user-profile записи. - Evict-by-namespace.
cache.del('user-profiles:42')— точечно; при необходимости сброса всего namespace — по паттерну черезioredis(SCAN+DEL). - Метрики per-namespace. Счётчики
hits/missesв порте инкрементируются с тегомcache: 'user-profiles'.
Kebab-case slug для имени кеша
R-CACHE-KEY-2: имена — kebab-case, не camelCase.
user-profiles ✓
order-summaries ✓
payment-methods ✓
feature-flags ✓
product-catalogue ✓
userProfiles ✗ — camelCase
UserProfiles ✗ — PascalCase
user_profiles ✗ — snake_case
Соглашение: ключи Redis в UCP — slug-формата, как URL-пути и event-топики. Это даёт консистентность между user-profiles:42 (кеш), /user-profiles/42 (API) и user-profile.updated (событие).
Explicit-билдер ключа
R-CACHE-KEY-3: ключ строится явной шаблонной строкой — никаких «весь объект целиком».
Один параметр:
// core/ports/out/order-summary-cache.port.ts
export interface OrderSummaryCachePort {
get(orderId: string): Promise<OrderSummaryDto | null>;
set(orderId: string, dto: OrderSummaryDto, ttlMs: number): Promise<void>;
del(orderId: string): Promise<void>;
}
// adapters/out/cache/order-summary-cache.adapter.ts
private key(orderId: string): string {
return `order-summaries:${orderId}`;
}
Composite-ключ:
// Заказы клиента по статусу — два измерения в ключе
private key(customerId: number, status: OrderStatus): string {
return `orders-by-customer:${customerId}:${status}`;
}
async get(customerId: number, status: OrderStatus): Promise<OrderSummaryDto[] | null> {
return this.cache.get<OrderSummaryDto[]>(this.key(customerId, status)) ?? null;
}
В Redis: orders-by-customer:42:CONFIRMED. Разделитель : — Redis-конвенция для вложенных namespace.
Объект-параметр — извлекаем нужные поля явно:
// Поиск продуктов по критериям — только значимые поля
private key(criteria: ProductSearchCriteria): string {
return `product-search:${criteria.categoryId}:${criteria.minPrice}:${criteria.maxPrice}:${criteria.page}`;
}
Никогда JSON.stringify(criteria) — порядок полей в объекте не гарантирован движком, ключ нестабилен между вызовами.
Custom key-builder для сложных случаев
R-CACHE-KEY-4: когда полей 4+ или нужна нормализация (lowercase, сортировка массивов, обрезка дат) — выносим в отдельную функцию-билдер.
// adapters/out/cache/customer-orders-key.builder.ts
export class CustomerOrdersKeyBuilder {
build(criteria: CustomerOrderSearchCriteria): string {
const tags = [...criteria.tags].sort().join(',');
const from = criteria.fromDate.toISODate();
const to = criteria.toDate.toISODate();
return `customer-orders:${criteria.customerId}:${criteria.status}:${from}:${to}:${criteria.page}:${tags}`;
}
}
// adapters/out/cache/customer-orders-cache.adapter.ts
@Injectable()
export class CustomerOrdersCacheAdapter implements CustomerOrdersCachePort {
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly keyBuilder: CustomerOrdersKeyBuilder,
) {}
async get(criteria: CustomerOrderSearchCriteria): Promise<CustomerOrderSummary[] | null> {
return this.cache.get<CustomerOrderSummary[]>(this.keyBuilder.build(criteria)) ?? null;
}
}
Когда применять: 4+ полей, нужна нормализация входных данных, или ключ нужен в нескольких местах (единая точка изменения).
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
JSON.stringify(dto) целого объекта в ключе | R-CACHE-KEY-X2 | шаблонная строка с явными полями |
String(dto) / dto.toString() (даёт [object Object]) | R-CACHE-KEY-X2 | шаблонная строка с явными полями |
Автоключ CacheInterceptor на service-методах | R-CACHE-KEY-X1 | explicit-ключ в кеш-порте |
Один shared-cache для разных entity | R-CACHE-KEY-X3 | per-entity namespace |
| Email / phone / токен в ключе plain-text | R-CACHE-KEY-X4 | createHash('sha256') |
| camelCase / snake_case имя кеша | R-CACHE-KEY-2 | kebab-case slug |
| Нестабильный порядок полей в composite-ключе | R-CACHE-KEY-3 | сортировка перед сборкой ключа |
Отсутствие namespace-префикса (ключ 42 без контекста) | R-CACHE-KEY-1 | user-profiles:42 |
Один общий shared-cache
// ПЛОХО — один кеш для разных entity
async getUser(userId: number): Promise<UserProfileDto | null> {
return this.cache.get<UserProfileDto>(`shared:user:${userId}`);
}
async getOrder(orderId: string): Promise<OrderSummaryDto | null> {
return this.cache.get<OrderSummaryDto>(`shared:order:${orderId}`);
}
Проблемы:
- TTL общий —
UserProfileDtoиOrderSummaryDtoне могут иметь разное время жизни. - Метрики смешанные — hit/miss по типу данных не разделить.
cache.del('shared:*')при сбросе — удаляет всё.
// ХОРОШО — per-entity namespace
private userKey(userId: number) { return `user-profiles:${userId}`; }
private orderKey(orderId: string) { return `order-summaries:${orderId}`; }
Plain PII/токенов в ключе
R-CACHE-KEY-X4: Redis-ключи видны в redis-cli MONITOR, slow query log, сетевом трафике при non-TLS.
// ПЛОХО — email в ключе виден в логах
async getByEmail(email: string): Promise<UserProfileDto | null> {
return this.cache.get<UserProfileDto>(`user-by-email:${email}`);
}
// ХОРОШО — хешируем email
import { createHash } from 'node:crypto';
private emailKey(email: string): string {
const hash = createHash('sha256').update(email).digest('hex');
return `user-by-email:${hash}`;
}
Лучше — не использовать sensitive значения как идентификаторы вообще. Внутренний customerId (BIGINT) идеален: ни в каком смысле не является персональными данными.
Куда дальше
- Cache stampede — single-flight и redlock при cache miss.
- Конфигурация — per-cache TTL из конфига, ioredis-store, fail-fast.
- Invalidation —
cache.delна write и@OnEvent-обработчики. - Observability — hit/miss-счётчики через
prom-client. - Паттерны — cache-aside, write-through, refresh-ahead.
- TTL — explicit TTL на каждый
cache.set, типовые значения. - Где кешируем — какие данные кешировать, read-проекции вместо агрегатов.