Опирается на правила: R-CACHE-OBS-1R-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% — симптом одной из проблем:

  1. TTL слишком короткий. order-summaries истекает за 30 секунд, а повторное чтение того же заказа через минуту — уже miss. Увеличить TTL.
  2. Частая invalidation + длинный TTL. Каждый PATCH /products/:id делает cache.del — кеш product-summaries большую часть времени пуст. Переосмыслить паттерн: write-through вместо evict, или сократить область invalidation.
  3. Ключи unbounded. Поиск с детальными фильтрами (customer-search?name=Ива&city=Москва&page=3) — каждый запрос уникален, cache-aside не помогает. Убрать кеш или сделать менее гранулярным.
  4. Кеш на редко читаемых данных. Если 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_stateHealth в 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-X1Counter через prom-client в каждом адаптере
Eviction на logger.log / logger.warnR-CACHE-OBS-3logger.debug с ключом
Нет алерта на hit rate < 70%R-CACHE-OBS-2PromQL-алерт for: 30m с runbook
Только Node-side метрики, без Redis ExporterR-CACHE-OBS-4Redis Exporter рядом с Redis
Счётчик без метки cache (общий для всех кешей)R-CACHE-OBS-1labelNames ['cache', 'result'] per-cache
Hit rate как avg за период, не rateR-CACHE-OBS-2rate(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 часто означает неправильный кандидат.