Привет, Хабр! Меня зовут Андрей Бирюков. Я эксперт в области ИТ и ИБ, преподаю в учебных центрах и пишу книги. В сегодняшней статье мы поговорим об инвалидации кеша и способах решения связанных с этим проблем.
В современном мире к скорости работы приложений предъявляются достаточно высокие требования. Конечно, можно пытаться выжать максимальную производительность из железа и до бесконечности оптимизировать алгоритмы. Но еще одним способом существенно увеличить производительность систем является кеширование. Конечно, удобно, когда у нас есть возможность сохранять результаты выполненных операций для увеличения производительности.

Но зачастую кэширование легко начать, но почти невозможно правильно закончить. И классическая проблема инвалидации кэша — это не поиск эффективного алгоритма сброса данных, а управление распределённой согласованностью в условиях асинхронности, гонок и частичных отказов. Об этом мы и будем говорить в сегодняшней статье.
Великое заблуждение о кэше
Кеш можно назвать молотком, который есть у каждого архитектора, и в любой непонятной ситуации он может им воспользоваться. Например, база данных отвечает медленно, а пользователь не хочет ждать, значит нам нужно хранить копию данных где‑то поближе. В оперативной памяти сервера, в Redis, на диске. Данные меняются редко, а читаются часто. Действительно кажется, что это идеальный сценарий. Вы ставите кеш, поднимаете время ответа с двухсот миллисекунд до пяти. И все вроде бы довольны.

Только потом кто‑то обновляет данные, и начинается ад. Наш основной инструмент — старый добрый TTL (время жизни записи в кэше) — работает, только если вам можно отдавать устаревшие данные в течение нескольких секунд или минут. Для табло курса валют это нормально, а вот для списка товаров с ограниченным остатком уже нет. Вы представляете, что пользователь видит «в наличии 3 штуки», оформляет заказ, а через секунду получает отказ, потому что товар уже купил другой человек? Получается, что кэш солгал ему.
Инвалидация по событию означает, что при обновлении базы мы отправляем команду «удалить ключ из кэша». Вроде бы звучит правильно. Но на практике оказывается, что событие идёт по сети, и, пока оно дошло, другой запрос мог уже прочитать старое значение из базы и записать его обратно в кэш. Получаем состояние гонки. Или кэш на секунду упал, событие потерялось, и теперь в кэше навсегда поселилась старая версия. Либо инвалидация произошла, но перед этим кто‑то успел сделать запрос и положил в кэш устаревшие данные — и они будут жить до следующей инвалидации, которой уже не будет.
Парадокс кэширования формулируется просто: кэш делает систему быстрее ценой нарушения согласованности. Вопрос не в том, как сделать инвалидацию идеальной. Вопрос в том, какую степень несогласованности вы готовы принять и как сделать её предсказуемой.
Библиотечная проблема
Чтобы понять всю глубину проблемы, рассмотрим классический сценарий, который можно называть «библиотечной проблемой». Представьте онлайн‑библиотеку, в которой пользователь А смотрит карточку книги — кэш отдаёт «в наличии 1 экземпляр». Пользователь Б в тот же момент берёт эту книгу — выполняется запрос в базу, остаток уменьшается до нуля. Система отправляет событие инвалидации кэша. Но оно ещё не дошло до кэша, а пользователь А уже оформляет заказ, читая из кэша устаревшую единицу. Через секунду он получит отказ, потому что настоящий остаток ноль.
Пользователь А будет зол, так как он не поймёт, почему система показала доступность, а потом отменила заказ. С его точки зрения, система неработоспособна.
А в чём, собственно, проблема? Система не нарушила никаких законов физики: данные в базе согласованы, кэш обновится через мгновение. Но для пользователя А была продемонстрирована несогласованность, так как был момент времени, когда два пользователя видели разное состояние одного и того же объекта. Один видел единицу, другой — ноль. Оба были правы с точки зрения своих источников, но одновременно существовать эти две правды не могли.
Это и есть фундаментальное ограничение. В распределённой системе без глобальной блокировки вы не можете гарантировать, что все клиенты видят одно и то же состояние в один и тот же момент времени без серьёзной платы за производительность. Кэш просто делает этот разрыв заметным и болезненным.
Выход из библиотечной проблемы — не лучшая инвалидация, а перепроектирование поведения. Например, не показывать точный остаток, а показывать статус «мало» или «проверяем доступность». Или при оформлении заказа не полагаться на кэш, а делать реальную резервацию товара в базе с блокировкой строки. Или внедрить «корзину ожидания»: пользователь выражает намерение купить, система резервирует товар на несколько минут, и только после этого показывает его другим как недоступный. Кэш продолжает использоваться для каталога — он всё ещё ускоряет пролистывание списков. Но для критической операции — остатка и резервации — кэш вообще не используется.
Это и есть главная мудрость: некоторые данные не должны попадать в кэш с возможностью несогласованности.
Паттерн «lease»
Итак, мы пришли к ситуации, когда, с одной стороны, мы не можем обойтись без кеша, но при этом несогласованность для нас тоже недопустима. В таком случае нам на помощь приходят блокировки. Здесь не идет речь о полных блокировках всей базы, а о достаточно умном блокировании, позволяющем избежать гонок. Один из таких механизмов — паттерн «lease» (аренда).
Когда клиент хочет прочитать данные из кэша и готов полностью за них отвечать, он не просто читает значение. Он запрашивает у «владельца кэша» (отдельного сервиса или Redis с Lua‑скриптами) короткоживущий токен с правом обновления. За время жизни токена (например, 5 секунд) никакой другой клиент не может изменить этот ключ. Если за это время приходит событие о реальном обновлении данных в базе, оно ставится в очередь и ждёт, пока истечёт токен. Клиент, владеющий токеном, может обновить кэш, и только после этого токен освобождается.

