Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Кэширование с Redis
caching_redis

Кэширование с Redis

Cache backends, per-view cache, template fragments, low-level API, invalidation

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

Статус: актуально для Django 5.2/6.0. В Django есть встроенный backend RedisCache; расширения конкретного Redis-клиента остаются API стороннего пакета.

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

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

#Сначала измерение, затем ключ

До добавления кэша ответьте на четыре вопроса:

  1. какая операция действительно дорога по профилю или метрикам;
  2. какую долю запросов можно обслужить одной копией;
  3. насколько устаревшее значение допустимо;
  4. кто и после какого события инвалидирует копию.

Ключ — часть контракта. Он должен включать каждое измерение, меняющее результат: tenant, пользователя или роль, язык, параметры фильтра, страницу, версию представления. Пропущенный tenant превращает оптимизацию в утечку данных.

def post_list_key(*, tenant_id, locale, page, generation): return f"posts:v3:{tenant_id}:{locale}:{page}:g{generation}"

Не помещайте в ключ email, access token и другие секреты: ключи видны в диагностике и метриках. Длинные произвольные фильтры можно сначала нормализовать и хешировать.

#Встроенный Redis backend

Для встроенного 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 в переносимом коде.

#Low-level 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 или наоборот.

#Cache-aside

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

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, параллельный запрос увидит старое состояние базы и снова закэширует его. После 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, если изменения одной организации не должны сбрасывать кэш всех остальных.

#Кэш целой view

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 может быть недостаточно. Проще не кэшировать маленький персональный блок, чем доказывать безопасность неполного ключа.

#Сессии в cache

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 в безусловно надёжную базу.

#Cache stampede

Когда популярный ключ истекает, множество workers одновременно перестраивают его и перегружают базу. Возможные стратегии:

  • добавить небольшой случайный jitter к TTL, чтобы связанные ключи не истекали в одну секунду;
  • заранее обновлять популярные значения фоновой задачей;
  • отдавать немного устаревшее значение по схеме stale-while-revalidate;
  • использовать распределённую блокировку с уникальным owner token и безопасным release;
  • ограничить параллелизм дорогого построения на уровне сервиса.

Стандартный объект 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) также не гарантирует, что значение успело появиться.

#Наблюдаемость и тесты

Собирайте не содержимое ключей, а агрегаты:

  • hit/miss и latency по операции/namespace;
  • ошибки и timeouts cache backend;
  • объём памяти, eviction и число подключений Redis;
  • длительность fallback-вычисления;
  • нагрузку на базу во время cache miss.

Не логируйте каждый miss как warning: после deploy или очистки это нормальное состояние и источник шума. Используйте метрики и sampling.

Тесты должны доказывать:

  1. одинаковый запрос попадает в кэш;
  2. разные tenant, пользователи и параметры не делят приватное значение;
  3. после commit ключ инвалидируется, после rollback — нет;
  4. cache miss и отказ Redis не меняют бизнес-результат согласно выбранной политике;
  5. deploy новой схемы данных не читает несовместимый старый формат.

#Legacy и актуальные выводы

  • LocMemCache полезен локально, но каждый процесс имеет собственное изолированное хранилище.
  • Redis — частый production-выбор, но не автоматическая рекомендация для любой задачи. Иногда достаточно оптимизировать SQL или вообще не кэшировать.
  • cache.get_or_set() удобен, но не является распределённым single-flight.
  • Инвалидация через cache.clear() почти всегда слишком широка.
  • Write-through «сначала БД, затем cache» без учёта транзакции и сбоя между двумя записями не гарантирует согласованность.

Хороший кэш ускоряет наблюдаемое узкое место, не расширяет доступ к данным и имеет документированное поведение при устаревании, гонке и полном отказе Redis.

#Официальная документация

  • Django: cache framework
  • Django: sessions

Далее: Безопасность: Hardening