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

Важно знать

  • В Python нет магии Micrometer — метрики hits/misses/evictions пишутся явно через prometheus-client.
  • Cache-порт (CachePort в core/) оборачивает счётчики: все точки get/set/delete в одном месте.
  • Hit rate = hits / (hits + misses) — основная метрика здоровья кеша.
  • Алерт при hit rate < 70% для долго существующих кешей — либо неподходящий TTL, либо слишком частые invalidation.
  • Eviction логируется на DEBUG с ключом. INFO — будет шумно на каждом write.
  • Redis-side метрики (memory, cluster state, evicted keys) — через отдельный Redis Exporter.
  • Отключить метрики или не подключать совсем — антипаттерн R-CACHE-OBS-X1: SRE не увидит hit rate 5%.

В отличие от Spring Boot с Micrometer, FastAPI не экспортирует cache-метрики автоматически. Счётчики нужно инкрементировать вручную — поэтому правильное место для них кеш-порт, а не каждый use case.

Cache-порт с метриками

R-CACHE-OBS-1: определяем метрики на уровне порта, один раз:

# adapters/out/cache/metrics.py
from prometheus_client import Counter, Histogram

cache_gets = Counter(
    "cache_gets_total",
    "Total cache get attempts",
    ["cache", "result"],          # result: hit | miss
)
cache_puts = Counter(
    "cache_puts_total",
    "Total cache put operations",
    ["cache"],
)
cache_evictions = Counter(
    "cache_evictions_total",
    "Total cache evict operations",
    ["cache"],
)
cache_get_duration = Histogram(
    "cache_get_duration_seconds",
    "Cache get latency",
    ["cache"],
    buckets=[0.001, 0.005, 0.01, 0.05, 0.1],
)
# adapters/out/cache/redis_cache_adapter.py
import logging
from typing import TypeVar, Callable, Awaitable

import redis.asyncio as aioredis

from adapters.out.cache.metrics import (
    cache_gets, cache_puts, cache_evictions, cache_get_duration,
)
from core.ports.cache_port import CachePort

T = TypeVar("T")
log = logging.getLogger(__name__)


class RedisCacheAdapter(CachePort):

    def __init__(self, client: aioredis.Redis) -> None:
        self._client = client

    async def get(self, cache_name: str, key: str) -> bytes | None:
        full_key = f"{cache_name}:{key}"
        with cache_get_duration.labels(cache=cache_name).time():
            value = await self._client.get(full_key)

        result = "hit" if value is not None else "miss"
        cache_gets.labels(cache=cache_name, result=result).inc()
        return value

    async def set(self, cache_name: str, key: str, value: bytes, ttl: int) -> None:
        full_key = f"{cache_name}:{key}"
        await self._client.set(full_key, value, ex=ttl)
        cache_puts.labels(cache=cache_name).inc()

    async def delete(self, cache_name: str, key: str) -> None:
        full_key = f"{cache_name}:{key}"
        deleted = await self._client.delete(full_key)
        if deleted:
            cache_evictions.labels(cache=cache_name).inc()
            log.debug("Cache evict: cache=%s key=%s", cache_name, key)

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

R-CACHE-OBS-2: hit rate считается из счётчиков. PromQL:

sum by (cache) (rate(cache_gets_total{result="hit"}[5m]))
  /
sum by (cache) (rate(cache_gets_total[5m]))

Алерт в Prometheus rules:

- 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%"
    runbook: https://runbooks.internal/cache-low-hit-rate

Hit rate < 70% указывает на одну из проблем:

  1. TTL слишком короткий. Данные истекают до повторного чтения. Проверяем settings.order_summary_cache_ttl_seconds и увеличиваем.
  2. Частые invalidation. Каждый write evict-ит — кеш большую часть времени пустой. Переходим с cache-aside на write-through или пересматриваем частоту writes.
  3. Unbounded ключи. Поиск product-search с детальными фильтрами — каждый запрос уникален, кеш не даёт hits. Убрать или огрубить ключ.
  4. Кешируем редко читаемые данные. Если ratio read/write < 10:1 — кеш приносит накладные расходы без выигрыша. Убрать кеш.

Пример cache-aside в use case с вызовом порта:

# application/use_cases/get_order_summary.py
import json
from dataclasses import dataclass

from core.ports.cache_port import CachePort
from core.ports.order_repository import OrderRepository
from core.settings import CacheSettings


@dataclass(frozen=True)
class GetOrderSummaryQuery:
    order_id: str
    customer_id: str


