Когда «горячий» ключ кэша истекает одновременно у всех, происходит cache stampede: тысячи запросов синхронно проваливаются в БД и кладут её за секунды, даже при 95% hit rate.

Защита простая: stale-while-revalidate, случайное раннее обновление (jitter / probabilistic expiration) и single-flight (mutex), чтобы в БД уходил один запрос, а не 10 000. Цена — несколько секунд устаревших данных, зато без упавшей базы. Подробнее - в новом переводе от команды Spring АйО.

Кэш, который положил базу данных 

График выглядел нормально — ровно до тех пор, пока перестал.

Трафик подскочил — ничего необычного. Доля попаданий в кэш держалась около 95%, ровно там, где и должна. А затем, в течение двух секунд, CPU базы данных ушёл в вертикаль. Соединения начали скапливаться. Задержки взорвались. Алерты посыпались доминошками.

Комментарий от Ильи Сазонова

В каком-то отдельно стоящем микросервисе проблема проявится не так. Количество соединений с базой данных как правило ограничено каким-то небольшим числом. И убить базу данных за счёт большого количества параллельных запросов такой сервис не сможет. Но если там и правда нужно обновить кеш, то соединения с БД будут использоваться для обновления кеша. Запросов в системе станет кратно больше и до запросов, которые нужны для работы бизнес логики очередь теперь будет доходить позже. База данных не рухнет, но вот сервис на какое-то время будет отвечать на запросы сильно дольше, чем раньше. А то и вообще перестанет.

В привычном смысле ничего «не так» не было. Ни релиза. Ни изменения схемы. Ни плохого запроса. Просто «горячий» ключ кэша, который истёк в точности в самый неудачный момент.

Дальше случился классический cache stampede: тысячи запросов обнаружили один и тот же отсутствующий ключ и вежливо, а потом отчаянно — одновременно начали просить базу данных дать ответы.

Эта статья — не обвинительный постмортем. Это практическое руководство о том, как сделать так, чтобы вам никогда не пришлось объяснять, почему «здоровый» кэш уложил «здоровую» базу данных меньше чем за две секунды.

Как на самом деле выглядит «стадный» штурм кэша

На механическом уровне cache stampede — штука скучная. Именно поэтому он и опасен.

Почти всегда последовательность одна и та же:

  • Есть «горячий» ключ (конфигурация главной страницы, цены, feature flags).

  • У него общий TTL для всех инстансов.

  • TTL истекает.

  • Тысячи параллельных запросов одновременно получают промах.

  • Все они проваливаются в базу данных.

Упрощённая временная шкала выглядит так:

Время →
---------------------------------------------------

t=0s      Попадание в кэш (TTL вот-вот истечёт)
t=1s      TTL истекает
t=1.1s    Запрос 1 → БД
t=1.1s    Запрос 2 → БД
t=1.1s    Запрос 3 → БД

...

t=1.1s    Запрос 10 000 → БД
---------------------------------------------------

База данных «тормозит» не потому, что она плохая. Она тормозит потому, что изначально не была рассчитана на синхронизированное любопытство 10 000 вежливых серверов приложений.

Комментарий от Ильи Сазонова

Всё-таки интересно, что это за 10 000 серверов приложений, у которых одновременно истёк срок актуальности кеша. Что это за система и насколько вероятно среднестатистическому разработчику с ней столкнуться. Создаётся впечатление, что если у вас 3333 серверов приложений, то это уже не ваша проблема )). На практике, цифры будут гораздо ниже. И проблема будет не в том, что БД сломалась, а в том, что приложение долго отвечает на запросы, потому что количество соединений с БД строго ограничено сверху.

Для того, чтобы у вас возникла именно та проблема, которая описана в статье, у вас должен быть высоконагруженный проект. В этом проекте должны кешироваться данные, обновление которых требует существенных усилий. И наконец, кеш должен истечь одновременно на существенном количестве инстансов.

Но если у вас система легче и проще, описанные в статье приёмы всё равно имеет смысл применять, потому что проблемы всё равно будут, хоть и проявятся по другому.

Почему наивные TTL ломаются под нагрузкой

«Просто кэшируйте на пять минут» работает — пока не перестаёт.

Проблема не в значении TTL. Проблема в синхронизации.

В распределённых системах кэши истекают не независимо друг от друга. Они истекают одновременно. У каждого инстанса примерно одни и те же часы, один и тот же TTL и один и тот же паттерн доступа.

Под нагрузкой это создаёт обрыв:

  • До истечения срока: почти нулевая нагрузка на базу данных.

  • После истечения срока: внезапный, синхронизированный обвал запросов на базу.

Это не плохая настройка и не недостаток железа. Это изъян дизайна. Фиксированные TTL предполагают, что запросы равномерно распределены во времени. Реальный трафик таким не бывает.

Паттерн №1: Stale-While-Revalidate

Первое исправление поначалу кажется неправильным, пока вы не выкатите его в прод: намеренно отдавать устаревшие данные.

Stale-while-revalidate означает:

  • Если данные в кэше просрочены, но всё ещё есть, сразу отдавайте их.

  • Запускайте фоновое обновление.

  • Обновляйте кэш, когда обновление завершится.

