Опирается на правила:
R-CACHE-CFG-1…R-CACHE-CFG-5иR-CACHE-CFG-X1…R-CACHE-CFG-X4из Caching Style Guide → раздел 2. Конфигурация.
Важно знать
- Redis в проде через
redis.asyncio, неdict/cachetools. В multi-instance каждый процесс имел бы свой локальный кеш.- JSON-сериализация через
json.dumps/json.loads(илиorjson).pickle— security risk: RCE при десериализации недоверенных данных.- Per-cache TTL — каждый namespace имеет explicit срок; один глобальный TTL — компромисс ни для кого.
pydantic-settingsдля cache settings: TTL меняется в.env/application.yml, не в коде.- Кеш-порт —
Protocolвcore/, реализация вadapters/out/cache/. Хендлер зависит от абстракции, не отredis.asyncioнапрямую.- В тестах —
fakeredisили Testcontainers Redis. Мок cache-порта теряет поведение TTL и eviction.aiocache-декораторы — удобная альтернатива явному cache-aside для простых read-методов; serializer =JsonSerializer, неPickleSerializer.- Явная инициализация клиента —
Redis.from_url(url, decode_responses=True); безdecode_responses=Trueполучаетеbytesвместоstr.
Redis вместо dict в проде
R-CACHE-CFG-1: production backend — Redis.
Почему не dict / lru_cache / cachetools:
- Multi-instance. Gunicorn с 4 воркерами или 10 реплик в K8s = 10 изолированных dict.
customer_id=42попадает в разные процессы и читает разные значения. - Invalidation race. Write в одном процессе не уведомляет другие — старое значение живёт до TTL.
- Persistence. Redis с RDB/AOF переживает restart.
dictтеряет всё. - Observability. Redis-side метрики, Prometheus Redis Exporter,
redis-cli monitorдля дебага.
Точка инициализации — один раз в lifespan FastAPI:
# adapters/out/cache/redis_client.py
from redis.asyncio import Redis
from app.config import Settings
_client: Redis | None = None
def get_redis(settings: Settings) -> Redis:
return Redis.from_url(
settings.redis.url,
encoding="utf-8",
decode_responses=True,
)
# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.config import Settings
from adapters.out.cache.redis_client import get_redis
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = Settings()
app.state.redis = get_redis(settings)
yield
await app.state.redis.aclose()
app = FastAPI(lifespan=lifespan)
decode_responses=True — Redis возвращает str, а не bytes. Без этого каждый get придётся декодировать вручную.
JSON-сериализация — никогда pickle
R-CACHE-CFG-2: сериализация значений — JSON. pickle запрещён.
Почему не pickle:
- Security.
pickle.loads()выполняет произвольный Python-код при десериализации. Если attacker может записать произвольный blob в Redis (через слабый key, через скомпрометированный соседний сервис) — RCE. Это тот же класс уязвимости, что JavaJdkSerializationRedisSerializer. - Читаемость. JSON в Redis viewable через
redis-cli GET order-summaries:ORD-123— видно содержимое для дебага.pickle-blob — нет. - Совместимость. JSON переживает изменение Python-класса (добавили поле — старые значения читаются, новое поле
None).pickleчасто ломается при рефакторинге модели.
Пример через orjson (быстрее стандартного json):
# adapters/out/cache/json_serializer.py
import orjson
from typing import Any
def serialize(value: Any) -> str:
return orjson.dumps(value).decode()
def deserialize(raw: str, model: type) -> Any:
data = orjson.loads(raw)
if hasattr(model, "model_validate"):
return model.model_validate(data)
return data
Для aiocache — явно указывать JsonSerializer:
from aiocache import Cache
from aiocache.serializers import JsonSerializer
cache = Cache(
Cache.REDIS,
endpoint="localhost",
port=6379,
serializer=JsonSerializer(), # не PickleSerializer
)
Per-cache конфигурация
R-CACHE-CFG-3: каждый namespace — свой TTL.
Разные данные меняются с разной частотой:
| Namespace | Характер данных | TTL |
|---|---|---|
customer-profiles | редко меняется | 15 мин |
product-catalog | справочник | 6 ч |
feature-flags | feature-store | 60 с |
customer-balances | money | 15 с |
order-summaries | read-проекция | 5 мин |
Один глобальный TTL = customer-balances stale 15 минут или product-catalog пересчитывается каждые 60 секунд. Оба варианта плохи. Подробнее — TTL.
pydantic-settings для cache settings
R-CACHE-CFG-4: настройки через pydantic-settings, не хардкод.
# app/config.py
from pydantic import BaseModel, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class CacheNamespaceSettings(BaseModel):
ttl_seconds: int
@field_validator("ttl_seconds")
@classmethod
def positive_ttl(cls, v: int) -> int:
if v <= 0:
raise ValueError("ttl_seconds must be positive")
return v
class RedisSettings(BaseModel):
url: str = "redis://localhost:6379/0"
class CacheSettings(BaseModel):
namespaces: dict[str, CacheNamespaceSettings]
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
)
redis: RedisSettings = RedisSettings()
cache: CacheSettings = CacheSettings(
namespaces={
"customer-profiles": CacheNamespaceSettings(ttl_seconds=900),
"product-catalog": CacheNamespaceSettings(ttl_seconds=21600),
"feature-flags": CacheNamespaceSettings(ttl_seconds=60),
"customer-balances": CacheNamespaceSettings(ttl_seconds=15),
"order-summaries": CacheNamespaceSettings(ttl_seconds=300),
}
)
В .env или переменных окружения:
REDIS__URL=redis://redis-master:6379/0
CACHE__NAMESPACES__CUSTOMER_BALANCES__TTL_SECONDS=10
SRE меняет TTL через config-map без redeploy. field_validator ловит невалидный ttl_seconds=0 при старте.
Кеш-порт как Protocol
В гексагональной архитектуре хендлер зависит от абстракции, не от redis.asyncio:
# core/ports/cache_port.py
from typing import Protocol, Any
class CachePort(Protocol):
async def get(self, key: str) -> str | None: ...
async def set(self, key: str, value: str, ttl_seconds: int) -> None: ...
async def delete(self, key: str) -> None: ...
# adapters/out/cache/redis_cache_adapter.py
from redis.asyncio import Redis
from core.ports.cache_port import CachePort
class RedisCacheAdapter:
def __init__(self, redis: Redis) -> None:
self._redis = redis
async def get(self, key: str) -> str | None:
return await self._redis.get(key)
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
await self._redis.set(key, value, ex=ttl_seconds)
async def delete(self, key: str) -> None:
await self._redis.delete(key)
Хендлер получает CachePort через DI — в тестах подменяется FakeCache, в проде — RedisCacheAdapter.
В тестах — fakeredis или Testcontainers
R-CACHE-CFG-5: два режима тестирования.
fakeredis — для unit/integration-тестов хендлеров, где нужно проверить «второй вызов не дёрнул репозиторий»:
# tests/conftest.py
import fakeredis.aioredis
import pytest_asyncio
from adapters.out.cache.redis_cache_adapter import RedisCacheAdapter
@pytest_asyncio.fixture
async def fake_cache():
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
yield RedisCacheAdapter(redis)
await redis.aclose()
# tests/handlers/test_get_order_summary.py
async def test_second_call_uses_cache(fake_cache, order_repo):
handler = GetOrderSummaryHandler(cache=fake_cache, repo=order_repo)
await handler.execute(order_id="ORD-123")
await handler.execute(order_id="ORD-123")
order_repo.find_by_id.assert_called_once() # второй вызов из кеша
Testcontainers — для тестов, проверяющих реальное TTL-поведение и eviction:
# tests/conftest.py
import pytest
from testcontainers.redis import RedisContainer
from redis.asyncio import Redis
@pytest.fixture(scope="session")
def redis_container():
with RedisContainer("redis:7-alpine") as container:
yield container
@pytest.fixture
async def real_redis(redis_container):
client = Redis.from_url(
redis_container.get_connection_url(),
decode_responses=True,
)
yield client
await client.flushdb()
await client.aclose()
Мок CachePort через MagicMock теряет реальное поведение: TTL не истекает, eviction не происходит, тест проходит, в проде — баг.
aiocache-декораторы как альтернатива явному cache-aside
Для простых read-методов без сложной бизнес-логики aiocache сокращает шаблон:
from aiocache import cached
from aiocache.serializers import JsonSerializer
from app.config import Settings
settings = Settings()
@cached(
ttl=settings.cache.namespaces["product-catalog"].ttl_seconds,
key_builder=lambda f, *args, **kwargs: f"product-catalog:{kwargs['product_id']}",
serializer=JsonSerializer(),
)
async def get_product_summary(product_id: str) -> dict:
return await product_repo.find_summary(product_id)
Правила те же: JsonSerializer (не PickleSerializer), explicit key_builder (не автоматический из всех аргументов), TTL из настроек.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
pickle-сериализация (PickleSerializer) | R-CACHE-CFG-X1 | JSON (JsonSerializer, orjson) |
dict / lru_cache / cachetools в multi-instance проде | R-CACHE-CFG-X2 | redis.asyncio |
| Один глобальный TTL для всех namespace | R-CACHE-CFG-X3 | per-namespace TTL в pydantic-settings |
| «Кеш» без реального backend (мок CachePort) | R-CACHE-CFG-X4 | fakeredis или Testcontainers |
TTL хардкодом в коде (ex=900) | R-CACHE-CFG-4 | settings.cache.namespaces["..."].ttl_seconds |
Redis.from_url(url) без decode_responses=True | — | decode_responses=True, иначе bytes |
aiocache с PickleSerializer | R-CACHE-CFG-X1 | явно serializer=JsonSerializer() |
Куда дальше
- Где кешируем — критерии выбора кандидатов, read-проекции vs агрегаты.
- Ключи — namespace-префикс, explicit ключи, sensitive-данные.
- TTL — типовые значения по характеру данных, money TTL.
- Invalidation — evict на write, доменные события.
- Паттерны — cache-aside, write-through, refresh-ahead.
- Cache stampede — distributed lock, asyncio.Lock ограничения.
- Observability — hit rate, prometheus-client, Redis Exporter.