lenec ru

← все посты

Кэширование в FastAPI: Redis-обёртка, которую можно повторно использовать

16K

Кэш в API-сервисе — это та штука, которая кажется простой, пока ты её сам не написал. «Положи результат в Redis на 5 минут». Звучит элементарно. А потом начинается: ключи, инвалидация, сериализация, обработка ошибок, разные типы данных, разные TTL, и через год это уже клубок утилит, разбросанных по проекту.

Я расскажу про обёртку, которая у нас работает в трёх FastAPI-сервисах. Не библиотека, а скорее готовый рецепт: один файл, который ты копируешь в проект и адаптируешь под себя. Главное — что она решает реальные проблемы, а не выглядит красиво.

Что должно быть в обёртке

Минимальный список требований, которые мы выработали:

  • Один способ кэшировать функцию через декоратор.
  • Поддержка async-функций.
  • Серриализация через JSON (быстро) с возможностью сменить на pickle (для сложных типов).
  • Адекватные ключи: имя функции + аргументы.
  • Игнорирование «технических» аргументов (например, session).
  • Инвалидация по тегам: «выкинь весь кэш, связанный с user=123».
  • Не падать, если Redis недоступен. Пусть просто прокладывает запрос мимо кэша.

Почти всегда люди начинают с пунктов 1–3, через месяц добавляют 4, через полгода — 6, и в итоге у них своя версия того же самого, только криво.

Базовый клиент Redis

Используем redis.asyncio (он же redis-py async-режим, после слияния aioredis):

from redis.asyncio import Redis, ConnectionPool

pool = ConnectionPool.from_url(
    "redis://redis:6379/0",
    max_connections=50,
    decode_responses=True,
)

def get_redis() -> Redis:
    return Redis(connection_pool=pool)

Один пул на процесс, инстанс Redis — лёгкий объект, его можно создавать на каждый запрос. decode_responses=True сразу даёт строки вместо bytes — удобно, пока ты не работаешь с бинарными значениями.

Сериализация и ключи

Берём JSON по умолчанию: быстро, читабельно, межязыковая совместимость. Для нестандартных типов (datetime, Decimal, UUID) добавляем кастомный энкодер:

import json
from datetime import datetime
from decimal import Decimal
from uuid import UUID

class JSONEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()
        if isinstance(obj, Decimal):
            return str(obj)
        if isinstance(obj, UUID):
            return str(obj)
        return super().default(obj)

def dumps(value) -> str:
    return json.dumps(value, cls=JSONEncoder, separators=(",", ":"))

def loads(value: str):
    return json.loads(value)

Для ключа собираем стабильную строку из имени функции и аргументов. Стабильность важна: один и тот же набор аргументов должен давать один ключ независимо от порядка kwargs.

import hashlib

def _build_key(prefix: str, func_name: str, args: tuple, kwargs: dict, ignore: set[str]) -> str:
    payload = {
        "args": args,
        "kwargs": {k: v for k, v in sorted(kwargs.items()) if k not in ignore},
    }
    raw = dumps(payload)
    digest = hashlib.sha1(raw.encode()).hexdigest()[:16]
    return f"{prefix}:{func_name}:{digest}"

SHA-1 — не для криптостойкости, а для длины и распределения. Берём первые 16 символов: ключи короткие, читаемые, коллизии практически невозможны на нашей нагрузке.

Декоратор: ядро обёртки

Код довольно компактный. Вот живой вариант, который мы используем:

import functools
import logging
from typing import Callable, Iterable

log = logging.getLogger(__name__)

def cached(
    *,
    ttl: int = 60,
    prefix: str = "cache",
    ignore_args: Iterable[str] = ("session", "request", "background_tasks"),
    tags: Callable[..., list[str]] | None = None,
):
    ignore_set = set(ignore_args)
    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            redis = get_redis()
            key = _build_key(prefix, func.__qualname__, args, kwargs, ignore_set)
            try:
                cached_value = await redis.get(key)
            except Exception as exc:
                log.warning("cache get failed: %s", exc)
                cached_value = None

            if cached_value is not None:
                return loads(cached_value)

            result = await func(*args, **kwargs)

            try:
                pipe = redis.pipeline()
                pipe.set(key, dumps(result), ex=ttl)
                if tags:
                    for tag in tags(*args, **kwargs):
                        pipe.sadd(f"{prefix}:tag:{tag}", key)
                        pipe.expire(f"{prefix}:tag:{tag}", ttl + 60)
                await pipe.execute()
            except Exception as exc:
                log.warning("cache set failed: %s", exc)
            return result
        return wrapper
    return decorator

Что важно:

  • Любая ошибка Redis ловится и логируется. Запрос всегда возвращается, даже если Redis лёг. Это критично — недоступный кэш не должен ронять бизнес-логику.
  • Параметр ignore_args исключает «технические» аргументы из ключа. Без этого в ключ попадает адрес AsyncSession, и ты получаешь cache miss на каждый запрос.
  • tags — функция, возвращающая список тегов по аргументам. На каждый тег ведём set ключей, которые с ним связаны.

Использование

@cached(
    ttl=300,
    prefix="users",
    tags=lambda user_id, **_: [f"user:{user_id}"],
)
async def get_user_profile(user_id: int, session: AsyncSession) -> dict:
    user = await session.get(User, user_id)
    return {"id": user.id, "name": user.name, "email": user.email}

В ключ попадёт только user_id, потому что session в ignore_args. Тег user:123 позволит позже инвалидировать всё связанное с этим пользователем.

Инвалидация

Отдельная функция для сброса по тегам:

async def invalidate_tags(*tags: str, prefix: str = "cache") -> int:
    redis = get_redis()
    total = 0
    for tag in tags:
        tag_key = f"{prefix}:tag:{tag}"
        keys = await redis.smembers(tag_key)
        if keys:
            await redis.delete(*keys)
            total += len(keys)
        await redis.delete(tag_key)
    return total

# где-то в эндпоинте после изменения пользователя
await invalidate_tags(f"user:{user.id}", prefix="users")

Логика прямолинейная: достаём из set все ключи, связанные с тегом, удаляем их и сам set. На больших базах ключей это безопасно — мы не делаем SCAN и не блокируем Redis.

Подводные камни, которые мы прошли

Thundering herd. Когда популярный ключ протух, все запросы одновременно идут в БД, считают и пишут одно и то же. На больших нагрузках это убивает базу. Решение — добавить блокировку или SETNX-флаг «считаю, не лезьте». Самый простой вариант: при miss один поток ставит «building» флаг, остальные кратко спят и читают результат.

Cache stampede на старте. После рестарта пода Redis пустой (если он у тебя локальный) или просто кэш холодный, и пик нагрузки идёт прямо на БД. Если важно — разогревай кэш на старте через lifespan.

Большие значения. Если кэшируешь объект на 500 КБ — Redis это съест, но будет неловко. Лучше кэшировать кусочки, а не гигантские агрегаты целиком.

Сериализация изменилась. Сменил структуру ответа функции — старые значения в кэше становятся несовместимы. Версионируй prefix: users.v2. На рестарте новые ключи, старые сами протухнут.

TTL у тегов. Если тег живёт меньше, чем закэшированные ключи, ты не сможешь корректно инвалидировать. Поэтому TTL тега всегда чуть больше TTL значений.

Что не делать

  • Не используй pickle, если данные могут быть прочитаны другим сервисом или другой версией Python. JSON надёжнее.
  • Не кэшируй на основе request: получишь утечку приватных данных между пользователями.
  • Не делай TTL None и не полагайся на ручную инвалидацию. Любой кэш должен иметь верхнюю границу жизни.
  • Не пихай весь кэш в db=0. Разные кэши — разные БД Redis или хотя бы разные префиксы. Это упрощает диагностику.

Сухой итог

Эта обёртка — не серебряная пуля и не замена нормальному кэширующему слою. Это рабочий минимум, который закрывает 90% случаев в прикладном коде. Декоратор, тегированная инвалидация, отказоустойчивость к Redis. С неё хорошо начинать; когда упрёшься в её ограничения — будешь знать, какую конкретно фичу добавлять.

Главное правило кэша: он должен ускорять, но не быть источником непредсказуемых ошибок. Если кэш падает, сервис должен продолжать работать. Это первая проверка, которую стоит проводить на любом кэширующем коде, прежде чем катить в прод.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.