Инвалидация кеша в Python: чем платит архитектура
Кеш ускоряет чтение, но инвалидация растекается по всему коду и превращает простой lru_cache в источник рассинхрона. Разбираем, где ломается инвалидация под нагрузкой и что она делает с архитектурой сервиса.

Сервис отдаёт карточку товара за 4 мс из Redis вместо 60 мс из PostgreSQL. Потом маркетолог меняет цену, коммит проходит, а на витрине висит старая цена ещё сорок минут — до истечения TTL. Хуже: под нагрузкой один инстанс уже обновил кеш, второй ещё нет, и два пользователя видят разную цену на одном SKU. Инвалидация — это не строчка cache.delete(key), а решение, которое протекает в каждый слой приложения. Разберём, какие архитектурные последствия тянет за собой попытка держать кеш согласованным с источником данных.
Угол этой статьи — именно инвалидация и её цена для структуры кода. Прогрев кеша, выбор бэкенда (Redis против Memcached) и сериализацию значений здесь не рассматриваем — это отдельные темы.
Почему инвалидация ломает границы модулей
Наивная схема выглядит чисто: функция чтения кладёт значение в кеш, функция записи его удаляет. Проблема в том, что удалять должен тот, кто пишет в источник, а знает ключ тот, кто читает. Эти два места почти никогда не совпадают.
Пример: карточку товара читает сервис витрины, а цену меняет админка через отдельный репозиторий заказов. Чтобы админка инвалидировала кеш витрины, ей нужно знать формат ключа витрины. Появляется скрытая зависимость: модуль записи знает о существовании кеша модуля чтения. Это то самое протекание, из-за которого «просто добавить кеш» превращается в рефакторинг границ.
Кеш без инвалидации — это база данных, о рассинхроне которой вы узнаёте от пользователя, а не из мониторинга.
Есть три архитектурных ответа на вопрос «кто и когда сбрасывает кеш», и у каждого своя цена.
Подход 1. TTL без явной инвалидации
Значение живёт фиксированное время и обновляется само. Кода на инвалидацию нет вообще — модуль записи ничего не знает о кеше.
Плата: окно рассинхрона равно TTL. Для цен это может быть неприемлемо, для списка категорий, которые меняются раз в месяц, — идеально. Второй скрытый счёт — cache stampede*: когда популярный ключ протухает, сотни запросов одновременно идут в базу за одним и тем же значением.
Подход 2. Явная инвалидация по событию записи
Тот, кто меняет данные, сбрасывает ключ. Окно рассинхрона стремится к нулю, но появляется связанность модулей и новый класс багов: забыли сбросить один из ключей, где то же значение участвует в составном представлении.
Плата: каждый новый способ прочитать данные — это новый ключ, который кто-то должен не забыть инвалидировать. Инвалидация не масштабируется линейно с ростом числа представлений.
Подход 3. Инвалидация через шину событий
Модуль записи публикует событие «товар N изменён», а подписчики (в том числе кеш) реагируют. Связанность разрывается: писатель не знает про кеш, кеш знает только про событие.
Плата: появляется инфраструктура (брокер, доставка, обработка дубликатов) и eventual consistency становится явной. Окно рассинхрона теперь равно лагу доставки события, а не нулю.
Сравнение подходов
| Критерий | TTL | Явный сброс | Шина событий |
|---|---|---|---|
| Окно рассинхрона | = TTL | ≈ 0 (если не забыли ключ) | = лаг доставки |
| Связанность модулей | Нет | Высокая | Низкая (через событие) |
| Риск забытого ключа | Нет | Высокий | Средний |
| Инфраструктура | Только кеш | Только кеш | + брокер |
| Поведение под нагрузкой | Stampede при протухании | Всплеск инвалидаций | Отложенное согласование |
Где подходы ломаются под нагрузкой
Архитектурное решение проверяется не на демо, а когда параллельных запросов становится много.
- Cache stampede. Ключ протух, 500 воркеров одновременно промахнулись и пошли в базу. База ложится. Лечится блокировкой на пересчёт (один воркер считает, остальные ждут или отдают старое значение) либо вероятностным ранним обновлением. Это меняет код чтения: он перестаёт быть «прочитал или посчитал» и обрастает координацией.
- Гонка чтение-запись. Классический баг паттерна cache-aside: воркер A прочитал старое значение из базы, воркер B обновил базу и сбросил кеш, воркер A записал в кеш прочитанное старое значение. Результат — устаревшие данные до следующей записи. Порядок операций «сначала инвалидация, потом запись» проблему не решает полностью, только сдвигает окно.
- Составные ключи. Товар входит в кеш карточки, кеш списка категории и кеш поисковой выдачи. Явная инвалидация должна знать про все три. Пропущенный ключ живёт до TTL — и это самый частый источник «у меня всё поменялось, а у клиента нет».
- Инвалидация по шаблону. Соблазн сделать
redis.keys("product:*")и снести пачкой. КомандаKEYSв Redis блокирующая и сканирует всё пространство ключей — на большой базе это фриз. Для перебора нуженSCANс курсором, но и он не атомарен.
Как это выглядит в коде
Показываю разницу между наивным cache-aside и вариантом с защитой от stampede через блокировку. Redis-py 5.x, Python 3.11+. Обвязку сериализации опускаю намеренно — фокус на инвалидации и координации.
import time
import redis
client = redis.Redis(host="localhost", port=6379, decode_responses=True)
# Наивный cache-aside: уязвим к stampede и к гонке чтение-запись
def get_product_naive(product_id: int) -> dict:
key = f"product:{product_id}"
cached = client.get(key)
if cached is not None:
return parse(cached) # своя десериализация
# промах: все параллельные воркеры окажутся здесь одновременно
value = load_from_db(product_id)
client.set(key, serialize(value), ex=300) # TTL 5 минут
return value
# Вариант с блокировкой на пересчёт: в базу идёт только один воркер
def get_product_locked(product_id: int) -> dict:
key = f"product:{product_id}"
lock_key = f"lock:{key}"
cached = client.get(key)
if cached is not None:
return parse(cached)
# SET NX EX — атомарная попытка захватить право на пересчёт
got_lock = client.set(lock_key, "1", nx=True, ex=10)
if got_lock:
try:
value = load_from_db(product_id)
client.set(key, serialize(value), ex=300)
return value
finally:
client.delete(lock_key)
else:
# кто-то уже считает: ждём немного и читаем снова
time.sleep(0.05)
cached = client.get(key)
return parse(cached) if cached else load_from_db(product_id)
Обратите внимание на цену второго варианта: код чтения стал координацией нескольких процессов. Тестировать его сложнее, гонок в нём больше, а не меньше — они просто загнаны в узкое место с блокировкой. Это и есть архитектурное последствие: защита от stampede делает слой чтения stateful по своей сути.
Для инвалидации по событию сброс выносится из кода записи в отдельный обработчик:
# Обработчик события "товар изменён": знает только про событие, не про витрину
def on_product_changed(product_id: int) -> None:
# сбрасываем все известные представления одного товара
keys = [
f"product:{product_id}",
f"product:card:{product_id}",
]
if keys:
client.delete(*keys) # delete принимает несколько ключей за один вызов
# список категории инвалидируется отдельно — он зависит не от товара,
# а от набора товаров, и точечно его не сбросить
Комментарий про категорию — не мелочь. Он показывает, где явная инвалидация упирается в стену: как только представление агрегирует много сущностей, точечный сброс становится либо невозможным, либо дороже пересчёта.
Что мерить в проде
Инвалидацию нельзя отладить в голове — её видно только по метрикам.
- Hit ratio по группам ключей. Не общий, а раздельно: карточки, списки, поиск. Проседание hit ratio по одной группе после релиза — сигнал, что кто-то сбрасывает лишнее.
- Задержка чтения p99, а не среднее. Stampede прячется в хвосте: среднее 5 мс, p99 — 400 мс в момент протухания популярного ключа.
- Число промахов в единицу времени с привязкой к TTL. Пики промахов, синхронные с истечением TTL, — прямое указание на несогласованный срок жизни ключей.
- Лаг доставки событий, если выбрали шину. Это ваше реальное окно рассинхрона, а не теоретическое.
Что делать дальше
Прежде чем добавлять кеш, ответьте на один вопрос: какое окно рассинхрона допустимо для этих данных. Для цен — секунды, для справочника регионов — сутки. Ответ определяет подход раньше, чем вы напишете первую строку. Дальше стоит зафиксировать в одном месте карту ключей: какое представление какие сущности агрегирует — иначе явная инвалидация начнёт забывать ключи через месяц после запуска. Если выбрали защиту от stampede, читайте раздел про блокировки и SET с флагами в документации Redis, а координацию воркеров закрывайте нагрузочным тестом, а не отзывом «вроде работает».
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: Redis SET command (флаги NX, EX), redis-py documentation
Частые вопросы
Подойдёт ли для этого встроенный functools.lru_cache?
А если у меня другая версия redis-py или клиент aioredis?
Что если под нагрузкой всё равно ловлю устаревшие данные?
Можно ли просто снести весь кеш при любом изменении?
Стоит ли сразу строить инвалидацию через шину событий?
Почему нельзя использовать redis.keys() для сброса по шаблону?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.