Кеш-инвалидация в Django: как устроено и где ломается
Устаревшие данные в кеше — не баг фреймворка, а последствие выбранной стратегии. Разбираем, как Django ставит и снимает ключи, почему TTL врёт и где инвалидация даёт гонки.

Пользователь обновил профиль, нажал сохранить, попал на свою же страницу — и увидел старое имя. В базе новое, в ответе старое. Это не редкий баг: почти каждый проект на Django, который дорос до кеша, проходит через момент, когда закешированные данные и реальные расходятся. Разберём, что именно происходит внутри Django при записи и чтении кеша, почему timeout не спасает и в каких точках инвалидация ломается под конкурентными запросами.
Речь про Django 4.2 и 5.x с бэкендом django.core.cache.backends.redis.RedisCache (встроенный, доступен с Django 4.0) либо django-redis. Механика ключей одинакова для обоих; различия отмечу отдельно.
Что Django на самом деле хранит и по какому ключу
Когда вы вызываете cache.set("user_profile", data, timeout=300), Django не пишет ключ user_profile напрямую. Он прогоняет его через функцию, которая добавляет префикс и версию. Итоговый ключ по умолчанию собирается как KEY_PREFIX:VERSION:key. Это делает make_key у бэкенда, а формат задаётся KEY_FUNCTION в настройках кеша.
Практическое следствие: два разных проекта, смотрящих в один Redis без KEY_PREFIX, будут затирать ключи друг друга. И наоборот — если вы меняете VERSION в конфиге, старые ключи мгновенно становятся недостижимыми. Это самый грубый способ инвалидации: сдвинули версию — весь кеш «пропал», хотя физически он ещё лежит в Redis до истечения TTL.
# settings.py, Django 5.x
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"KEY_PREFIX": "shop", # изоляция от других проектов в том же Redis
"VERSION": 1, # инкремент = массовая инвалидация
}
}Ключ, который реально уйдёт в Redis для cache.set("user:42", ...), будет выглядеть как shop:1:user:42. Держите это в голове, когда лезете в Redis через redis-cli и не находите ключ по имени, которое передавали в код.
Три стратегии инвалидации и чем каждая платит
Инвалидация — это ответ на вопрос «когда закешированное значение перестаёт быть правдой». Способов заставить кеш это заметить всего три, и они не взаимозаменяемы.
1. TTL: пусть протухнет само
Вы задаёте timeout, и Redis сам удалит ключ по истечении. Просто, но данные живут устаревшими весь срок TTL. Для списка категорий магазина, который меняется раз в месяц, TTL в час нормален. Для остатка товара на складе TTL в 5 минут означает, что пять минут вы продаёте то, чего нет.
Отдельная ловушка: timeout=None в Django означает «хранить вечно», а timeout=0 — «не кешировать вообще» (значение сразу считается истёкшим). Их легко перепутать, и 0 вместо None тихо отключает кеш.
2. Явное удаление по событию
При изменении объекта вы вызываете cache.delete() для затронутых ключей. Данные всегда свежие, но вы обязаны точно знать все ключи, которые зависят от изменённого объекта. На практике эту логику вешают на сигналы post_save и post_delete.
# signals.py, Django 4.2+
from django.core.cache import cache
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from .models import Product
def product_cache_key(product_id):
return f"product:{product_id}"
@receiver([post_save, post_delete], sender=Product)
def invalidate_product(sender, instance, **kwargs):
# Снимаем карточку товара
cache.delete(product_cache_key(instance.pk))
# И связанный список, где товар мог участвовать
cache.delete(f"category:{instance.category_id}:products")
Цена — связанность. Каждый новый экран, который кеширует этот товар, добавляет новый ключ, который надо не забыть снять в сигнале. Забыли — получили тот самый рассинхрон из первого абзаца. Django не отслеживает зависимости между ключами за вас: список ключей на удаление вы поддерживаете руками.
3. Версионирование ключа через тег
Вместо удаления вы включаете в ключ значение-версию, которое инкрементите при изменении. Старый ключ остаётся в Redis, но код на него больше не сошлётся.
from django.core.cache import cache
def get_catalog(category_id):
ver = cache.get(f"catalog_ver:{category_id}", 1)
key = f"catalog:{category_id}:v{ver}"
data = cache.get(key)
if data is None:
data = build_catalog(category_id) # тяжёлый запрос в БД
cache.set(key, data, timeout=3600)
return data
def bump_catalog(category_id):
# incr атомарен на стороне Redis
try:
cache.incr(f"catalog_ver:{category_id}")
except ValueError:
cache.set(f"catalog_ver:{category_id}", 2)
Плюс: одним инкрементом вы инвалидируете сразу все производные ключи категории, не перечисляя их. Минус: старые версии висят в Redis до своего TTL и жрут память. Если bump частый, а TTL длинный, память течёт.
Инвалидация никогда не бывает бесплатной. TTL платит устаревшими данными, явное удаление — связанностью кода, версионирование — памятью Redis. Выбор — это выбор валюты, которой вы платите.
Где это ломается под нагрузкой
Гонка «прочитали пусто — записали старое»
Классический сценарий на конкурентных запросах:
- Запрос A читает кеш, там пусто (только что инвалидировали), идёт в БД.
- Пока A читает БД, приходит запись — данные в БД меняются.
- Запрос B тоже читает БД, но уже новые данные, и кладёт их в кеш.
- Запрос A возвращается со старыми данными и перезаписывает свежий ключ B.
Django не защищает от этого сам по себе. cache.set — это безусловная перезапись. Смягчается коротким TTL (аномалия живёт недолго) либо блокировкой на пересчёт: только один процесс строит значение, остальные ждут. В django-redis для этого есть cache.lock(); во встроенном бэкенде своей блокировки нет — берите распределённый лок вручную.
Cache stampede: толпа на холодный ключ
Ключ протух, и в ту же миллисекунду 300 запросов не нашли его в кеше и одновременно пошли в БД строить одно и то же. База ложится не от нагрузки на чтение кеша, а от синхронного промаха. Лечится тем же локом на пересчёт или «мягким» TTL: храните рядом с данными их логический срок и обновляйте фоново, пока старое значение ещё отдаётся.
Инвалидация до коммита транзакции
Если вы вызываете cache.delete() внутри post_save, а сохранение идёт в транзакции, есть окно: ключ удалён, но транзакция ещё не закоммичена. Параллельный запрос успеет перечитать старые данные из БД и вернуть их в кеш. Правильно — откладывать инвалидацию до фактического коммита:
from django.db import transaction
from django.core.cache import cache
@receiver(post_save, sender=Product)
def invalidate_after_commit(sender, instance, **kwargs):
transaction.on_commit(
lambda: cache.delete(f"product:{instance.pk}")
)
transaction.on_commit гарантирует, что колбэк выполнится только после успешного коммита. Вне транзакции он срабатывает немедленно, так что код безопасен в обоих режимах.
Как выбирать стратегию
| Ситуация | Стратегия | Почему |
|---|---|---|
| Данные меняются редко, устаревание терпимо | TTL | Минимум кода, нет связанности |
| Один объект, известны все зависимые ключи | Явное удаление на on_commit | Свежесть без гонок с транзакцией |
| Много производных ключей от одной сущности | Версионирование | Один incr вместо перечисления ключей |
| Тяжёлый пересчёт, высокая параллельность | TTL + лок на пересчёт | Защита от stampede |
Что делать дальше
Первое — включите мониторинг hit rate. В django-redis статистику удобно снимать через сам Redis: redis-cli INFO stats покажет keyspace_hits и keyspace_misses. Отношение промахов к попаданиям — главный индикатор того, что инвалидация слишком агрессивна или TTL слишком короткий.
Второе — заведите единый модуль, где живут функции построения ключей (как product_cache_key выше). Разбросанные по вьюхам f-строки с ключами — прямой путь к рассинхрону, потому что при удалении вы будете собирать ключ иначе, чем при записи.
Прогрев кеша и стратегии подогрева холодного старта — отдельная тема, здесь мы разбирали только инвалидацию. За деталями блокировок и TTL смотрите документацию бэкенда: у встроенного RedisCache и у django-redis набор методов отличается.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Django documentation — Cache framework, django-redis documentation
Частые вопросы
У меня Django 3.2, встроенного RedisCache там нет. Что делать?
cache.delete() не сработал, данные всё равно старые. Почему?
Как инвалидировать все ключи по маске, например product:*?
Грозит ли рассинхрон, если я просто поставлю очень короткий TTL?
Нужен ли лок на пересчёт всегда?
Почему cache.set с timeout=0 отключил кеш вместо мгновенного хранения?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.