Опирается на правила: R-CACHE-OBS-1R-CACHE-OBS-4 и R-CACHE-OBS-X1 из Caching Rules → раздел 8. Observability.

Важно знать

  • В Go нет автоматического экспорта метрик кеша — счётчики hits/misses/evictions декларируются явно через promauto.NewCounterVec.
  • Hit rate = cache_hits_total / (cache_hits_total + cache_misses_total) — главная метрика здоровья кеша.
  • Алерт при hit rate < 70% для долго существующих кешей: либо неподходящий TTL, либо слишком частые evict.
  • Eviction логируется на slog.DebugContext с key — не InfoContext, не WarnContext: один evict на каждый write, при высокой нагрузке будет шумно.
  • Redis-side метрики (redis_memory_used_bytes, redis_keys_total, redis_evicted_keys_total) — через Redis Exporter, не в коде приложения.
  • Метрики объявляются один раз в adapters/out/cache/metrics.go и используются всеми кеш-адаптерами пакета.
  • Label cache = имя namespace (kebab-case: customer-profiles, order-summaries) — совпадает с ключевым префиксом (R-CACHE-KEY-1).

Кеш без observability — это «надеемся, что работает». Hit rate 5% означает «потратили инфру на Redis, эффект нулевой» — а узнаёшь об этом только когда вспомнишь проверить.

Явный экспорт метрик через promauto

R-CACHE-OBS-1: в Go нет Spring Micrometer, поэтому три счётчика объявляются вручную один раз на пакет:

// adapters/out/cache/metrics.go
package cache

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    cacheHits = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "cache_hits_total",
            Help: "Cache hits by cache namespace",
        },
        []string{"cache"},
    )
    cacheMisses = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "cache_misses_total",
            Help: "Cache misses by cache namespace",
        },
        []string{"cache"},
    )
    cacheEvictions = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "cache_evictions_total",
            Help: "Cache evictions (explicit deletes) by cache namespace",
        },
        []string{"cache"},
    )
)

promauto регистрирует счётчики в prometheus.DefaultRegisterer при инициализации пакета — отдельный Register-вызов не нужен.

Инкремент в cache-aside Handler

Счётчики инкрементируются там, где происходит обращение к кешу — в Use Case Handler или адаптере, но не в обоих:

// core/order/get_order_summary_handler.go
func (h *GetOrderSummaryHandler) Handle(ctx context.Context, q GetOrderSummaryQuery) (*OrderSummary, error) {
    if cached, err := h.cache.GetSummary(ctx, q.OrderID); err == nil && cached != nil {
        cacheHits.WithLabelValues("order-summaries").Inc()
        return cached, nil
    }
    cacheMisses.WithLabelValues("order-summaries").Inc()

    summary, err := h.repo.GetOrderSummary(ctx, q.OrderID)
    if err != nil {
        return nil, err
    }
    if err := h.cache.SetSummary(ctx, q.OrderID, summary, h.cfg.Cache.OrderSummaryTTL); err != nil {
        slog.WarnContext(ctx, "cache set failed", "order_id", q.OrderID, "error", err)
    }
    return summary, nil
}

То же для CustomerSummary и ProductCatalog — label "cache" должен совпадать с namespace-префиксом ключа.

Eviction — DEBUG, не INFO

R-CACHE-OBS-3: каждый DeleteSummary потенциально вызывается при каждом write. На высоконагруженном сервисе это несколько сотен событий в секунду.

// adapters/out/cache/customer_cache.go
func (c *RedisCustomerCache) DeleteSummary(ctx context.Context, customerID string) error {
    err := c.redis.Del(ctx, summaryKey(customerID)).Err()
    if err != nil {
        return fmt.Errorf("cache evict customer summary %s: %w", customerID, err)
    }
    cacheEvictions.WithLabelValues("customer-profiles").Inc()
    slog.DebugContext(ctx, "cache evict",
        "cache", "customer-profiles",
        "key", customerID,
    )
    return nil
}

slog.DebugContext — не InfoContext. DEBUG включается при инциденте, когда нужно понять «инвалидировался ли кеш после write». В штатном режиме строка не пишется.

Аналогично для ProductCatalog:

func (c *RedisProductCache) DeleteProduct(ctx context.Context, productID string) error {
    err := c.redis.Del(ctx, productKey(productID)).Err()
    if err != nil {
        return fmt.Errorf("cache evict product %s: %w", productID, err)
    }
    cacheEvictions.WithLabelValues("product-catalog").Inc()
    slog.DebugContext(ctx, "cache evict",
        "cache", "product-catalog",
        "key", productID,
    )
    return nil
}

Hit rate — главная метрика

R-CACHE-OBS-2: hit rate рассчитывается в Prometheus, не в коде приложения.

sum by (cache) (rate(cache_hits_total[5m]))
  /
sum by (cache) (rate(cache_hits_total[5m]) + rate(cache_misses_total[5m]))

Алерт:

- alert: CacheHitRateLow
  expr: |
    (
      sum by (cache, service) (rate(cache_hits_total[1h]))
      /
      (
        sum by (cache, service) (rate(cache_hits_total[1h]))
        + sum by (cache, service) (rate(cache_misses_total[1h]))
      )
    ) < 0.7
  for: 30m
  annotations:
    summary: "Cache {{ $labels.cache }} hit rate < 70% в {{ $labels.service }}"
    runbook: "https://runbooks.internal/cache-low-hit-rate"

Hit rate < 70% для долго существующего кеша указывает на одну из проблем:

  1. TTL слишком короткий. Данные истекают до повторного чтения — увеличить TTL или проверить TTL.
  2. Слишком частый evict. Каждый write инвалидирует — кеш пустой большую часть времени. Пересмотреть Invalidation.
  3. Ключи unbounded. product-search с детальными фильтрами — каждый запрос уникален. Кеш не помогает; убрать или сменить стратегию на Паттерны.
  4. Кеш для редко читаемых данных. Ratio read/write < 10:1 — кеш приносит больше работы, чем экономии.

Runbook должен включать процедуру диагностики через cache_hits_total, cache_misses_total, cache_evictions_total — чтобы оператор быстро определил причину.

Redis-side метрики

R-CACHE-OBS-4: приложение видит только Java-side (read-через-порт). Состояние Redis — memory pressure, cluster health, replication lag — мониторим через Redis Exporter, не в adapters/out/cache/.

МетрикаЧто показывает
redis_upДоступность инстанса
redis_memory_used_bytesИспользованная память
redis_memory_max_bytesЛимит (maxmemory)
redis_keys_total{db}Количество ключей в базе
redis_evicted_keys_totalКлючей вытеснено Redis-ом по LRU/LFU
redis_cluster_stateЗдоровье кластера
redis_master_replication_lag_secondsОтставание реплики

Алерт на memory pressure:

- alert: RedisMemoryHigh
  expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.9
  for: 5m
  annotations:
    summary: "Redis использует > 90% maxmemory — близко к eviction"

redis_evicted_keys_total rate > 0 при политике noeviction — что-то не так с TTL или объёмом данных.

Что запрещено

АнтипаттернПравилоЧто взамен
Нет cache_hits_total / cache_misses_total в кодеR-CACHE-OBS-X1promauto.NewCounterVec в adapters/out/cache/metrics.go
Eviction на slog.InfoContextR-CACHE-OBS-3slog.DebugContext
Нет алерта на hit rate < 70%R-CACHE-OBS-2alertrule for: 30m с runbook
Только app-side метрики, без Redis ExporterR-CACHE-OBS-4Redis Exporter в инфраструктуре
Label cache не совпадает с namespace-префиксом ключаR-CACHE-OBS-1kebab-case имя namespace одинаково везде
Hit rate рассчитывается avg вместо rateR-CACHE-OBS-2rate(...[5m]) в PromQL

Куда дальше

  • TTL — низкий hit rate часто из-за неподходящего TTL
  • Invalidation — частые evict снижают hit rate
  • Паттерны — cache-aside, write-through, refresh-ahead
  • Cache stampede — singleflight и SetNX-lock при cache miss
  • Ключи — namespace-префиксы и kebab-case имена кешей
  • Конфигурация — CacheConfig, per-cache TTL через envconfig
  • Где кешируем — низкий hit rate часто из-за неправильного cacheable candidate