Опирается на правила:
R-CACHE-OBS-1…R-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% для долго существующего кеша указывает на одну из проблем:
- TTL слишком короткий. Данные истекают до повторного чтения — увеличить TTL или проверить TTL.
- Слишком частый evict. Каждый write инвалидирует — кеш пустой большую часть времени. Пересмотреть Invalidation.
- Ключи unbounded.
product-searchс детальными фильтрами — каждый запрос уникален. Кеш не помогает; убрать или сменить стратегию на Паттерны. - Кеш для редко читаемых данных. 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-X1 | promauto.NewCounterVec в adapters/out/cache/metrics.go |
Eviction на slog.InfoContext | R-CACHE-OBS-3 | slog.DebugContext |
| Нет алерта на hit rate < 70% | R-CACHE-OBS-2 | alertrule for: 30m с runbook |
| Только app-side метрики, без Redis Exporter | R-CACHE-OBS-4 | Redis Exporter в инфраструктуре |
Label cache не совпадает с namespace-префиксом ключа | R-CACHE-OBS-1 | kebab-case имя namespace одинаково везде |
| Hit rate рассчитывается avg вместо rate | R-CACHE-OBS-2 | rate(...[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