Опирается на правила:
R-CACHE-OBS-1…R-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% указывает на одну из проблем:
- TTL слишком короткий. Данные истекают до повторного чтения. Проверяем
settings.order_summary_cache_ttl_secondsи увеличиваем. - Частые invalidation. Каждый write evict-ит — кеш большую часть времени пустой. Переходим с cache-aside на write-through или пересматриваем частоту writes.
- Unbounded ключи. Поиск
product-searchс детальными фильтрами — каждый запрос уникален, кеш не даёт hits. Убрать или огрубить ключ. - Кешируем редко читаемые данные. Если 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 на INFO | R-CACHE-OBS-3 | log.debug(...) |
| Нет алерта на hit rate < 70% | R-CACHE-OBS-2 | алерт for: 30m с runbook |
| Только счётчики приложения, без Redis Exporter | R-CACHE-OBS-4 | Redis Exporter рядом с Redis |
| Метрики из каждого use case напрямую, без порта | R-CACHE-OBS-1 | единый cache-порт |
cache_gets_total без лейбла cache | R-CACHE-OBS-1 | лейбл cache=<name> обязателен |
Hit rate по avg, не по rate | R-CACHE-OBS-2 | rate(...)[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 под нагрузкой.