Почему инвалидация кеша — это трудно
Кеш — это копия данных, которая обязана устареть. Вопрос не в том, устареет ли кеш, а в том, когда вы об этом узнаете. Если система настроена идеально, пользователь получает ответ мгновенно, но данные в нём уже неактуальны. Если система ждёт актуальности, пользователь получает верные данные, но ждёт их дольше, чем мог бы.
Инвалидация — это процесс, который должен уравновесить эти два состояния: быстро и правильно. Но баланс здесь хрупкий, потому что кеш не знает, что данные изменились. Он просто хранит копию и отдаёт её по запросу, пока не получит команду обновиться или не истечёт срок жизни записи.
Проблема в том, что кеш не может сам решить, когда данные устарели. Он зависит от внешних сигналов: либо от времени, либо от события, либо от изменения ключа. Каждый из этих подходов работает в одних сценариях и ломается в других — и ни один не решает задачу полностью.
Три стратегии инвалидации и их цена
Есть три основных способа инвалидировать кеш: срок жизни записи (TTL), удаление по событию и версия ключа. Все они решают одну и ту же задачу, но по-разному распределяют нагрузку между компонентами системы — и по-разному проявляют свои слабости.
Срок жизни (TTL)
Самый простой способ. Кеш хранит запись ровно столько, сколько указано в TTL, а потом автоматически её удаляет. Когда приходит запрос на тот же ключ, кеш либо возвращает устаревшую запись (если TTL ещё не истёк), либо идёт за актуальными данными и сохраняет новую копию.
Как работает на практике:
- Если TTL слишком короткий, кеш почти не помогает — система постоянно обращается к источнику данных.
- Если TTL слишком длинный, данные могут быть неактуальными долгое время, даже если они изменились почти сразу после сохранения.
- Чем чаще данные меняются, тем короче должен быть TTL — но тем меньше выигрыш от кеширования.
Когда использовать:
- Когда данные меняются редко, а скорость важнее актуальности.
- Когда нет возможности отслеживать изменения данных в реальном времени.
- По умолчанию. Если не уверены, какой подход выбрать, начните с TTL — это самый предсказуемый вариант.
Удаление по событию
Кеш удаляет запись, когда получает сигнал о том, что данные изменились. Например, при обновлении записи в базе данных сервис отправляет команду на удаление соответствующего ключа в кеше.
Как работает на практике:
- Данные становятся актуальными сразу после изменения — но только если событие дошло до кеша.
- Если событие потеряется (например, из-за сбоя сети), кеш будет хранить устаревшие данные бесконечно.
- Чем больше источников изменений, тем сложнее отслеживать все события. Например, если данные могут измениться через API, через админку и через фоновые процессы, легко пропустить одно из событий.
Когда использовать:
- Когда данные меняются часто, а актуальность важнее скорости.
- Когда есть возможность отслеживать все изменения данных в реальном времени — и когда вы уверены, что не пропустите ни одного события.
Версия ключа
Вместо того чтобы удалять запись из кеша, мы меняем ключ, по которому она хранится. Например, вместо ключа user:123 используем user:123:v2. Когда данные изменяются, генерируется новая версия ключа, и старый ключ становится неактуальным сам собой — на него просто перестают приходить запросы.
Как работает на практике:
- Не требует удаления записей. Старые версии ключей просто вытесняются из кеша по мере истечения TTL или заполнения памяти.
- Переносит проблему на генерацию ключа. Нужно придумать, как генерировать уникальные версии ключей, и убедиться, что они не конфликтуют.
- Усложняет логику приложения. Приложение должно знать, какую версию ключа использовать для каждого запроса.
Когда использовать:
- Когда данные меняются часто, а удаление записей по событию слишком сложно реализовать.
- Когда кеш распределённый, и синхронизация удалений между инстансами — это боль.
Ловушки инвалидации
Даже если выбрать правильную стратегию, инвалидация кеша может сломаться из-за неочевидных деталей. Вот несколько распространённых ловушек, которые стоит учитывать.
Кеш пустого ответа
Представьте, что пользователь запрашивает данные, которых ещё нет в базе. Кеш сохраняет пустой ответ (например, null или пустой массив). Потом данные появляются, но кеш продолжает отдавать пустой ответ, потому что никто не сказал ему обновиться.
Как это ломается:
- Если TTL для пустых ответов слишком длинный, система будет возвращать пустые данные даже после того, как они появились в источнике.
- Если не кешировать пустые ответы вообще, каждый запрос будет идти к источнику данных — и это может создать нагрузку, если таких запросов много.
Как избежать:
- Использовать короткий TTL для пустых ответов. Чем быстрее истечёт срок жизни пустой записи, тем быстрее кеш запросит актуальные данные.
- Удалять запись из кеша при добавлении новых данных. Если данные появились, пустая запись уже не нужна.
Лавина запросов после истечения TTL
Если много клиентов одновременно запрашивают один и тот же ключ, и TTL у всех записей истекает в одно время, система получит всплеск нагрузки на источник данных. Это называется “thundering herd problem” — лавина запросов.
Как это ломается:
- Если TTL у всех записей одинаковый, все клиенты одновременно пойдут за актуальными данными, как только истечёт срок.
- Если источник данных не справляется с нагрузкой, система может упасть или начать отвечать с задержками.
Как избежать:
- Добавлять случайный разброс к TTL. Например, вместо фиксированного часа использовать диапазон значений — так записи будут истекать в разное время, и нагрузка распределится равномернее.
- Использовать стратегию “stale-while-revalidate”. Кеш продолжает отдавать устаревшие данные, пока обновляет их в фоне — это снижает нагрузку на источник.
Кеш на уровне пользователя, который стал общим
Представьте, что вы кешируете данные на уровне пользователя: user:123:profile. Потом оказывается, что эти данные нужны не только пользователю, но и другим сервисам. Например, профиль пользователя отображается в списке друзей. Теперь кеш user:123:profile стал общим, и его инвалидация усложнилась — нужно учитывать изменения не только от пользователя, но и от других сервисов.
Как это ломается:
- Если данные изменились в одном месте, но событие об этом не дошло до всех сервисов, часть системы будет работать с устаревшими данными.
- Если разные сервисы используют разные стратегии инвалидации, кеш может стать несогласованным.
Как избежать:
- Избегать кеширования данных, которые могут стать общими. Если данные нужны нескольким сервисам, лучше кешировать их на уровне сервиса, а не пользователя.
- Использовать версию ключа. Например,
user:123:profile:v2, чтобы разные сервисы могли использовать разные версии одного и того же профиля.
Что нужно измерять
Инвалидация кеша — это не только про то, чтобы данные были актуальными. Это ещё и про то, чтобы система работала эффективно. Вот что стоит измерять, чтобы понимать, насколько хорошо работает кеш.
Доля попаданий (hit ratio)
Процент запросов, которые кеш смог обработать без обращения к источнику данных. Чем выше доля попаданий, тем эффективнее работает кеш — но только если данные при этом остаются достаточно актуальными.
Как это работает:
- Если доля попаданий низкая, кеш почти не помогает — система постоянно обращается к источнику данных.
- Если доля попаданий высокая, но данные сильно устарели, кеш работает эффективно, но не выполняет свою основную задачу — обеспечивать актуальность.
- Оптимальное значение зависит от задачи. Для одних систем важнее скорость, для других — актуальность.
Как улучшить:
- Увеличивать TTL для редко меняющихся данных.
- Использовать более точные стратегии инвалидации (например, удаление по событию) для часто меняющихся данных.
Возраст данных (data freshness)
Средний возраст данных, которые отдаёт кеш. Чем меньше возраст, тем актуальнее данные — но тем чаще приходится обращаться к источнику.
Как это работает:
- Если возраст данных слишком большой, пользователи получают устаревшую информацию.
- Если возраст данных слишком маленький, кеш не успевает накапливать записи, и доля попаданий падает.
- Оптимальное значение зависит от того, насколько критична актуальность. Для новостного сайта минута — это много, а для погодного сервиса — приемлемо.
Как улучшить:
- Уменьшать TTL для часто меняющихся данных.
- Использовать стратегию “stale-while-revalidate”, чтобы снизить возраст данных без потери доли попаданий.
Скорость ответа (latency)
Среднее время ответа кеша и источника данных. Чем быстрее отвечает кеш, тем лучше — но только если данные при этом остаются достаточно актуальными.
Как это работает:
- Если время ответа кеша близко ко времени ответа источника, кеш не даёт выигрыша в скорости.
- Если время ответа кеша сильно меньше, но данные сильно устарели, кеш работает быстро, но не выполняет свою задачу.
Как улучшить:
- Увеличивать TTL для редко меняющихся данных, чтобы кеш реже обращался к источнику.
- Использовать более быстрые стратегии инвалидации (например, версию ключа), чтобы снизить нагрузку на источник.
Тупик: сбрасывать кеш целиком
Первое, что приходит в голову, когда данные изменились, — сбросить весь кеш. Это кажется простым и надёжным решением: раз данные устарели, удалим все записи и начнём с чистого листа.
Почему это плохо:
- Лавина запросов. Если кеш большой, его полный сброс приведёт к тому, что все клиенты одновременно пойдут за актуальными данными к источнику. Это может создать нагрузку, с которой источник не справится.
- Потеря производительности. Кеш перестаёт помогать, пока не заполнится заново — и в это время система работает медленнее.
- Не решает проблему. Если данные изменились только частично, сброс всего кеша — это перебор. Гораздо эффективнее инвалидировать только те записи, которые действительно устарели.
Когда это может сработать:
- Если кеш маленький, а источник данных справляется с нагрузкой даже при полном сбросе.
- Если данные меняются редко, а сброс кеша — это крайняя мера, которая применяется нечасто.
- Если есть возможность сбрасывать кеш постепенно, а не весь сразу — например, по частям, чтобы нагрузка распределялась равномернее.
Что делать вместо этого:
- Инвалидировать только те записи, которые изменились. Например, удалять по событию или использовать версию ключа.
- Использовать стратегию “stale-while-revalidate”. Кеш продолжает отдавать устаревшие данные, пока обновляет их в фоне — это снижает нагрузку на источник.
- Добавлять случайный разброс к TTL, чтобы избежать лавины запросов при истечении срока жизни записей.
Позиция редакции
Инвалидация кеша — это не техническая задача, а бизнес-решение. Нужно выбирать стратегию исходя из того, что важнее: скорость или актуальность. По умолчанию стоит начинать с TTL, потому что это самый простой и предсказуемый способ. Но если данные меняются часто, а актуальность критична, лучше использовать удаление по событию или версию ключа.
Главное — не пытаться решить все проблемы одним махом. Инвалидация кеша — это компромисс, и идеального решения не существует. Нужно измерять, экспериментировать и подбирать стратегию под конкретную задачу.
Когда наша позиция неверна:
- Если данные меняются настолько часто, что даже короткий TTL не спасает — система будет постоянно обращаться к источнику, и кеш перестанет помогать.
- Если источник данных не справляется с нагрузкой, и даже небольшой всплеск запросов приводит к сбоям — в этом случае любая стратегия инвалидации может стать проблемой.
- Если бизнес-требования диктуют, что актуальность важнее скорости, и компромисс невозможен — тогда нужно выбирать стратегию, которая гарантирует актуальность, даже если это замедлит систему.