Кэширование в Django

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

Кэш — это быстрое хранилище уже вычисленного результата, к которому обращаются вместо того, чтобы вычислять заново. В Django кэш — это абстракция с общим API cache.get/cache.set, за которой может стоять Redis, Memcached, БД или память процесса.

Типичная страница каталога делает десяток запросов к БД, рендерит тяжёлый шаблон, считает агрегаты — и так на каждый из тысяч одинаковых заходов. Большая часть этой работы повторяется впустую: данные меняются раз в час, а пересчитываются тысячу раз в секунду. Кэширование разрывает эту растрату — результат вычисляется один раз, кладётся в быстрое хранилище и оттуда отдаётся, пока не устареет. Django предлагает не один способ, а целую лестницу: от кэша всей страницы целиком до точечного кэша одного значения.

Зачем это на практике

Представьте, что главная страница магазина под нагрузкой отвечает за 800 мс, и профайлер показывает: 600 мс из них — это запросы к БД за списком категорий и баннеров, которые меняются раз в день. Закэшировав результат на 10 минут, вы превращаете 800 мс в 30 мс и снимаете нагрузку с базы, не покупая новый сервер. Кэш — самый дешёвый способ масштабирования: он не требует переписывать логику, только аккуратно решить, что и насколько можно сохранять. Цена ошибки — устаревшие данные на экране, поэтому ключевой навык здесь не «как закэшировать», а «как вовремя сбросить кэш».

Бэкенд: Redis или Memcached

Сам кэш где-то живёт. Для продакшена берут отдельный сервер в памяти — Redis или Memcached: они хранят данные в RAM, общие для всех процессов и воркеров приложения. Настраивается это в settings.py через словарь CACHES.

# settings.py — Redis как кэш (Django 4+ умеет Redis из коробки)
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
        "TIMEOUT": 300,  # время жизни записи по умолчанию, секунд
    }
}

Memcached настраивается аналогично, через PyMemcacheCache и адрес 127.0.0.1:11211. Разница для большинства проектов невелика: Memcached проще и заточен строго под кэш, Redis универсальнее (умеет структуры данных, persistence, очереди — и часто уже стоит в проекте под Celery). Локальный по умолчанию LocMemCache хранит данные в памяти одного процесса — он годится для разработки, но в проде с несколькими воркерами каждый будет иметь свой отдельный кэш, что почти всегда не то, что нужно.

БэкендГде хранитКогда брать
RedisCacheсервер Redis (RAM)прод; особенно если Redis уже есть под Celery
PyMemcacheCacheсервер Memcached (RAM)прод, когда нужен только кэш и ничего больше
LocMemCacheпамять процессаразработка, тесты — НЕ для многопроцессного прода
DatabaseCacheтаблица в БДкогда нет Redis/Memcached; медленнее, но переживает рестарт

Уровень 1: кэш всей страницы

Самый грубый и самый мощный уровень — закэшировать ответ вьюхи целиком. Декоратор cache_page сохраняет весь HTTP-ответ под ключом, собранным из URL, и на повторный запрос отдаёт его, не заходя во вьюху вообще.

from django.views.decorators.cache import cache_page

@cache_page(60 * 15)  # держим ответ 15 минут
def article_list(request):
    articles = Article.objects.all()  # этот запрос выполнится раз в 15 минут
    return render(request, "articles/list.html", {"articles": articles})

Это идеально для страниц, одинаковых для всех и редко меняющихся: статьи, документация, публичный каталог. Но осторожно: cache_page кэширует ответ для всех пользователей одинаково. Применять его к странице, которая показывает имя залогиненного пользователя или его корзину, — прямой путь показать данные одного человека другому. Для персонализированных страниц либо не кэшируйте страницу целиком, либо настраивайте варьирование ответа по заголовкам (Vary) и куках — но это тонко, и чаще проще кэшировать ниже уровнем.

Уровень 2: кэш фрагмента шаблона

