Кэширование в 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 под реальную частоту изменения данных.