Как видно, здесь всё достаточно просто, но вернёмся к библиотеке. Снова наш пользователь А открывает карточку книги. Система выдаёт ему lease на 5 секунд. В течение этих пяти секунд все запросы остатка этой книги от других пользователей идут не в кэш, а напрямую в базу или ждут. Пользователь Б оформляет заказ — система видит активный lease и не трогает кэш, а работает с базой напрямую. Когда lease истекает (или пользователь А закрыл карточку), кэш может быть обновлён. Пользователь А за всё время держал в руках консистентное значение. Он не видел момента, когда единица превратилась в ноль.
Цена этого подхода — определённая сложность и задержки во время действия lease. Другие пользователи могут ждать освобождения кэша. Но в сценариях, где данные меняются нечасто и важна согласованность для каждого чтения (например, в биллинговых системах, при бронировании ресурсов), lease оправдан. Главный недостаток — lease требует централизованного координатора, который помнит о выданных токенах. Если этот координатор упал, система либо останавливается, либо теряет гарантии и возвращается к обычным гонкам.
Кеш как реплика
Современный подход к серьёзному кэшированию, который набирает популярность в высоконагруженных системах, — это рассматривать кэш не как временное хранилище, а как реплику базы данных для чтения. Идея заключается в следующем: вы отказываетесь от инвалидации в обычном понимании. Вместо этого приложение пишет в базу (например, PostgreSQL).
Отдельный процесс — Change Data Capture (CDC) — читает журнал транзакций базы (WAL, binlog) и транслирует каждое изменение в виде события в поток (Apache Kafka, RabbitMQ). Все кэши — их может быть несколько, в разных регионах — подписываются на поток и обновляют свои копии. Задержка между записью в базу и обновлением кэша — порядка десятков миллисекунд, предсказуема и конечна.

