Опирается на правила:
R-CACHE-OBS-1…R-CACHE-OBS-4иR-CACHE-OBS-X1из Caching Style Guide → раздел 8. Observability.
Важно знать
cache-managerне экспортирует метрики автоматически — hits/misses/evictions нужно считать вручную в кеш-порте черезprom-client.- Hit rate =
hits / (hits + misses)— основная метрика здоровья кеша; алерт при < 70% для долго существующих кешей.- Eviction (вызов
cache.del) логировать наDEBUG, неinfo— на write-intensive нагрузке INFO даёт сотни строк в секунду.- Redis-side состояние (memory pressure, cluster health, replication lag) — через отдельный Redis Exporter, не через
cache-manager.- Счётчики — per-cache: метка
cacheна каждом Counter, иначе нельзя диагностировать, какой именно кеш просел.- Без метрик SRE не увидит hit rate 5% и не узнает, что кеш бесполезен (
R-CACHE-OBS-X1).- Низкий hit rate — симптом: неправильный TTL, слишком частая invalidation, или кешируются уникальные ключи.
Кеш без observability — это вера в то, что он работает. Hit rate 5% означает «развернули Redis, платим за него, а толку нет» — и узнаёшь об этом только случайно.
Метрики в кеш-порте через prom-client
R-CACHE-OBS-1: cache-manager не имеет встроенного экспорта в Prometheus. Метрики регистрируются вручную в кеш-порте — единственном месте, где происходят все get/set/del.
// adapters/out/cache/cache-metrics.ts
import { Counter } from 'prom-client';
export const cacheHits = new Counter({
name: 'cache_gets_total',
help: 'Total cache get operations',
labelNames: ['cache', 'result'] as const,
});
export const cachePuts = new Counter({
name: 'cache_puts_total',
help: 'Total cache put operations',
labelNames: ['cache'] as const,
});
export const cacheEvictions = new Counter({
name: 'cache_evictions_total',
help: 'Total cache eviction operations',
labelNames: ['cache'] as const,
});
// adapters/out/cache/order-cache.adapter.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { OrderSummaryDto } from '@/core/order/dto/order-summary.dto';
import { cacheHits, cachePuts, cacheEvictions } from './cache-metrics';
const CACHE_NAME = 'order-summaries';
@Injectable()
export class OrderCacheAdapter {
private readonly logger = new Logger(OrderCacheAdapter.name);
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
async get(orderId: string): Promise<OrderSummaryDto | null> {
const key = `${CACHE_NAME}:${orderId}`;
const hit = await this.cache.get<OrderSummaryDto>(key);
if (hit) {
cacheHits.inc({ cache: CACHE_NAME, result: 'hit' });
return hit;
}
cacheHits.inc({ cache: CACHE_NAME, result: 'miss' });
return null;
}
async set(orderId: string, dto: OrderSummaryDto, ttlMs: number): Promise<void> {
const key = `${CACHE_NAME}:${orderId}`;
await this.cache.set(key, dto, ttlMs);
cachePuts.inc({ cache: CACHE_NAME });
}
async del(orderId: string): Promise<void> {
const key = `${CACHE_NAME}:${orderId}`;
await this.cache.del(key);
cacheEvictions.inc({ cache: CACHE_NAME });
this.logger.debug(`Cache evict: cache=${CACHE_NAME} key=${key}`);
}
}
Аналогично для product-summaries, customer-profiles — каждый адаптер инкрементирует свой Counter с меткой cache.
Hit rate — главная метрика
R-CACHE-OBS-2: PromQL для расчёта hit rate по каждому кешу.
sum by (cache) (rate(cache_gets_total{result="hit"}[5m]))
/
sum by (cache) (rate(cache_gets_total[5m]))
Алерт в Alertmanager:
- alert: CacheHitRateLow
expr: |
(
sum by (cache, service) (rate(cache_gets_total{result="hit"}[1h]))
/
sum by (cache, service) (rate(cache_gets_total[1h]))
) < 0.7
for: 30m
labels:
severity: warning
annotations:
summary: "Cache {{ $labels.cache }} hit rate < 70% в {{ $labels.service }}"
runbook: https://runbooks.internal/cache-low-hit-rate
Hit rate < 70% — симптом одной из проблем:
- TTL слишком короткий.
order-summariesистекает за 30 секунд, а повторное чтение того же заказа через минуту — уже miss. Увеличить TTL. - Частая invalidation + длинный TTL. Каждый
PATCH /products/:idделаетcache.del— кешproduct-summariesбольшую часть времени пуст. Переосмыслить паттерн: write-through вместо evict, или сократить область invalidation. - Ключи unbounded. Поиск с детальными фильтрами (
customer-search?name=Ива&city=Москва&page=3) — каждый запрос уникален, cache-aside не помогает. Убрать кеш или сделать менее гранулярным. - Кеш на редко читаемых данных. Если ratio read/write < 10:1 — кеш приносит больше накладных расходов, чем экономии. Отключить.
Runbook должен описывать процедуру: смотреть на cache_gets_total, cache_puts_total, cache_evictions_total в Grafana, проверить TTL в конфиге, сопоставить с частотой записи.
Eviction — DEBUG, не info
R-CACHE-OBS-3: каждый cache.del потенциально срабатывает при каждом write. Для order-summaries при высокой нагрузке это сотни событий в секунду. Логирование на info убьёт читаемость логов в ELK.
// PREFER: DEBUG с ключом
this.logger.debug(`Cache evict: cache=${CACHE_NAME} key=${key}`);
// AVOID: информационный шум
this.logger.log(`Evicted cache entry for order ${orderId}`);
this.logger.warn(`Cache cleared for customer ${customerId}`);
DEBUG включается per-environment при инциденте: в .env выставить LOG_LEVEL=debug или через динамический уровень NestJS Logger — тогда видно, инвалидировался ли кеш после конкретного write.
Экспорт метрик через PrometheusModule
Регистрация prom-client в NestJS через @willsoto/nestjs-prometheus:
// app.module.ts
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
@Module({
imports: [
PrometheusModule.register({ defaultMetrics: { enabled: true } }),
CacheModule.registerAsync({ ... }),
],
})
export class AppModule {}
GET /metrics возвращает все счётчики в формате Prometheus. Теги service, env, version добавляются через defaultLabels:
PrometheusModule.register({
defaultMetrics: { enabled: true },
defaultLabels: {
service: process.env.SERVICE_NAME,
env: process.env.NODE_ENV,
},
})
Redis-side метрики
R-CACHE-OBS-4: cache-manager видит только Node-side (hits/misses). Redis-side состояние — память, cluster health, replication lag — мониторится через отдельный Redis Exporter.
| Метрика | Что показывает |
|---|---|
redis_up | Доступность Redis |
redis_memory_used_bytes | Использованная память |
redis_memory_max_bytes | Лимит (maxmemory) |
redis_keys_total{db} | Количество ключей |
redis_evicted_keys_total | Ключей evicted Redis-ом по LRU/LFU |
redis_cluster_state | Health в cluster mode |
redis_master_replication_lag_seconds | Задержка репликации |
Алерт на redis_memory_used_bytes / redis_memory_max_bytes > 0.9 — Redis близко к принудительному eviction. Алерт на rate(redis_evicted_keys_total[5m]) > 0 при политике noeviction — что-то не так с расчётом TTL или объёмом данных.
Конфигурация Redis Exporter в docker-compose для локального окружения:
redis-exporter:
image: oliver006/redis_exporter:latest
environment:
REDIS_ADDR: redis://redis:6379
ports:
- "9121:9121"
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Нет hit/miss-счётчиков в кеш-порте | R-CACHE-OBS-X1 | Counter через prom-client в каждом адаптере |
Eviction на logger.log / logger.warn | R-CACHE-OBS-3 | logger.debug с ключом |
| Нет алерта на hit rate < 70% | R-CACHE-OBS-2 | PromQL-алерт for: 30m с runbook |
| Только Node-side метрики, без Redis Exporter | R-CACHE-OBS-4 | Redis Exporter рядом с Redis |
Счётчик без метки cache (общий для всех кешей) | R-CACHE-OBS-1 | labelNames ['cache', 'result'] per-cache |
Hit rate как avg за период, не rate | R-CACHE-OBS-2 | rate(cache_gets_total[5m]) |
Куда дальше
- Cache stampede — single-flight и redlock для защиты hot-ключей.
- Конфигурация —
CacheModule.registerAsync, fail-fast без Redis-backend. - Invalidation —
cache.delна write-методах и@OnEvent-handler'ах. - Ключи — namespace-префикс, explicit-билдер, kebab-case.
- Паттерны — cache-aside, write-through, refresh-ahead.
- TTL — типовые TTL, связь с hit rate и invalidation.
- Где кешируем — что кешировать и почему низкий hit rate часто означает неправильный кандидат.