Если ваш API отвечает по 300–800 мс на каждый запрос, а база данных при пиковой нагрузке начинает задыхаться — скорее всего, вы просто не кешируете. Или кешируете, но неправильно. Кеширование — одна из тех тем, где легко сделать «в целом работает», но при этом получить либо протухшие данные, либо кеш, который не сбрасывается никогда.

Разберём всё по порядку: где кешировать, какую стратегию выбрать и как грамотно инвалидировать.

Почему кеш вообще нужен

Каждый запрос к API — это цепочка: принять запрос, авторизовать, сходить в базу, обработать, сериализовать, отдать. Даже на быстрой инфраструктуре это 50–200 мс. При 100 одновременных пользователях база получает 100 одинаковых запросов за секунду.

Кеш разрывает эту цепочку. Вместо похода в базу — чтение из памяти за 1–5 мс. Нагрузка на БД падает в разы, время ответа сокращается на порядок.

Но есть нюанс: кеш — это копия данных. Копия может устареть. Поэтому стратегия инвалидации важна не меньше, чем само кеширование.

Где живёт кеш

Прежде чем выбирать стратегию, нужно понять, на каком уровне кешировать.

Клиент (браузер или мобильное приложение). HTTP-кеш по заголовкам Cache-Control, ETag, Last-Modified. Бесплатно, не требует серверной логики, но вы не контролируете инвалидацию на стороне клиента.

CDN. Cloudflare, Fastly, AWS CloudFront кешируют ответы ближе к пользователю. Запрос вообще не доходит до вашего сервера. Отлично для публичных, редко меняющихся данных.

API Gateway или прокси. Nginx, Kong, Traefik умеют кешировать ответы апстрима. Один слой кеша до приложения.

Приложение (in-process cache). Словарь в памяти процесса. Быстрее всего, но не шарится между несколькими инстансами. Подходит для статичных справочников.

Внешний кеш (Redis, Memcached). Самый распространённый вариант для продакшена. Шарится между всеми инстансами приложения, поддерживает TTL, атомарные операции, pub/sub.

Для большинства API-проектов используют комбинацию: HTTP-заголовки на клиенте + Redis на сервере.

Стратегии кеширования

Cache-Aside (ленивое кеширование)

Самый популярный паттерн. Приложение само управляет кешем:

  1. Пришёл запрос — смотрим в кеш.
  2. Есть данные (cache hit) — возвращаем из кеша.
  3. Нет данных (cache miss) — идём в БД, кладём результат в кеш, возвращаем клиенту.
def get_product(product_id: int):
    cache_key = f"product:{product_id}"
    cached = redis.get(cache_key)
    if cached:
        return json.loads(cached)
    
    product = db.query("SELECT * FROM products WHERE id = %s", product_id)
    redis.setex(cache_key, 300, json.dumps(product))  # TTL 5 минут
    return product

Плюсы: простота, гибкость, кеш прогревается только для реально запрашиваемых данных. Минусы: первый запрос всегда медленный (cold start), при одновременных промахах несколько процессов могут одновременно пойти в БД — «thundering herd».

С thundering herd борются через mutex lock: прежде чем идти в БД, ставим в Redis временную блокировку. Остальные запросы ждут или возвращают устаревшие данные.

Read-Through

Похоже на Cache-Aside, но логика чтения инкапсулирована в самом кеш-провайдере или отдельном слое. Приложение всегда обращается только к кешу, а он сам знает, как загрузить данные при промахе.

Удобно, когда кеш-логика повторяется в разных местах кода — выносите в единый класс.

Write-Through

При записи данные обновляются одновременно в кеше и в базе. Кеш всегда актуален, промахов почти нет.

def update_product(product_id: int, data: dict):
    db.execute("UPDATE products SET ... WHERE id = %s", product_id)
    cache_key = f"product:{product_id}"
    redis.setex(cache_key, 300, json.dumps(data))

Минус: каждая запись дороже, кеш заполняется данными, которые могут не читаться.

Write-Behind (Write-Back)

Запись идёт только в кеш, а в БД — асинхронно, через очередь. Запись быстрая, но риск потери данных при падении кеш-сервера до сброса в БД.

Для API с ответственными данными (финансы, заказы) — не используйте без дополнительных гарантий.

HTTP-кеширование: заголовки

Если ваш API отдаёт данные браузеру или мобильному приложению — HTTP-кеш работает на вас бесплатно.

Cache-Control — главный заголовок:

  • max-age=300 — кешировать 5 минут
  • no-store — не кешировать вообще
  • no-cache — кешировать, но всегда проверять актуальность
  • private — только в браузере пользователя, не в CDN
  • public — можно кешировать на любом промежуточном узле

ETag — хеш или версия ресурса. Браузер сохраняет ETag и при следующем запросе отправляет If-None-Match. Если данные не изменились — сервер возвращает 304 Not Modified без тела. Трафик экономится, но запрос к серверу всё равно уходит.

Last-Modified + If-Modified-Since — то же самое, но по дате изменения.

Для публичных данных, которые меняются редко (каталог, статьи, справочники), комбинация Cache-Control: public, max-age=60 + ETag даёт хороший результат.

Инвалидация кеша

Фил Карлтон однажды сказал, что в программировании есть только две сложные вещи: инвалидация кеша и именование переменных. Это не шутка.

TTL (Time To Live)

Простейший способ: просто указываем время жизни записи. Через 5 минут данные протухают и следующий запрос обновит кеш.

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

Не подходит: если данные обновились секунду назад, а кеш живёт ещё 4 минуты 59 секунд — пользователь видит устаревшую информацию.

Правило выбора TTL: чем чаще меняются данные и чем критичнее актуальность — тем короче TTL. Для статичных справочников — часы. Для остатков товаров — минуты или секунды.

