Cache backends, per-view cache, template fragments, low-level API, invalidation
Статус: актуально для Django 5.2/6.0. В Django есть встроенный backend
RedisCache; расширения конкретного Redis-клиента остаются API стороннего пакета.
Кэш хранит результат дорогой операции ближе к месту чтения. Он ускоряет приложение только тогда, когда попадания достаточно часты, а стоимость согласованности ниже стоимости повторного вычисления.
Полезная модель: база данных — источник истины, кэш — удаляемая копия. После полного удаления кэша приложение должно остаться корректным, хотя и может стать медленнее. Если потеря записи кэша нарушает бизнес-данные, это уже не кэш.
До добавления кэша ответьте на четыре вопроса:
Ключ — часть контракта. Он должен включать каждое измерение, меняющее результат: tenant, пользователя или роль, язык, параметры фильтра, страницу, версию представления. Пропущенный tenant превращает оптимизацию в утечку данных.
def post_list_key(*, tenant_id, locale, page, generation):
return f"posts:v3:{tenant_id}:{locale}:{page}:g{generation}"Не помещайте в ключ email, access token и другие секреты: ключи видны в диагностике и метриках. Длинные произвольные фильтры можно сначала нормализовать и хешировать.
Для встроенного backend нужен Python-клиент redis. Адрес и credentials берут из окружения или secret manager, а не фиксируют в репозитории.
import os
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ["CACHE_URL"],
"TIMEOUT": 300,
"KEY_PREFIX": "catalog",
"VERSION": 3,
},
}KEY_PREFIX разделяет приложения или окружения, VERSION меняет пространство ключей backend. Они не освобождают от прикладной версии формата значения. Никогда не направляйте development и production в одну Redis database с одинаковыми префиксами.
В production настройте сетевые timeouts, TLS и аутентификацию согласно инфраструктуре. Решите, что делает приложение при недоступности Redis: для обычного cache-aside разумно вычислить значение заново, но бесконечные медленные fallback-запросы тоже способны перегрузить базу.
django-redis остаётся популярным сторонним backend и предоставляет дополнительные возможности, например доступ к нативному клиенту. Не смешивайте его методы с гарантированным Django cache API в переносимом коде.
from django.core.cache import cache
cache.set("currency:USD", "92.40", timeout=300)
value = cache.get("currency:USD")
cache.delete("currency:USD")
cache.set_many({"feature:a": True, "feature:b": False}, timeout=60)
flags = cache.get_many(["feature:a", "feature:b"])
cache.delete_many(["feature:a", "feature:b"])cache.clear() очищает весь выбранный backend и может удалить ключи других компонентов. В прикладном запросе почти всегда нужен delete(), namespace/generation или версионирование.
NoneОбычный get() возвращает None и при промахе, и когда сохранено значение None. Для negative caching используйте уникальный sentinel:
from django.core.cache import cache
MISSING = object()
def get_public_post(post_id):
key = f"post:public:v2:{post_id}"
cached = cache.get(key, MISSING)
if cached is not MISSING:
return cached
post = (
Post.objects.filter(pk=post_id, status=Post.Status.PUBLISHED)
.values("id", "title", "updated_at")
.first()
)
cache.set(key, post, timeout=60 if post is None else 600)
return postОтсутствие обычно кэшируют ненадолго: иначе недавно созданный объект долго будет выглядеть несуществующим.
Хранить простой DTO/словарь безопаснее, чем целый instance модели. Pickle-представление зависит от кода, может устареть после deploy и способно нести неожиданно загруженные связи. Не считайте данные из недоверенного cache безопасными для десериализации.
get_or_set() и конкуренцияvalue = cache.get_or_set("report:today", build_report, timeout=300)Передача callable откладывает вычисление до cache miss, но не даёт универсальной гарантии «функция выполнится ровно один раз» для всех конкурентных запросов. Для дешёвого повторного вычисления это нормально. Для дорогого — нужен отдельный план защиты от stampede.
Атомарность add(), incr() и других операций зависит от backend. Проверяйте гарантии выбранного хранилища, а не переносите поведение LocMemCache на Redis или наоборот.
В распространённой схеме приложение сначала читает кэш, при промахе обращается к источнику истины и кладёт результат в кэш:
from django.core.cache import cache
def get_category_summary(*, tenant_id, category_id):
key = f"category-summary:v2:{tenant_id}:{category_id}"
summary = cache.get(key)
if summary is not None:
return summary
summary = build_category_summary(
tenant_id=tenant_id,
category_id=category_id,
)
cache.set(key, summary, timeout=300)
return summaryЕсли None допустим как результат, применяйте sentinel. Функция построения должна сама ограничивать tenant и видимость: кэш не исправляет небезопасный исходный запрос.
Рассмотрим запись внутри транзакции. Если удалить ключ до commit, параллельный запрос увидит старое состояние базы и снова закэширует его. После commit кэш останется устаревшим.
from django.core.cache import cache
from django.db import transaction
def rename_post(*, post, new_title):
with transaction.atomic():
post.title = new_title
post.save(update_fields=["title", "updated_at"])
transaction.on_commit(
lambda: cache.delete(f"post:detail:v2:{post.pk}")
)on_commit() не выполняется при rollback. Для нескольких известных ключей используйте delete_many() после commit.
Сигнал post_save тоже срабатывает внутри окружающей транзакции. Если инвалидация живёт в сигнале, он должен зарегистрировать on_commit(), а не удалять ключ немедленно. Явный сервис записи часто легче понять и протестировать, чем сеть сигналов.
Список может зависеть от тысяч объектов, и перечислить все его ключи сложно. Один из вариантов — поколение:
generation = cache.get("posts:generation", 1)
key = f"posts:list:v3:{tenant_id}:g{generation}:p{page}"После успешной записи generation увеличивают. Старые ключи становятся недостижимыми и исчезают по TTL. Для конкурентного incr() заранее создайте ключ и учитывайте гарантии backend. Поколение также должно быть разделено по tenant, если изменения одной организации не должны сбрасывать кэш всех остальных.
from django.views.decorators.cache import cache_page
@cache_page(60 * 5, key_prefix="public-catalog-v2")
def catalog(request):
return render(request, "catalog/list.html", build_context())Per-view cache предназначен для ответов GET/HEAD со статусом 200 и строит ключ с учётом URL. Корректные Vary headers влияют на варианты ответа. Это удобно для действительно публичной страницы, но опасно для персонального кабинета: убедитесь, что cookie, Authorization, язык и другие измерения не смешивают ответы пользователей.
cache_page сохраняет готовый HTTP-ответ, поэтому вместе с ним могут кэшироваться заголовки. Не применяйте его механически к endpoint, который устанавливает cookie или возвращает приватные данные. Иногда безопаснее кэшировать только общий DTO low-level API, а персональную оболочку строить каждый раз.
{% load cache %}
{% cache 300 public_sidebar request.LANGUAGE_CODE using="alternate" %}
{% include "catalog/_sidebar.html" %}
{% endcache %}После timeout указывают имя фрагмента и все переменные, от которых зависит HTML. using="alternate" выбирает alias и должен стоять последним.
Исправлено относительно legacy-версии курса: аргумент
version=2не является синтаксисом стандартного шаблонного тега{% cache %}. Версию включают в имя фрагмента, напримерpublic_sidebar_v2, либо используют версию low-level cache API.
Если фрагмент зависит от разрешений, feature flags или timezone, одного user.id может быть недостаточно. Проще не кэшировать маленький персональный блок, чем доказывать безопасность неполного ключа.
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
SESSION_CACHE_ALIAS = "sessions"cached_db сохраняет данные в базе и ускоряет чтение через cache. При промахе он может восстановить сессию из базы. Чистый backend django.contrib.sessions.backends.cache хранит сессию только в cache: eviction или очистка Redis разлогинит пользователей, а неподходящий backend без надёжной persistence способен потерять все сессии.
Выделяйте сессиям отдельный cache alias/политику памяти и не очищайте его вместе с прикладными ключами. Redis persistence не превращает cache в безусловно надёжную базу.
Когда популярный ключ истекает, множество workers одновременно перестраивают его и перегружают базу. Возможные стратегии:
Стандартный объект django.core.cache.cache не обещает метод lock(). Такой метод может предоставлять сторонний Redis backend или нативный клиент.
Наивная схема cache.add(lock_key) плюс безусловный cache.delete(lock_key) опасна: если построение длится дольше TTL, блокировку успеет получить другой worker, а первый удалит уже чужой lock. Корректный release сравнивает owner token атомарно, обычно Lua-командой Redis или проверенной библиотекой. Однократный sleep(0.1) также не гарантирует, что значение успело появиться.
Собирайте не содержимое ключей, а агрегаты:
Не логируйте каждый miss как warning: после deploy или очистки это нормальное состояние и источник шума. Используйте метрики и sampling.
Тесты должны доказывать:
LocMemCache полезен локально, но каждый процесс имеет собственное изолированное хранилище.cache.get_or_set() удобен, но не является распределённым single-flight.cache.clear() почти всегда слишком широка.Хороший кэш ускоряет наблюдаемое узкое место, не расширяет доступ к данным и имеет документированное поведение при устаревании, гонке и полном отказе Redis.
Далее: Безопасность: Hardening