Преимущество этого подхода в том, что гонки «чтение старого значения + запись в кэш» просто не возникают. Потому что кэш никогда не читает данные из базы напрямую. Он только слушает события и обновляется. Если кэш упал и пропустил несколько событий, он при старте может подтянуть снапшот из базы и затем доесть события из Kafka с нужного смещения.
Но на огромном количестве событий подобный подход может дать сбой. Если база обновляется 100 000 раз в секунду, поток событий будет огромным. Кэш должен успевать их переваривать. И второе — время жизни события: если кэш отставал на час, ему придётся прогнать через себя час изменений, что может занять время. На этот случай существуют снапшоты — полные копии данных, которые периодически рассылаются в кэши.
Рассмотрим небольшой пример: крупный сервис бронирования авиабилетов использует именно такую модель. База броней — PostgreSQL, CDC через Debezium в Kafka. Всего несколько десятков кэшей Redis по всему миру слушают изменения и показывают актуальные цены и остатки мест. Задержка от момента бронирования до обновления кэша во всех регионах — в среднем 120 миллисекунд, что для туриста незаметно. Но при этом система способна обрабатывать пики до 50 000 бронирований в секунду, а каждый билет виден консистентно во всех регионах с разницей в сотни миллисекунд.
Пламя, которое сожгло магазин
А теперь давайте разберём реальную историю, произошедшую в одном ритейлере. Компания имела классическую архитектуру: веб‑приложение, за ним — кэш Redis, за кэшем — PostgreSQL. Остатки товаров хранились в Redis с TTL 30 секунд и обновлялись из базы при промахе кэша. Инвалидация по событию вообще не использовалась — надеялись, что 30 секунд — допустимый люфт.
Так работало два года, но потом случилась распродажа на товар‑хит — игровую консоль. В течение двух минут пришло 5000 запросов. Представим, что на складе было 2000 единиц. Первые 2000 запросов прошли: Redis честно показывал остаток 2000, забирали. База тоже честно уменьшала остаток. Но Redis каждые 30 секунд перезагружал значение из базы.
Проблема возникла из‑за того, что Redis не блокировался во время обновления. Запрос номер 2001 пришёл в тот момент, когда Redis как раз перезагружал значение из базы — а база уже показывала 0. Но из‑за асинхронности Redis взял базы 0, записал в себя и с этого момента показывал всем «0». Всё правильно.