Инвалидация по событию

Вместо ожидания TTL — сбрасываем кеш в момент изменения данных.

def update_product(product_id: int, data: dict):
    db.execute("UPDATE products SET ... WHERE id = %s", product_id)
    redis.delete(f"product:{product_id}")
    # Инвалидируем и зависимые ключи
    redis.delete(f"category:{data['category_id']}:products")

Данные в кеше всегда свежие, но логика усложняется: нужно точно знать, какие ключи нужно сбросить при каждом изменении. Пропустили ключ — данные протухнут незаметно.

Теги кеша (Cache Tags)

Решение для сложных зависимостей. Каждой записи в кеше присваиваем теги, а инвалидируем по тегу — одной командой.

# Кешируем продукт с тегами
cache.set(
    key=f"product:{product_id}",
    value=product_data,
    tags=[f"product:{product_id}", f"category:{category_id}", "products"]
)

# Инвалидируем всё, что связано с категорией
cache.invalidate_tag(f"category:{category_id}")

Redis из коробки теги не поддерживает, но их можно реализовать через sets или использовать библиотеки типа django-cache-machine, Stash (PHP), FusionCache (.NET).

Версионирование ключей

Трюк: вместо удаления записи меняем версию в ключе.

# Версия хранится отдельно
version = redis.get("product_version") or 1
cache_key = f"product:{product_id}:v{version}"

# Инвалидация — просто увеличиваем версию
redis.incr("product_version")

Старые записи остаются в памяти и вытесняются сами по истечении TTL. Нет гонки условий, нет удаления под нагрузкой. Минус — память тратится на «мёртвые» записи.

Типичные ошибки

Кешировать ошибки. Если API-запрос к внешнему сервису вернул 500, не надо класть это в кеш на 5 минут. Кешируйте только успешные ответы.

if response.status_code == 200:
    redis.setex(cache_key, 300, response.text)

Один большой ключ вместо гранулированных. all_products весом 2 МБ инвалидируется целиком при изменении одного товара. Лучше product:{id} + category:{id}:product_ids.

Не учитывать параметры запроса в ключе. /api/products?sort=price&page=2 и /api/products?sort=name&page=1 — разные ответы. Ключ должен включать все параметры, влияющие на результат.

import hashlib

def make_cache_key(endpoint: str, params: dict) -> str:
    params_hash = hashlib.md5(str(sorted(params.items())).encode()).hexdigest()
    return f"{endpoint}:{params_hash}"

Кешировать персонализированные данные в публичном кеше. Ответ /api/user/profile нельзя класть в CDN без Cache-Control: private. Иначе данные одного пользователя увидит другой.

Игнорировать прогрев кеша. После деплоя или перезапуска Redis все ключи пропали. Первая волна трафика бьёт прямо в БД. Решение: асинхронный прогрев популярных ключей при старте приложения.

Мониторинг кеша

Без метрик непонятно, работает ли кеш вообще.

Ключевые показатели:

  • Hit rate — доля запросов, обслуженных из кеша. Ниже 70–80% — кеш плохо настроен или TTL слишком короткий.
  • Miss rate — обратная величина.
  • Eviction rate — как часто Redis вытесняет записи из-за нехватки памяти. Высокий eviction = памяти мало или TTL слишком длинный.
  • Latency — время отклика кеша. Redis должен отвечать за 1–5 мс.

Redis отдаёт всё это через INFO stats и INFO memory. Подключите к Grafana через redis_exporter — и у вас будет полная картина.

Кеширование в контексте архитектуры

Если у вас микросервисная архитектура, будьте аккуратны с распределённым кешем. Несколько сервисов, читающих и пишущих один и тот же ключ в Redis, — источник тонких багов.

Чаще всего лучше, чтобы каждый сервис владел своим кешем и своим пространством ключей. Префиксы помогают: orders:, catalog:, users:.

Если сервис A нужны актуальные данные от сервиса B — не лезьте в его кеш напрямую. Либо подпишитесь на события (Kafka, RabbitMQ), либо запрашивайте через API с разумным TTL на своей стороне.

При проектировании API в REEXY мы закладываем кеш-стратегию ещё на этапе архитектуры — это дешевле, чем переделывать потом. Корпоративный сайт с нагруженным API стоит от 15 000 ₽, и кеширование там идёт по умолчанию.

Когда кеш не нужен

Кеш решает не все проблемы. Если запрос к БД занимает 2 секунды — скорее всего, нужен индекс, а не кеш. Кеш прячет симптом, не лечит причину.

Также не стоит кешировать:

  • данные реального времени (биржевые котировки, чат-сообщения)
  • результаты записи (POST/PUT/DELETE обычно не кешируются)
  • очень маленькие выборки, которые БД отдаёт быстрее 5 мс

Проверяйте запросы через EXPLAIN ANALYZE прежде чем тащить Redis.

Практическая шпаргалка

Что выбрать в зависимости от типа данных:

Данные Стратегия TTL
Публичный каталог товаров Cache-Aside + HTTP Cache-Control 5–15 минут
Карточка товара Cache-Aside + инвалидация по событию 10 минут
Пользовательская сессия Read-Through 30–60 минут
Справочники (страны, категории) Write-Through или прогрев при старте 1–24 часа
Агрегаты и аналитика Cache-Aside + TTL 1–5 минут
Внешние API (погода, курсы) Cache-Aside По документации API

Кеширование — это не настройка один раз и забыл. Это живая часть системы: TTL приходится подбирать под реальный трафик, стратегию инвалидации — пересматривать при изменении бизнес-логики. Но сделанное правильно, оно превращает перегруженный API в систему, которая держит нагрузку без паники.