class GetOrderSummaryUseCase:

    def __init__(
        self,
        cache: CachePort,
        repo: OrderRepository,
        settings: CacheSettings,
    ) -> None:
        self._cache = cache
        self._repo = repo
        self._settings = settings

    async def execute(self, query: GetOrderSummaryQuery) -> dict:
        cache_name = "order-summaries"
        key = query.order_id

        cached = await self._cache.get(cache_name, key)
        if cached is not None:
            return json.loads(cached)

        summary = await self._repo.get_order_summary(query.order_id)
        await self._cache.set(
            cache_name, key,
            json.dumps(summary).encode(),
            ttl=self._settings.order_summary_cache_ttl_seconds,
        )
        return summary

Eviction — DEBUG, не INFO

R-CACHE-OBS-3: evict срабатывает на каждом write. На нагруженном сервисе это 100+ строк/секунду, если уровень INFO.

RedisCacheAdapter.delete() выше уже пишет log.debug(...). При инциденте DEBUG включается на конкретном логгере:

# только при диагностике, не постоянно
import logging
logging.getLogger("adapters.out.cache").setLevel(logging.DEBUG)

Или через env-переменную в logging.yaml:

loggers:
  adapters.out.cache:
    level: ${LOG_LEVEL_CACHE:DEBUG}

В docker-compose.override.yml или переменных деплоя LOG_LEVEL_CACHE=DEBUG включается локально, без изменения кода.

Пример evict на write в use case UpdateProduct:

# application/use_cases/update_product.py
import json
from dataclasses import dataclass

from core.ports.cache_port import CachePort
from core.ports.product_repository import ProductRepository


@dataclass(frozen=True)
class UpdateProductCommand:
    product_id: str
    customer_id: str
    new_price: float


class UpdateProductUseCase:

    def __init__(self, cache: CachePort, repo: ProductRepository) -> None:
        self._cache = cache
        self._repo = repo

    async def execute(self, cmd: UpdateProductCommand) -> None:
        await self._repo.update_price(cmd.product_id, cmd.new_price)
        await self._cache.delete("product-catalog", cmd.product_id)

log.debug("Cache evict: cache=%s key=%s", ...) печатается только при включённом уровне — в проде молча.

Redis-side метрики

R-CACHE-OBS-4: prometheus-client видит только счётчики приложения. Redis-side состояние (memory pressure, cluster health, LRU evictions) — через 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Состояние кластера (1 = ok)
redis_master_replication_lag_secondsЗадержка репликации

Алерт на redis_evicted_keys_total при политике noeviction — означает, что Redis упёрся в лимит и ключи удаляются вне контроля:

- alert: RedisMemoryPressure
  expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.9
  for: 5m
  annotations:
    summary: "Redis memory > 90% on {{ $labels.instance }}"

Разница Redis Exporter vs. приложение: приложение не знает о LRU-eviction на стороне Redis. Если Redis evict-нул ключ order-summaries:order-42 по памяти — счётчик cache_evictions_total в приложении не изменится, но следующий get вернёт miss. redis_evicted_keys_total покажет это.

Экспорт метрик в FastAPI

prometheus-client подключается к ASGI через make_asgi_app():

# main.py
from fastapi import FastAPI
from prometheus_client import make_asgi_app
from starlette.routing import Mount

app = FastAPI()

# метрики на /metrics, не на основном роутере
metrics_app = make_asgi_app()
app.mount("/metrics", metrics_app)

В проде /metrics закрывается на уровне ingress (доступен только Prometheus-серверу, не публично).

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

АнтипаттернПравилоЧто взамен
Нет счётчиков hits/misses вообщеR-CACHE-OBS-1инкремент в cache-порту
Eviction на INFOR-CACHE-OBS-3log.debug(...)
Нет алерта на hit rate < 70%R-CACHE-OBS-2алерт for: 30m с runbook
Только счётчики приложения, без Redis ExporterR-CACHE-OBS-4Redis Exporter рядом с Redis
Метрики из каждого use case напрямую, без портаR-CACHE-OBS-1единый cache-порт
cache_gets_total без лейбла cacheR-CACHE-OBS-1лейбл cache=<name> обязателен
Hit rate по avg, не по rateR-CACHE-OBS-2rate(...)[5m] в PromQL

Куда дальше

  • Конфигурация — как настроить redis.asyncio и pydantic-settings для кеш-порта.
  • TTL — низкий hit rate чаще всего из-за неправильного TTL.
  • Где кешируем — низкий hit rate может означать, что кешируем неподходящий кандидат.
  • Invalidation — частые evictions как причина низкого hit rate.
  • Ключи — unbounded ключи как причина нулевых hits.
  • Паттерны — выбор паттерна влияет на соотношение hits/misses.
  • Cache stampede — stampede маскирует реальный hit rate под нагрузкой.