Пользователи почти никогда этого не замечают. Системы — всегда.

С точки зрения потока это выглядит так:

Запрос
   |
   v
Кэш (истёк, но запись есть)
   |
   +--> Сразу отдать устаревшие данные
   |
   +--> Запустить асинхронное обновление
              |
              v
            База данных
              |
              v
          Обновить кэш

Вы обмениваете несколько секунд устаревания на мощную защиту от перегрузки. Для большинства «горячих» данных (конфигураций, метаданных, счётчиков) — это простое решение. Идеальная актуальность обходится дорого. Небольшое устаревание — дёшево.

Паттерн №2: Probabilistic Early Expiration

Даже при использовании stale-while-revalidate вы не хотите, чтобы все обновляли данные одновременно.

Вероятностное раннее истечение срока распределяет обновления во времени за счёт добавления случайности. Вместо того чтобы истекать строго по TTL, каждый запрос с небольшой вероятностью может инициировать обновление ещё до истечения срока.

Концептуально:

Окно TTL
------------------------------------------------
|        |        |        |        |        |
|   R    |        |   R    |        |    R   |
------------------------------------------------
R = случайное раннее обновление

Вместо одного гигантского всплеска вы получаете множество небольших обновлений. База данных видит стабильную «струйку» запросов вместо синхронизированного наводнения.

Ключевая мысль: истечение срока — не один единственный момент. Это распределение вероятностей. Как только вы это принимаете, стадный эффект исчезает.

Паттерн №3: Mutex / Single-Flight Refresh

Иногда нужна максимальная актуальность или данные настолько дорогие, что дублирующие обновления больно бьют по системе.

В таких случаях используйте паттерн mutex (или single-flight):

  • Первый запрос захватывает блокировку и обновляет кэш.

  • Остальные либо немного ждут, либо получают устаревшие данные.

  • В базу данных уходит только один запрос.

Визуально:

Запросы
   |
   v
Промах кэша
   |
   v
Захватить блокировку?
   |
   +--> Да → Обновить из БД → Обновить кэш → Освободить блокировку
   |
   +--> Нет → Отдать устаревшее ИЛИ подождать

Компромиссом является задержка для «невезучего» запроса, который держит блокировку. Но эта задержка локализована. Коллапс базы данных — нет.

При аккуратном использовании этот паттерн превращает «штамповки» в упорядоченные очереди.

Что это на самом деле даёт (в цифрах)

Когда эти паттерны применяются вместе, эффект оказывается впечатляющим.

До:

Нагрузка на БД

^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|                                   |
|                                   |
|            ВСПЛЕСК                |
|^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^|

------------------------------------- Время

После:

Нагрузка на БД
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|     ~~~     ~~~     ~~~           |
|   ~~~   ~~~   ~~~                 |
| ~~~                               |
|                                   |
------------------------------------- Время

В реальных системах это означает:

  • снижение нагрузки на базу данных на 50–90% во время событий истечения срока;

  • более ровные кривые задержек;

  • меньше каскадных отказов при всплесках трафика.

Вы не устраняете нагрузку. Вы придаёте ей форму.

Распространённые ошибки, которые снова приводят к «стадным» всплескам

Большинство команд откатываются назад случайно.

Типичные сценарии, в которых всё ломается:

  • TTL настолько короткий, что сводит на нет любое сглаживание.

  • Блокировка всех запросов на время обновления вместо выдачи устаревших данных.

  • Отсутствие запасного сценария на случай сбоя обновления из-за чего ретраи начинают накапливаться.

  • Восприятие истечения срока кэша как ошибки, а не как нормального состояния.

Каждая из этих проблем проявляется только под нагрузкой, когда отлаживать сложнее всего.

Когда можно не заморачиваться

Не каждой системе нужна такая степень сложности.

Скорее всего, о «стадных» всплесках можно не думать, если:

  • трафик невысокий и без резких всплесков;

  • нет «горячих» ключей;

  • сервис недолговечный или используется только внутри компании.

Это не лень, а соразмерная инженерия. Опасность возникает когда вы после роста продолжаете считать, что всё ещё относитесь к этой категории.

Комментарий от Ильи Сазонова

Утверждение верно не только для кешей, но и вообще для всех, казалось бы "детских" проблем с производительностью. Они возникают на проектах, в которых нагрузка оказалась выше предполагаемой. Скорее всего потому что стартап "взлетел" и пришли пользователи. Поэтому в появлении таких проблем есть и позитивный компонент - их зачастую можно считать косвенным свидетельством, что дела у проекта иду хорошо.

Устаревшие данные дешевле простоя системы

Самый трудный урок здесь — психологический: кэширование не про «идеальную корректность». Оно про управление нагрузкой.

Безупречная актуальность — роскошь. Доступность — нет.

Любая высоконагруженная система в итоге это усваивает — либо спокойно на этапе проектирования, либо болезненно во время инцидента. Отдать чуть устаревшие данные на несколько секунд почти всегда дешевле, чем объяснять, почему ваша база данных превратилась в расплавленную лужу.

Если вам доводилось переживать кошмар из-за истечения кэша, то вы не одни. Поделитесь историей в комментариях. Кто-то сейчас всего в одном TTL от того, чтобы усвоить тот же урок.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.