Кэш: как ускорить приложение и не начать показывать чужие данные
Кэш даёт самый заметный прирост скорости при минимальных усилиях. Он же приводит к самым неприятным ошибкам: данные, показанные не тому пользователю, обнаруживаются не в логах, а в жалобе.
Прежде чем кэшировать
Кэш скрывает проблему, а не решает её. Запрос, выполняющийся три секунды, останется трёхсекундным — просто реже. Перед кэшированием стоит убедиться, что нет отсутствующего индекса, запроса в цикле или загрузки лишних полей.
Кэш уместен, когда данные дороги в получении, часто запрашиваются и меняются заметно реже, чем читаются.
Уровни
Кэшировать можно на разной высоте, и цена ошибки везде разная.
- Результат запроса к базе — самый безопасный уровень: данные ещё не смешаны с контекстом пользователя.
- Фрагмент страницы — блок «популярные статьи», одинаковый для всех.
- Страница целиком — быстрее всего и опаснее всего: именно здесь чаще всего утекают приватные данные.
- HTTP-кэш (заголовки
Cache-Control,ETag) — работает на стороне браузера и CDN.
Ключ решает всё
Большинство аварий с кэшем — это неполный ключ. Если в ключе не учтён пользователь, первый посетитель наполнит кэш своими данными, а остальные их увидят:
# Опасно: один ключ на всех
key = "dashboard"
# Правильно: всё, от чего зависит результат, входит в ключ
key = f"dashboard:v2:user:{user.id}:lang:{language}"
Префикс версии (v2) — дешёвый приём: при изменении
структуры данных достаточно поднять номер, и старые записи перестают
использоваться без ручной очистки.
Приватные данные разумнее вообще не класть в общий кэш. Если всё же приходится — короткий TTL и обязательный идентификатор пользователя в ключе.
Инвалидация
Два основных подхода, и обычно нужны оба.
По времени (TTL). Просто и надёжно: запись живёт заданное время. Цена — устаревшие данные в течение этого окна. Подходит там, где небольшая задержка допустима: счётчики, списки, агрегаты.
По событию. Кэш сбрасывается при изменении данных. Точно, но требует помнить обо всех местах, где данные меняются — включая админку, консольные команды и миграции. В Django для этого удобны сигналы:
from django.core.cache import cache
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
@receiver([post_save, post_delete], sender=Article)
def drop_article_cache(sender, instance, **kwargs):
cache.delete(f"article:{instance.slug}")
cache.delete("articles:latest")
Разумная стратегия — сбрасывать по событию и всё равно ставить TTL как страховку: если сброс где-то забыт, данные протухнут сами.
Одновременный промах
Популярный ключ истёк — и сотня запросов одновременно обнаружила пустой кэш. Все они идут в базу с одинаковым тяжёлым запросом. Это cache stampede, и он способен уронить базу именно в момент пиковой нагрузки.
Простое средство — блокировка: первый запрос считает, остальные ждут готового результата:
def get_popular_articles():
data = cache.get("articles:popular")
if data is not None:
return data
# add() отработает только у одного процесса — он и пересчитает
if cache.add("lock:articles:popular", "1", timeout=30):
try:
data = expensive_query()
cache.set("articles:popular", data, timeout=300)
finally:
cache.delete("lock:articles:popular")
return data
time.sleep(0.05)
return cache.get("articles:popular") or expensive_query()
Второй приём — разброс времени жизни: если у тысячи ключей TTL ровно
300 секунд, они истекут одновременно. Случайная добавка
(300 + random.randint(0, 60)) размазывает нагрузку.
Чек-лист перед выкладкой
- Входит ли в ключ всё, от чего зависит результат: пользователь, язык, права, версия структуры?
- Что увидит пользователь, если кэш вернёт данные минутной давности?
- Работает ли приложение, если кэш недоступен целиком?
- Сбрасывается ли кэш при изменении через админку и команды?
- Есть ли TTL как страховка от забытой инвалидации?
Что в итоге
Кэш — это осознанный обмен свежести данных на скорость. Опасность не в самом кэшировании, а в неявных предположениях: что ключ полон, что сброс сработает и что устаревшие данные никому не помешают. Каждое из них стоит проверить до продакшна, а не после.