Кеш «прогрелся» и уронил базу: эффект стада
Ночью перезапустили Redis по плану — новая версия, всё по регламенту. Через несколько секунд после старта база данных легла под нагрузкой, которую в обычный день даже не замечает. Дежурный инженер первым делом смотрит на кеш: он же только что перезапущен, значит, ни при чём. А проблема как раз в кеше — просто не там, где её ищут.
Содержание
- Парадокс: кеш должен защищать базу, а не топить её
- Механика cache stampede: как один промах превращается в лавину
- Почему опасность растёт вместе с популярностью ключа
- Мьютекс на заполнение ключа: в базу идёт только первый
- Stale-while-revalidate: отдаём немного несвежее, но не рушим базу
- Джиттер TTL: не давайте ключам истекать хором
Парадокс: кеш должен защищать базу, а не топить её
Смысл кеширования — снять с базы повторяющиеся запросы. Один раз посчитали дорогой SELECT с джойнами, положили результат в Redis на пару минут — и следующие тысячи запросов от разных пользователей получают ответ из памяти, не трогая диск и не занимая соединение с базой. Чем популярнее данные, тем больше выигрыш.
Именно поэтому авария выглядит нелогично. Кеш не сломался, метрики hit rate до инцидента были в порядке. Но в момент, когда самый горячий ключ исчез — истёк по TTL или пропал целиком после рестарта, — база получила не один переоткрытый запрос, а лавину одинаковых запросов от всех, кто в эту секунду обращался к приложению. Разбор похожей ситуации, только с блокировками внутри самой СУБД, есть в статье «База встала на ровном месте» — там другая механика, но тот же принцип: тихая на вид причина даёт взрывной эффект в момент совпадения по времени.
Это явление называется thundering herd применительно к кешу, или cache stampede, реже — dog-piling. При обычной нагрузке кеш работает исправно годами, и про этот сценарий вспоминают только после первого падения.
Механика cache stampede: как один промах превращается в лавину
По шагам, что происходит в момент истечения популярного ключа.
- Ключ
product:12345:priceкешируется на 60 секунд. Пока он жив, все запросы к нему отдаются из кеша, база не участвует. - TTL истекает. Очередной запрос видит
nilи идёт в базу за свежим значением. - Между «TTL истёк» и «новое значение записано обратно в кеш» проходит время выполнения запроса к базе — пусть даже несколько десятков миллисекунд. За это окно к тому же ключу успевают прийти ещё десятки или сотни параллельных запросов.
- Каждый из них тоже видит пустой кеш и тоже идёт в базу за тем же самым значением — не по ошибке, а потому что в наивной реализации проверка «есть ли кеш» никак не связана между процессами.
- База вместо одного запроса получает залп одинаковых тяжёлых запросов одновременно. Если запрос дорогой (агрегация, джойн по большой таблице), даже пара десятков параллельных копий может забить пул соединений или упереться в CPU.
- Пока база отвечает медленно из-за перегрузки, окно гонки растёт ещё больше — положительная обратная связь: чем хуже базе, тем дольше держится окно, тем больше новых запросов в него попадает.
Наивный код, который к этому приводит:
def get_price(product_id):
value = redis.get(f"product:{product_id}:price")
if value is None:
value = db.query("SELECT price FROM products WHERE id = %s", product_id)
redis.set(f"product:{product_id}:price", value, ex=60)
return value
Логика для одного вызова верна. Проблема в том, что функцию параллельно вызывают сотни воркеров, а координации между ними нет.
Второй вариант того же явления — не истечение TTL по одному ключу, а полная очистка кеша: FLUSHALL, перезапуск без persistence, деплой с несовместимым форматом ключей. Тогда «стадо» получают сразу все горячие ключи — тот же механизм, но в максимально жёсткой форме, и именно так выглядела авария из вступления. Смежный разбор потери данных при рестарте — в статье «Redis теряет данные после перезапуска».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему опасность растёт вместе с популярностью ключа
Здесь вторая часть парадокса: чем лучше кеш работает в обычном режиме, тем опаснее момент его истечения. Холодный, редко запрашиваемый ключ истекает без последствий — за секунду до следующего обращения к нему просто некому создать параллельную нагрузку. А ключ, который держит на себе большую долю трафика именно потому, что данные популярны (главная страница, цена ходового товара, конфигурация фичи), в момент истечения становится точкой, куда сходится весь этот трафик разом.
Нагрузка на базу в момент промаха кеша примерно пропорциональна тому, сколько запросов к ключу пришло за время его заполнения — конкретные цифры зависят от RPS на ключ и от длительности запроса к базе, и на разных проектах они будут разными. Важен принцип: у популярных ключей это произведение обычно на порядки больше, чем у второстепенных, поэтому стампед почти никогда не проявляется на редких данных — только на самых нагруженных.
Отсюда практический вывод: защиту от стада стоит внедрять не для всего кеша подряд, а в первую очередь для десятка самых горячих ключей — они дают основной риск, остальные можно оставить с простым TTL.
Мьютекс на заполнение ключа: в базу идёт только первый
Первый фикс — не пускать в базу всех, кто увидел пустой кеш, а пускать только одного. Остальные ждут его результат или получают что-то временное. Это single-flight, или lock-based cache filling.
def get_price(product_id):
key, lock_key = f"product:{product_id}:price", f"lock:product:{product_id}:price"
value = redis.get(key)
if value is not None:
return value
if redis.set(lock_key, "1", nx=True, px=5000): # лок получит ровно один
try:
value = db.query("SELECT price FROM products WHERE id = %s", product_id)
redis.set(key, value, ex=60)
finally:
redis.delete(lock_key)
return value
for _ in range(10): # кто-то другой уже считает
time.sleep(0.05)
value = redis.get(key)
if value is not None:
return value
return get_stale_or_default(product_id) # не дождались — деградация
SET ... NX атомарно проверяет и ставит блокировку одной командой, поэтому даже при гонке лок получит ровно один процесс. PX 5000 — защита от «зависшего» лока, если процесс упадёт и не снимет его сам.
В части стека это уже реализовано за вас: в Go — пакет golang.org/x/sync/singleflight схлопывает параллельные вызовы одной функции в один; в Nginx как reverse-proxy-кеш — директива proxy_cache_lock on; делает то же самое на уровне прокси; в Varnish — связка grace/beresp.grace. Без такой защиты параллельные попадания в базу нередко утыкаются не в саму нагрузку, а в исчерпание пула соединений — это тот же по сути сценарий, что разобран в статье «MySQL: ошибка Too many connections».
Минус подхода — часть запросов ждёт (обычно недолго, десятки-сотни миллисекунд, конкретное время зависит от того, сколько считается значение), и нужно продумать, что отдавать тем, кто не дождался.
Stale-while-revalidate: отдаём немного несвежее, но не рушим базу
Второй паттерн решает ту же проблему иначе: не заставлять пользователей ждать, а отдавать чуть устаревшие данные, пока в фоне тихо считается новое значение. Это stale-while-revalidate — подход, знакомый по одноимённому HTTP-заголовку Cache-Control, но применимый и к кешу на уровне приложения.
Идея — два TTL вместо одного: «мягкий» (например, 60 секунд), после которого данные считаются устаревшими, но ещё пригодны к выдаче, и «жёсткий» (например, 300 секунд), после которого данные удаляются окончательно.
def get_price(product_id):
key = f"product:{product_id}:price"
cached = redis.get(key) # хранит {"value": ..., "fresh_until": ts}
if cached and cached["fresh_until"] > now():
return cached["value"] # горячий путь
if cached:
if acquire_lock(f"lock:{key}"):
enqueue_background_refresh(product_id) # пересчёт в фоне, один раз
return cached["value"] # отдаём устаревшее, но сразу
return get_price_with_lock(product_id) # холодный старт — только лок
Разница с обычным мьютексом принципиальная: пользователь никогда не ждёт пересчёта — он получает либо свежие данные, либо чуть устаревшие, но мгновенно. В базу по-прежнему ходит только один фоновый процесс на ключ. Тот же принцип стоит за proxy_cache_use_stale updating error timeout; в Nginx и за grace-режимом в Varnish — прокси отдаёт старую версию страницы, пока обновление не досчиталось.
Плата честная: клиент временно видит не самые свежие данные. Для цены товара или списка новостей это почти всегда допустимо. Для баланса счёта или статуса платежа — нет: там нужна консистентность, и TTL-кеш со stale-режимом там неуместен без явной инвалидации.
Джиттер TTL: не давайте ключам истекать хором
Оба паттерна выше защищают от гонки вокруг одного ключа. Но есть частный случай, который они сами по себе не решают: массовый холодный старт, когда после рестарта тысячи ключей получают одинаковый TTL и потом синхронно истекают все разом. Мьютекс спасёт от параллельных запросов к одному ключу, но если таких «одновременно истёкших» ключей тысяча, база всё равно получит тысячу первых запросов подряд — по одному на ключ вместо стада на один ключ.
Лечится это разбросом (jitter) TTL, чтобы одинаковые по смыслу ключи не истекали синхронно:
import random
def set_with_jitter(key, value, base_ttl=60, jitter=15):
ttl = base_ttl + random.randint(-jitter, jitter)
redis.set(key, value, ex=ttl)
При базовом TTL 60 секунд и джиттере ±15 секунд ключи, записанные в один момент, разъедутся по времени истечения в окне 45-75 секунд вместо одной точной секунды. Конкретные значения базового TTL и амплитуды джиттера подбираются под ваш профиль нагрузки — важен сам факт разброса, а не эти цифры как таковые.
Для сценария «полный сброс кеша после рестарта» джиттер работает в связке с прогревом: вместо того чтобы отдавать инстанс в продакшн сразу после старта с пустым кешем, полезно прогнать по нему прогревочные запросы к топ-N самым горячим ключам ещё до реального трафика — тот же принцип, что применяют для прогрева файлового кеша ОС, только на уровне Redis.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Достаточно ли просто увеличить TTL, чтобы избежать стада?
Нет, это отодвигает проблему во времени, но не убирает её: ключ всё равно истечёт, и стадо станет реже, но не исчезнет. Лучше сочетать разумный TTL с мьютексом или stale-while-revalidate, а не пытаться «перерасти» проблему одним параметром.
Работает ли мьютекс, если у меня несколько инстансов приложения за балансировщиком?
Да, если лок хранится в общем месте — в Redis через SET NX, а не в памяти процесса. Лок в памяти одного инстанса не остановит остальные инстансы от похода в базу.
Что делать, если у меня Memcached, а не Redis, и там нет SET NX?
В части реализаций Memcached есть команда add, которая кладёт значение только если ключа ещё нет, — функциональный аналог SET NX для той же схемы лока. Если её нет, проще перенести лок на отдельный Redis только для локов или использовать stale-while-revalidate, которому распределённый лок нужен реже.
Как понять, что у меня уже был или скоро будет cache stampede?
Косвенный признак — скачки нагрузки на базу, совпадающие по времени не с ростом пользовательского трафика, а с TTL-интервалом самых горячих ключей или с рестартами кеширующего сервиса. Если такие скачки повторяются с характерной периодичностью, это почти наверняка стадо. Заодно стоит проверить первый запрос к базе после холодного старта — подробнее в статье «Почему первый запрос к базе медленный».
Нужно ли применять эти паттерны ко всем ключам в кеше?
Нет, оправдано это в первую очередь для самых горячих ключей — тех, что несут основную долю трафика. Для длинного хвоста редко запрашиваемых ключей риск синхронного стада минимален, и добавлять туда лишнюю сложность обычно не нужно.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →