Антипаттерн: кеш без стратегии инвалидации
Кеш почти всегда добавляют одной фразой: «давай завернём в Redis, чтобы не бить базу на каждый запрос». Через месяц выясняется, что часть пользователей видит цены недельной давности, часть — актуальные, а разработчик, который ловит баг, не может его воспроизвести, потому что на его машине кеш уже успел протухнуть. Проблема не в том, что кеш поставили, а в том, что его поставили без ответа на вопрос «когда и как эти данные перестанут быть кешем и станут устаревшим мусором».
Содержание
- Как это выглядит на практике
- Почему это одна из двух по-настоящему сложных задач
- Последствие первое: пользователи видят устаревшие данные без понимания когда
- Последствие второе: TTL на глазок — либо бесполезен, либо опасен
- Последствие третье: баги, которые невозможно стабильно воспроизвести
- Стратегии инвалидации: TTL, event-based, версионирование ключей
- Как выбрать стратегию осознанно под тип данных
Как это выглядит на практике
Типичная реализация, которую можно найти в половине проектов, — cache-aside с TTL, выбранным интуитивно и никогда больше не пересматривавшимся:
def get_product(product_id):
cached = redis.get(f"product:{product_id}")
if cached:
return json.loads(cached)
product = db.query(
"SELECT * FROM products WHERE id = %s", product_id
)
redis.setex(f"product:{product_id}", 300, json.dumps(product))
return product
Код выглядит совершенно нормально и проходит любое ревью. Проблема не в синтаксисе, а в том, чего в нём нет: ни слова о том, что происходит, когда product меняется — цена, наличие на складе, описание. Ключ product:{product_id} отдаёт старое значение все 300 секунд, независимо от того, что случилось с исходными данными за это время. Если админ поменял цену через панель управления, у Redis нет повода узнать об этом раньше, чем истечёт TTL.
То же самое повторяется с кешем списков, агрегатов и производных данных:
def get_category_products(category_id):
cached = redis.get(f"category:{category_id}:products")
if cached:
return json.loads(cached)
products = db.query(
"SELECT * FROM products WHERE category_id = %s", category_id
)
redis.setex(f"category:{category_id}:products", 600, json.dumps(products))
return products
Здесь проблема глубже: этот кеш зависит не от одной записи, а от целого набора — если товар добавили в категорию, удалили из неё или изменили сортировку, список в кеше не имеет с текущим состоянием базы ничего общего до истечения TTL. Для product:{id} хотя бы теоретически можно «просто удалить ключ при обновлении», а для category:{id}:products это уже требует заранее знать, какие ключи вообще нужно инвалидировать — тот самый вопрос, который в антипаттерне никто не задал на этапе проектирования.
Почему это одна из двух по-настоящему сложных задач
В индустрии давно ходит шутка Фила Карлтона: «в информатике есть только две сложные вещи — инвалидация кеша и именование переменных». Смешно это ровно потому, что чистая правда: сбросить кеш — вроде бы «просто удалить ключ», но требует понимания, какие именно ключи зависят от изменившихся данных, когда это изменение произошло и кто должен об этом узнать.
Сложность не техническая — DEL в Redis выполняется за микросекунды. Сложность концептуальная: кеш по определению — это копия данных, оторванная от источника истины. В момент создания копии она верна, дальше начинается гонка между тем, как быстро меняются исходные данные, и тем, как быстро об этом узнаёт кеш. Антипаттерн «кеш без стратегии инвалидации» — это ситуация, когда о самом существовании этой гонки никто не подумал, и весь контроль над рассинхронизацией отдан одному параметру — TTL, выбранному на глаз в момент первой реализации.
Отдельная статья про то, почему сам процесс инвалидации технически труден даже когда стратегия выбрана осознанно — инвалидация кеша: почему выкинуть данные сложнее, чем их сохранить. Здесь же разбираем более базовую проблему — что происходит, если стратегии просто нет вообще.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПоследствие первое: пользователи видят устаревшие данные без понимания когда
Самое очевидное следствие — это stale data, устаревшие данные, отданные пользователю как актуальные. Хуже другого: без явной стратегии инвалидации никто в команде не может точно сказать, насколько устаревшие данные видит конкретный пользователь прямо сейчас. Это не «данные отстают максимум на N секунд», а «данные отстают на где-то от нуля до TTL секунд — в зависимости от того, когда конкретно ключ последний раз кешировался относительно момента изменения источника».
На практике это рассинхронизация состояния, которую пользователи описывают в поддержку как «глючит»:
- Один пользователь обновил профиль, зашёл на другую страницу — там всё ещё старые данные, потому что тот эндпоинт кеширует отдельно и по своему TTL.
- Менеджер изменил цену товара в 14:00, а часть покупателей до 14:05 продолжает видеть старую цену, потому что их запрос попал в кеш до изменения, а TTL был 300 секунд.
- Заказ прошёл, но в личном кабинете статус «в обработке» держится ещё пару минут после того, как склад его уже собрал — потому что карточка заказа кешировалась отдельно от статуса.
Формально ни одна из этих ситуаций не баг в смысле «код упал» — код работает штатно, ровно так, как написан. Но с точки зрения пользователя это выглядит как ненадёжный сервис: данные то актуальны, то нет, без предсказуемой закономерности — есть только случайное совпадение момента запроса и момента последнего обновления кеша.
Последствие второе: TTL на глазок — либо бесполезен, либо опасен
Когда единственный механизм борьбы с устареванием — это TTL, выбор его значения превращается в компромисс без хорошего решения в обе стороны:
TTL слишком короткий (секунды, единицы секунд) — кеш формально есть, но пользы от него почти нет. Каждый запрос почти гарантированно промахивается мимо кеша и идёт в базу, потому что предыдущая запись уже истекла. Хуже: короткий TTL на популярных ключах создаёт классический thundering herd — когда ключ протухает, десятки параллельных запросов одновременно бьют в базу, пытаясь пересчитать одно и то же значение, вместо того чтобы один из них посчитал, а остальные подождали результат.
TTL слишком долгий (часы, дни) — кеш снимает нагрузку с базы, но ценой того, что данные могут отставать от реальности на весь этот срок. Для списка городов доставки это нормально. Для остатков товара на складе или статуса платежа — прямой путь к тому, что пользователь оформит заказ на товар, которого уже нет, или увидит «платёж в обработке» через час после того, как он давно прошёл.
Проблема не в том, что TTL существует как механизм — это нормальный, рабочий инструмент. Проблема в том, что при отсутствии стратегии TTL выбирается один раз, интуитивно, а затем копируется по проекту как «стандартное значение», без пересмотра под данные с принципиально другой скоростью изменения. Разбор того, почему на первый взгляд безобидное значение TTL может сработать хуже более короткого — в статье почему TTL кеша 60 секунд хуже, чем пять.
Последствие третье: баги, которые невозможно стабильно воспроизвести
Это последствие бьёт не по пользователям напрямую, а по команде, которая пытается разобраться, что вообще происходит. Баг, вызванный устаревшим кешем, проявляется только в узком окне времени — между моментом, когда данные изменились, и моментом, когда истёк TTL старой записи. Вне этого окна всё работает штатно, и разработчик, пытающийся воспроизвести жалобу пользователя, с высокой вероятностью просто не попадёт в это окно.
Типичный сценарий: пользователь пишет «у меня в корзине не тот товар», разработчик открывает свою сессию — всё в порядке, код верен, в базе данные правильные. Проблема кажется несуществующей, потому что к моменту проверки кеш уже успел обновиться сам, TTL истёк, и следующий запрос честно сходил в базу. Баг «исчез» не потому, что его исправили, а потому что время само его замаскировало — а через день, при следующем изменении данных, всё повторится.
Отдельно опасный вариант той же проблемы — коллизия ключей кеша, когда разным сущностям по ошибке присваивается один и тот же ключ (например, из-за отсутствия в ключе идентификатора пользователя). Разбор конкретного такого инцидента — в материале кеш отдавал чужие корзины: ошибка в ключе. Такие баги систематически недооценивают именно потому, что они не воспроизводятся по требованию — приоритет на исправление занижают, и баг живёт в проде месяцами.
Стратегии инвалидации: TTL, event-based, версионирование ключей
Осознанная работа с кешем — это не отказ от TTL, а выбор одной или комбинации из трёх базовых стратегий под конкретный тип данных.
TTL (time-to-live) — самый простой механизм: запись живёт заданное время, после чего считается недействительной независимо от того, изменились ли реальные данные. Подходит для данных, устаревание которых допустимо и предсказуемо — справочники, списки категорий, курсы валют с приемлемой погрешностью.
redis.setex(f"exchange_rate:usd_rub", 3600, str(rate)) # раз в час — достаточно
Плюс — не требует никакой дополнительной инфраструктуры. Минус — данные устаревают всегда, вопрос только в том, на сколько именно, и это устаревание никак не связано с тем, изменились ли данные на самом деле.
Event-based инвалидация — кеш сбрасывается не по таймеру, а по факту события изменения данных. Правильный источник истины (обычно приложение или база через триггер/outbox) явно удаляет или обновляет соответствующий ключ кеша в момент изменения:
def update_product_price(product_id, new_price):
db.execute(
"UPDATE products SET price = %s WHERE id = %s",
new_price, product_id
)
redis.delete(f"product:{product_id}")
# Не забыть про производные ключи, которые от него зависят:
redis.delete(f"category:{get_category_id(product_id)}:products")
Это точнее по свежести данных — кеш инвалидируется ровно тогда, когда нужно, а не спустя произвольное время. Но именно здесь прячется главная сложность концепции Фила Карлтона: нужно заранее знать все места, зависящие от изменившихся данных, и не забыть инвалидировать каждое. Пропущенный производный ключ (агрегат, список, счётчик) — точно такой же баг устаревших данных, только вместо TTL виновата неполнота ручной инвалидации. Для сложных зависимостей event-based инвалидацию комбинируют с тегированием ключей: ключ регистрируется не только под своим именем, но и под тегом набора, к которому принадлежит, и инвалидация идёт по тегу.
Версионирование ключей кеша — вместо удаления старой записи в ключ добавляется версия, и при изменении данных версия просто инкрементируется, а старые записи с прежней версией сами теряют актуальность (и со временем вытесняются политикой maxmemory, если используется LRU/LFU):
def get_product(product_id):
version = redis.get(f"product:{product_id}:version") or 1
key = f"product:{product_id}:v{version}"
cached = redis.get(key)
if cached:
return json.loads(cached)
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
redis.set(key, json.dumps(product))
return product
def update_product_price(product_id, new_price):
db.execute("UPDATE products SET price = %s WHERE id = %s", new_price, product_id)
redis.incr(f"product:{product_id}:version")
Плюс подхода — не нужно перечислять и удалять конкретные ключи, достаточно сдвинуть версию, и весь старый набор перестаёт быть достижимым по новому ключу. Это особенно удобно для массовой инвалидации (например, весь кеш категории после переиндексации каталога) — сдвигаете одну общую версию, и все ключи, построенные с её участием, автоматически «протухают» логически, даже если физически ещё лежат в памяти. Минус — старые записи не удаляются немедленно, а просто становятся недостижимыми, и место в памяти освобождается только когда дойдёт очередь вытеснения.
Как выбрать стратегию осознанно под тип данных
Стратегии не взаимоисключающие — разные типы данных обычно кешируются по-разному, и это нормально. Ошибка антипаттерна не в выборе конкретной стратегии, а в том, что выбор вообще не был сделан осознанно — просто скопировали setex(..., 300, ...) из соседнего эндпоинта, потому что «так уже где-то было».
| Тип данных | Скорость изменения | Цена устаревания | Рекомендуемая стратегия |
|---|---|---|---|
| Курсы валют, справочники | Редко (часы) | Низкая | TTL, час-сутки |
| Список категорий, статичный контент | Очень редко | Низкая | TTL, сутки + ручной сброс при публикации |
| Цена товара, остаток на складе | Часто, непредсказуемо | Высокая (деньги, доверие) | Event-based при изменении |
| Профиль пользователя, настройки | По действию пользователя | Средняя | Event-based или короткий TTL |
| Статус заказа/платежа | Часто, критично по времени | Очень высокая | Event-based, TTL как страховка (fallback) |
| Агрегаты, счётчики, витрины | Массово, пакетно | Средняя | Версионирование ключей |
Практическое правило: чем выше цена показать пользователю неактуальные данные (деньги, статус платежа, наличие товара) — тем меньше допустимо полагаться только на TTL и тем важнее event-based инвалидация в момент изменения. Чем ниже цена устаревания и реже данные меняются — тем спокойнее оставить простой TTL: событийная инвалидация сама по себе добавляет точку отказа — если обработчик события упал или не был вызван (например, обновление сделали напрямую в базе, в обход приложения), кеш останется устаревшим бессрочно, а не на TTL-окно, и это зачастую хуже неточного TTL.
Поэтому даже для event-based стратегии разумно ставить достаточно большой, но конечный TTL как страховку — не как основной механизм свежести, а как гарантию, что в худшем случае данные самоисправятся сами, пусть и с задержкой. Для выбора конкретной технологии кеша под эти сценарии — сравнение в статье Redis или Memcached: что выбрать для сервера.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли обойтись без инвалидации, если TTL достаточно короткий?
Формально да, но это не решение проблемы, а способ уменьшить окно её проявления. Чем короче TTL, тем меньше пользы от кеша — вы платите сложностью thundering herd за то, чтобы реже видеть устаревшие данные, вместо того чтобы решить, когда они реально должны обновляться.
Не проще ли вообще не использовать кеш вместо событийной инвалидации?
Нет — если данные меняются редко относительно того, как часто их читают, кеш с корректной event-based инвалидацией снимает основную нагрузку с базы почти без потери свежести. Отказ от кеша решает проблему устаревания ценой производительности там, где кеш реально нужен.
Что делать с производными данными — агрегатами, списками, витринами?
Это самый сложный случай, потому что одно изменение может затрагивать десятки производных ключей. Рабочий подход — тегирование ключей (регистрация ключа под тегом набора) или версионирование на уровне тега, а не попытка перечислить все зависимые ключи вручную.
Как понять, что в проекте уже есть этот антипаттерн?
Диагностический вопрос для команды: «если я прямо сейчас поменяю цену товара в базе, через сколько это увидит пользователь и почему именно столько?» Если ответа нет, а есть только «ну, TTL там вроде 5 минут» — стратегии инвалидации фактически нет.
Нужно ли одинаково относиться к инвалидации в Redis и в CDN-кеше?
Нет, это разные уровни с разной скоростью реакции: сброс ключа в Redis мгновенен и контролируется приложением напрямую, а инвалидация CDN обычно асинхронна и занимает заметное время в зависимости от провайдера. Стратегию нужно проектировать отдельно для каждого уровня кеширования.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →