Инвалидация кеша в Django: разбор инцидента
После обновления каталога на Django-проекте пользователи ещё 40 минут видели старые цены — при том, что кеш должен был сброситься сразу. Разбираем, как устроена инвалидация кеша в Django, почему она ломается и что с этим делать.

Что произошло: старые цены после обновления каталога
Интернет-магазин на Django 5.0, бэкенд кеширования — Redis (django-redis 5.4), TTL фрагментных кешей — 15 минут. Менеджер обновил цены через админку, Django-сигнал post_save вызвал cache.delete() по нужным ключам. Мониторинг показал, что ключи действительно удалились из Redis в ту же секунду. Но пользователи продолжали видеть старые цены — в отдельных случаях до 40 минут. Техподдержка получила девять обращений за вечер.
Проблема оказалась не в Redis и не в сигналах. Она была в том, как именно проект использовал несколько слоёв кеша одновременно, — и ни один из них не знал о существовании другого.
Архитектура кеширования: где прячутся слои
В типичном Django-проекте кеш — это не одна точка, а стопка слоёв, каждый из которых может хранить устаревшую копию данных.
- Кеш шаблонных фрагментов — тег
{% cache %}в шаблонах. Ключ формируется из имени фрагмента и аргументов. - Кеш представлений — декораторы
cache_page,cache_control, middlewareUpdateCacheMiddleware/FetchFromCacheMiddleware. - Ручной кеш — прямые вызовы
cache.set()/cache.get()в бизнес-логике. - Upstream-кеш — Nginx, Varnish, CDN. Django о них ничего не знает.
В нашем инциденте cache.delete() чистил ручные ключи. Но карточка товара рендерилась через {% cache 900 product_card product.pk %} — шаблонный фрагмент с собственным ключом и 15-минутным TTL. А страница каталога дополнительно проходила через cache_page(600). Удаление ручного ключа не трогало ни фрагментный кеш, ни кеш страницы — у них разные ключи, генерируемые разным кодом.
Как Django строит ключи
Фрагментный кеш формирует ключ через make_template_fragment_key(fragment_name, vary_on). Ключ от cache_page строится из URL, метода запроса, заголовков и настройки KEY_PREFIX. Ручной ключ — любая строка, которую вы передали сами. Три механизма, три пространства ключей, ни одного общего реестра.
«There are only two hard things in Computer Science: cache invalidation and naming things.» — Phil Karlton. В Django к этому добавляется третье: помнить, сколько кешей вы на самом деле используете.
Почему cache.delete() не помог
Разберём цепочку по шагам.
- Менеджер сохраняет товар в админке.
- Сигнал
post_saveвызываетcache.delete(f"product_data_{product.pk}"). - Redis удаляет ключ
product_data_42. Это подтверждается в логах. - Пользователь открывает каталог. Middleware
FetchFromCacheMiddlewareнаходит закешированную страницу каталога (ключ вида:1:views.decorators.cache.cache_page...) и отдаёт её, не дойдя до view. - Даже если middleware отключён, шаблон рендерит фрагмент
{% cache 900 product_card 42 %}— и берёт его из кеша, где лежит старая цена.
Итог: из трёх слоёв инвалидирован один. Пользователь видит устаревшие данные до истечения TTL двух оставшихся.
Четыре подхода к инвалидации
1. Версионирование ключа
Вместо удаления ключа — изменение версии, встроенной в ключ. При каждом обновлении товара инкрементируется счётчик, и все слои кеша промахиваются, потому что запрашивают ключ с новой версией.
# Django 5.x, django-redis 5.4
from django.core.cache import cache
def get_product_version(product_id: int) -> int:
"""Возвращает текущую версию кеша товара."""
version_key = f"product_version_{product_id}"
version = cache.get(version_key)
if version is None:
version = 1
cache.set(version_key, version, timeout=None)
return version
def bump_product_version(product_id: int) -> int:
"""Инкрементирует версию — все старые ключи становятся мусором."""
version_key = f"product_version_{product_id}"
try:
return cache.incr(version_key)
except ValueError:
cache.set(version_key, 1, timeout=None)
return 1
В шаблоне фрагмент получает версию как аргумент:
{% cache 900 product_card product.pk product_cache_version %}
... карточка товара ...
{% endcache %}
Переменную product_cache_version view передаёт в контекст, вызвав get_product_version(product.pk). После сохранения товара сигнал вызывает bump_product_version(product.pk) — и следующий запрос уже получает промах кеша.
Цена: старые ключи остаются в Redis до истечения TTL. При большом каталоге это мусор, но Redis справляется — ключи лёгкие, а TTL конечен.
2. Прямое удаление всех связанных ключей
Можно вычислить ключи фрагментного кеша через make_template_fragment_key и удалить их явно:
from django.core.cache.utils import make_template_fragment_key
from django.core.cache import cache
def invalidate_product_fragments(product_id: int) -> None:
"""Удаляет фрагментный кеш карточки товара."""
key = make_template_fragment_key("product_card", [product_id])
cache.delete(key)
Цена: нужно вручную перечислить каждый фрагмент и каждый аргумент. Если кто-то добавит новый {% cache %} блок и забудет обновить инвалидатор — инцидент повторится. Подход не масштабируется дальше трёх-четырёх фрагментов.
3. Отказ от фрагментного кеша в пользу данных
Кешировать не HTML-фрагмент, а результат запроса к базе. View всегда рендерит шаблон, но данные берёт из кеша. Инвалидировать — один ключ.
Цена: шаблон рендерится при каждом запросе. Если рендеринг тяжёлый (сложная вёрстка, фильтры, форматирование), нагрузка на CPU растёт. В нашем проекте рендеринг каталога из 60 карточек занимал ~35 мс — допустимо. Но при 200+ карточках с вложенными шаблонами уже ощутимо.
4. Сигнальная инвалидация по группе ключей
Хранить в Redis множество (set) ключей, связанных с товаром. При обновлении — пройтись по множеству и удалить всё. По сути, самодельный реестр зависимостей.
Цена: дополнительная запись при каждом cache.set(), усложнение кода, риск рассинхронизации самого реестра.
| Подход | Надёжность инвалидации | Сложность внедрения | Мусор в Redis | Когда выбирать |
|---|---|---|---|---|
| Версионирование ключа | Высокая | Низкая | Умеренный | Основной выбор для большинства проектов |
| Прямое удаление | Средняя (хрупкая) | Низкая | Нет | Мало фрагментов, команда маленькая |
| Кеш данных, не HTML | Высокая | Средняя (рефакторинг шаблонов) | Нет | Лёгкий рендеринг, тяжёлые запросы к БД |
| Реестр зависимостей | Высокая | Высокая | Нет | Сложные графы зависимостей между сущностями |
Слой, о котором забыли: upstream
Даже если Django инвалидирует всё корректно, ответ может застрять в Nginx. Директива proxy_cache_valid 200 10m; в конфиге Nginx означает, что десять минут после первого запроса клиент получает копию из Nginx, а Django вообще не получает запрос.
Варианты:
- Отдавать заголовок
Cache-Control: no-cache, must-revalidateдля страниц с динамическими ценами и полагаться наETag/Last-Modifiedдля условных запросов. - Использовать короткий
max-age(30–60 секунд) и принять, что цена может отставать на минуту. - Активно очищать upstream-кеш через API (Varnish
BAN, Nginxproxy_cache_purge) — но это уже отдельная инфраструктурная задача.
В нашем случае Nginx с proxy_cache_valid 200 5m; добавлял до пяти минут сверху. После перехода на Cache-Control: max-age=30 для каталога разрыв сократился до приемлемых 30 секунд.
Граничные случаи и где подход ломается
- Массовое обновление. Импорт 10 000 товаров через management-команду. Сигнал
post_saveсрабатывает 10 000 раз, каждый вызываетcache.incr()— Redis справится, но если инвалидация тяжелее (удаление группы ключей), пайплайн Redis может стать узким местом. Решение: при массовых операциях отключать сигнал и делать одинcache.clear()или версионировать весь каталог одним глобальным ключом. - Гонка между записью и чтением. Запрос A читает данные из БД (старая цена), запрос B обновляет товар и инвалидирует кеш, запрос A записывает прочитанные старые данные в кеш. Итог — кеш снова содержит устаревшую цену. Версионирование ключа решает это: запрос A пишет в ключ со старой версией, а все новые запросы читают ключ с новой.
- Многопроцессный локальный кеш. Если используется
LocMemCache(илиdjango.core.cache.backends.locmem.LocMemCache) в связке с Redis через цепочку, каждый воркер Gunicorn хранит свою копию. Инвалидация Redis не затрагивает локальную память других процессов. Не используйтеLocMemCacheв продакшене для данных, которые должны быть консистентными между воркерами.
* Фрагментный кеш (template fragment cache) — механизм Django, позволяющий кешировать отрендеренный HTML-фрагмент прямо в шаблоне с помощью тега {% cache %}. Ключ кеша вычисляется из имени фрагмента и переданных аргументов.
Что делать дальше
Если у вас Django-проект с кешированием в продакшене, проведите аудит: выпишите все точки, где создаётся кеш (шаблонные фрагменты, декораторы, ручные вызовы, Nginx), и все точки, где он должен инвалидироваться. Нарисуйте матрицу «сущность → ключи кеша». Если хотя бы одна ячейка пустая — у вас потенциальный инцидент. Внедрите версионирование ключей для сущностей, которые обновляются чаще раза в час, и добавьте в мониторинг метрику cache hit rate по каждому слою отдельно — это первое, что поможет заметить проблему до того, как о ней напишет пользователь.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Django documentation: The cache framework, django-redis documentation
Частые вопросы
Почему cache.delete() не сбрасывает кеш шаблонного фрагмента?
Не приведёт ли версионирование ключей к утечке памяти в Redis?
Как инвалидировать кеш при массовом импорте товаров?
Что если у меня другая версия Django — подход с версионированием сработает?
Как обнаружить проблему с инвалидацией до жалоб пользователей?
Гарантирует ли версионирование защиту от гонки кеша?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.