Часто на странице тяжела не вся она, а один блок — например, меню категорий или «топ-10 за неделю». Тег {% raw %}{% cache %}{% endraw %} кэширует только кусок шаблона, оставляя остальное динамическим.

{% raw %}{% load cache %}

<h1>Привет, {{ user.username }}!</h1>  {# это динамично — НЕ в кэше #}

{% cache 600 sidebar_categories %}
  {# этот блок считается раз в 10 минут и общий для всех #}
  <ul>
    {% for c in expensive_categories %}
      <li>{{ c.name }} ({{ c.product_count }})</li>
    {% endfor %}
  </ul>
{% endcache %}{% endraw %}

Первый аргумент — время жизни в секундах, второй — имя фрагмента (часть ключа). Если фрагмент зависит от чего-то (например, от языка или категории), это добавляют в тег как дополнительные аргументы — {% raw %}{% cache 600 sidebar request.LANGUAGE_CODE %}{% endraw %} — и тогда для каждого варианта будет своя запись. Этот уровень — золотая середина: персональная часть страницы остаётся живой, а дорогой общий блок кэшируется.

Уровень 3: низкоуровневый cache API

Самый гибкий уровень — кэшировать произвольное значение прямо в Python-коде. Это нужно, когда дорогая операция — не рендер, а сам расчёт: агрегат по миллиону строк, ответ внешнего API, результат тяжёлой функции.

from django.core.cache import cache

def get_dashboard_stats():
    key = "dashboard_stats_v1"
    data = cache.get(key)          # пробуем достать из кэша
    if data is None:               # промах — считаем и кладём
        data = {
            "users": User.objects.count(),
            "orders": Order.objects.filter(paid=True).count(),
            "revenue": Order.objects.aggregate(s=Sum("total"))["s"],
        }
        cache.set(key, data, timeout=300)  # на 5 минут
    return data

Паттерн «get → если промах, посчитать → set» настолько частый, что для него есть сокращение cache.get_or_set(key, callable, timeout). Логика та же модель, что и в любом кэше: cache hit (значение нашлось) экономит работу, cache miss (не нашлось) запускает вычисление. Чтобы прочувствовать саму идею промаха и попадания на чистом Python, без Django, посмотрите на встроенный мемоизатор стандартной библиотеки:

from functools import lru_cache

calls = 0

@lru_cache(maxsize=None)
def slow_square(n):
    global calls
    calls += 1          # растёт только при РЕАЛЬНОМ вычислении (промах)
    return n * n

print(slow_square(12))  # miss: вычисляем
print(slow_square(12))  # hit: берём из кэша, calls не растёт
print(slow_square(5))   # miss: новое значение
print("реальных вычислений:", calls)

Вывод:

144
144
25
реальных вычислений: 2

Три вызова, но вычислений всего два: повторный запрос slow_square(12) попал в кэш. Ровно так же ведёт себя cache.get_or_set в Django, только хранилище — Redis, общий для всех процессов, а не память одной функции.

Инвалидация: самое трудное

«В программировании всего две трудные вещи: инвалидация кэша и именование» — шутка, в которой много правды. Кэш полезен ровно до момента, когда данные изменились, а старое значение всё ещё отдаётся. Есть три стратегии сброса.

По времени (TTL). Самый простой подход: запись живёт timeout секунд и сама исчезает. Подходит, когда небольшая задержка актуальности терпима («статистика обновляется раз в 5 минут»). Не требует ничего, кроме выбора числа.

Явный сброс по событию. Когда данные обновились, удаляем ключ руками: cache.delete("dashboard_stats_v1"). Это даёт мгновенную свежесть, но требует не забыть про сброс во всех местах, где данные меняются. Удобно повесить это на сигналы модели:

from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache

@receiver([post_save, post_delete], sender=Order)
def drop_stats_cache(sender, **kwargs):
    # любое изменение заказа делает статистику устаревшей — сбрасываем
    cache.delete("dashboard_stats_v1")

Версионирование ключа. Вместо удаления меняют сам ключ: добавляют в него номер версии или updated_at объекта (f"product_{pk}_{obj.updated_at.timestamp()}"). Старая запись просто перестаёт запрашиваться и протухает по TTL сама, а новый ключ всегда указывает на свежие данные. Это избавляет от гонок «удалили, но кто-то успел записать старое».

Как это работает под капотом

Любой бэкенд кэша в Django реализует один и тот же небольшой интерфейс — get, set, delete, get_many, — поэтому ваш код одинаков что для Redis, что для памяти процесса. Когда вы делаете cache.set("x", obj), Django сериализует объект (по умолчанию через pickle) в байты и отправляет их в хранилище под ключом; cache.get достаёт байты и десериализует обратно. Отсюда два следствия. Во-первых, ключ — это строка, и Django подставляет к ней префикс KEY_PREFIX и версию, чтобы разные проекты и релизы не сталкивались в общем Redis. Во-вторых, кэшируемый объект обязан быть picklable — закэшировать открытый файл или соединение с БД не выйдет. Сам Redis/Memcached хранит всё в оперативной памяти и при нехватке места вытесняет старые записи (LRU), поэтому кэш ненадёжен по своей природе: запись может исчезнуть раньше TTL, и код всегда должен корректно отрабатывать промах — отсюда и обязательная ветка «если None, посчитать».

Частые ошибки

  • Кэшировать страницу целиком там, где есть приватные данные. cache_page отдаёт один ответ всем; корзина или имя пользователя в нём утекут другим. Для персонального — фрагменты или низкий уровень.
  • Использовать LocMemCache в проде. У каждого воркера свой кэш в памяти — попадания случайны, инвалидация одного воркера не видна другим. Нужен общий Redis/Memcached.
  • Забыть про инвалидацию. Закэшировали и не сбрасываете при изменении — пользователи видят старые цены и остатки. Кэш без продуманного сброса опаснее его отсутствия.
  • Не обрабатывать промах. Кэш может вытеснить запись в любой момент; код, который верит, что значение всегда на месте, упадёт. Всегда есть ветка «нет в кэше — посчитать».
  • Слишком длинный TTL для меняющихся данных. Сутки кэша на товар, цена которого правится днём, — гарантированный показ неверной цены. TTL подбирают под реальную частоту изменений.

Итоги

  • Кэш хранит уже вычисленный результат в быстром хранилище; в проде это общий Redis или Memcached, а LocMemCache — только для разработки.
  • Три уровня: вся страница (cache_page), фрагмент шаблона ({% raw %}{% cache %}{% endraw %}) и низкоуровневый cache.get/set для произвольных значений.
  • Базовый паттерн — «get → промах → посчитать → set»; для него есть cache.get_or_set, а кэш всегда может промахнуться и код это учитывает.
  • Инвалидация — главная сложность: по TTL (само протухнет), явным cache.delete по сигналу или сменой версии ключа.
  • Не кэшируйте приватные данные общим ответом и подбирайте TTL под реальную частоту изменения данных.
Проверьте себя
1. Почему LocMemCache не подходит для продакшена с несколькими воркерами?
AОн слишком медленный для production-нагрузки
BОн хранит данные в памяти отдельного процесса, поэтому у каждого воркера свой кэш и инвалидация в одном не видна остальным
CОн не умеет хранить строки, только числа
DОн автоматически очищается каждую секунду
2. В чём опасность декоратора cache_page на странице с данными залогиненного пользователя?
Acache_page работает медленнее обычной вьюхи
Bcache_page кэширует один ответ для всех пользователей, поэтому персональные данные одного человека могут показаться другому
Ccache_page не умеет кэшировать HTML
Dcache_page отключает работу базы данных
3. Какая стратегия инвалидации даёт мгновенную свежесть данных при их изменении?
AПоставить очень большой TTL
BЯвно удалить ключ (cache.delete), например по сигналу post_save модели
CНикогда не вызывать cache.set
DУменьшить размер кэша