А вот запросы 2002–5000 получили 0 и ушли с пустыми руками. Но проблема была не в них. Проблема была в том, что пользователи, оформившие заказ в первые секунды, получили подтверждение, а через несколько минут — отмену. Почему? Потому что когда приложение получало запрос на покупку, оно сначала проверяло Redis. Если Redis говорил «есть остаток», приложение делало реальную резервацию в базе. В базе остаток был, блокировка проходила, заказ сохранялся. Но затем, через секунду, проверка антифрода отбраковывала заказ, потому что «несоответствие остатка» — какой‑то баг в интеграции.
В итоге честные покупатели получали подтверждение заказа и через 20 минут — письмо «к сожалению, товар закончился». В результате компания потеряла репутацию.
Урок этой истории не в том, что TTL плох. Урок в том, что кэш с TTL создаёт иллюзию актуальности. Если вы не можете жить с тем, что данные в кэше могут быть устаревшими на величину TTL, то данная технология вам не подходит. Переходите к CDC или используйте кэш только для данных, которые не меняются или меняются предсказуемо по расписанию. Никогда не кэшируйте складские остатки в высоконагруженной торговле — это прямой путь к катастрофе.
Кэшировать или не кэшировать: навигатор принятия решений
В завершении предлагаем не универсальную формулу, а набор вопросов, которые архитектор должен задать себе перед тем, как добавить кэш.
Первый вопрос. Что произойдёт, если пользователь увидит устаревшие данные? Если ничего страшного — например, аватарка пользователя загрузится на секунду старой — кэшируйте смело с любым TTL хоть в минуту. Если произойдёт финансовый ущерб, потеря заказа, юридические последствия — не кэшируйте эту сущность вообще, либо используйте только синхронную строгую согласованность с блокировками.
Второй вопрос. Как часто данные меняются относительно того, как часто они читаются? Классическая формула: кэш эффективен при соотношении чтений к записям больше 10 к 1. Если данные меняются чаще — кэш будет постоянно инвалидироваться и создавать нагрузку без пользы.
Третий вопрос. Готовы ли вы к распределённой отладке? Когда в системе есть кэш, база и, возможно, CDC, исчезает простая причинность. Вы не можете сказать «я обновил запись, теперь она везде такая». Вы можете сказать только «через некоторое время, если всё пойдёт нормально, она обновится везде». Если ваша команда не готова к такому уровню неопределённости и инструментам трассировки (распределённые трейсы, дашборды задержек), возможно, кэш добавит больше проблем, чем решений.
Четвёртый вопрос. Можете ли вы решить проблему без кэша? Часто оказывается, что медленная база данных — это симптом неправильных индексов, неоптимальных запросов или плохой схемы. Ускорение может принести вертикальное масштабирование (больше памяти, NVMe‑диски), использование read replica или изменение способа работы с данными. Кэш — это мощное, но опасное средство. Применяйте его, когда все более простые способы уже испробованы.
Делаем выводы
Кэширование — это всегда компромисс между скоростью и правдой. TTL даёт вам контролируемую ложь на заданный интервал. Инвалидация по событию пытается быть честной, но сталкивается с гонками и потерянными событиями. CDC и lease решают проблемы ценной сложности и координации.
Самый опасный вид кэширования — тот, который создаёт иллюзию актуальности, будучи по сути асинхронным. Вы не можете иметь быстрый распределённый кэш и строгую согласованность одновременно. Кэш не нарушает CAP‑теорему — он платит за скорость, жертвуя согласованностью. И как архитектор вы должны осознанно выбирать эту жертву, понимая её цену для бизнеса и пользователей.
Инвалидация кэша — действительно одна из наиболее сложных задач в компьютерных технологиях. Но если воспринимать её не как техническую задачу «как сбросить ключ», а как бизнес‑задачу «какой объём несогласованности допустим», решение становится ближе. Ответ часто лежит не в технологии, а в том, чтобы не кэшировать критичные данные вовсе, переложив ответственность за скорость на базу данных и её реплики.

Если тема кэширования, асинхронности и согласованности в распределённых системах для вас не абстрактная теория, а ежедневная боль в проде — присмотритесь к открытым урокам OTUS. Это бесплатные занятия в рамках онлайн-курсов: их проводят преподаватели-практики, можно познакомиться с экспертами, оценить формат обучения и задать вопросы по своим кейсам.
Ближайшие уроки по теме:
10 июня, 20:00 — «Мониторинг распределенных систем»
На уроке обсудим, как наблюдать за сложными системами, где есть кэш, база, брокеры, задержки, частичные отказы и не всегда очевидная причинно-следственная связь.16 июня, 20:00 — «Асинхронная обработка данных в высоконагруженных системах»
На уроке поговорим о том, как проектировать обработку данных там, где важны скорость, отказоустойчивость и предсказуемое поведение системы под нагрузкой.16 июня, 20:00 — «Использование брокера сообщений Apache Kafka в распределенных очередях»
Преподаватель покажет, как Kafka помогает строить событийные системы и распределённые очереди — то есть те самые механизмы, которые часто становятся альтернативой хрупкой ручной инвалидации кэша.
Приходите на открытые уроки, если хотите глубже разобраться, как проектировать системы, которые не просто быстро отвечают, но и сохраняют предсказуемость в реальных условиях нагрузки.
И подписывайтесь на канал OTUS в MAX — там делимся анонсами открытых уроков, полезными материалами и новостями для IT-специалистов.
lilpuf
Вы в каком-то другом мире живете, видимо.