Почему кеш на 60 секунд иногда работает хуже, чем кеш на пять
Интуиция подсказывает: чем дольше кеш держит значение, тем реже вы дёргаете базу или внешний API — значит, TTL нужно увеличивать при любой возможности. Логика верна в среднем, но именно слово «в среднем» здесь и прячет проблему. На практике TTL в 60 секунд иногда роняет источник данных так, как TTL в 5 секунд никогда бы не смог — не потому что запросов стало больше, а потому что они вдруг пришли не растянуто, а все разом. Разберём, откуда берётся этот эффект и как от него защититься, не жертвуя выгодой длинного TTL.
Содержание
- Простая математика: длинный TTL — меньше запросов к источнику
- Эффект стада: что происходит, когда TTL истекает одновременно
- Почему синхронное истечение — типичный сценарий, а не редкость
- Короткий TTL с равномерным разбросом — почему пиковая нагрузка мягче
- Jitter: случайный разброс времени жизни записи
- Блокировка пересчёта: single-flight и «отдать старое, пока считаем новое»
Простая математика: длинный TTL — меньше запросов к источнику
Базовая модель, на которой держится вся интуиция про «увеличьте TTL — станет легче», выглядит так. У вас есть кешируемый ключ (результат запроса к БД, ответ внешнего API, посчитанная агрегация). Пока запись жива в кеше, все обращения к ней обслуживаются из кеша и источник данных вообще не трогается. Как только TTL истекает, следующий запрос к этому ключу считается «промахом» — он идёт к источнику, получает свежее значение и кладёт его обратно в кеш с новым TTL.
Если трафик к ключу распределён равномерно во времени, то на каждый интервал длиной T (ваш TTL) приходится ровно один поход к источнику — на пересчёт. Увеличили TTL с 5 до 60 секунд — сократили частоту обращений к источнику в 12 раз. Для БД, которая и так на пределе по CPU или IOPS, это выглядит как чистый выигрыш, и в большинстве обзоров по кешированию на этом мысль и останавливается.
Проблема в скрытом допущении: «трафик распределён равномерно» и «промахи разных ключей не совпадают по времени». Оба допущения регулярно нарушаются в реальных системах, и именно тогда длинный TTL превращается из решения в источник новой проблемы.
Эффект стада: что происходит, когда TTL истекает одновременно
Эффект стада (thundering herd, он же cache stampede) — это ситуация, когда запись в кеше истекает, и в этот момент к ней одновременно обращаются не один, а сразу много параллельных запросов. Ни один из них не видит валидного значения в кеше — все они видят промах. И поскольку между запросами нет никакой координации, каждый из них по отдельности решает: «кеш пуст, надо сходить в источник и пересчитать». В результате источник получает не один запрос на пересчёт, а сразу N — ровно в тот момент, когда он меньше всего к этому готов, потому что до этого момента вообще не получал нагрузки по этому ключу.
Если пересчёт значения — это тяжёлый SQL-запрос с агрегацией по таблице на миллионы строк, а не просто SELECT по индексу, разница между «один такой запрос в 60 секунд» и «пятьдесят таких запросов одновременно раз в 60 секунд» — это разница между фоновой нагрузкой и полноценной перегрузкой БД, с ростом времени ответа, исчерпанием пула соединений и каскадным замедлением всего остального, что в этот момент работает с той же базой. Похожий сценарий подробно разобран в статье про реальный инцидент с прогревом кеша, уронившим базу — там видно, как безобидный на первый взгляд TTL превращается в спайк нагрузки на ровном месте.
Ключевая деталь: масштаб проблемы зависит не от среднего числа запросов к источнику, а от того, сколько из них могут столкнуться в одной и той же секунде. Средняя нагрузка от короткого и длинного TTL может отличаться в разы, а вот пиковая нагрузка от синхронного истечения может отличаться на порядки — и именно пик, а не среднее, чаще всего кладёт систему.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему синхронное истечение — типичный сценарий, а не редкость
Может показаться, что одновременное истечение TTL для десятков параллельных запросов — экзотика, которая случается редко. На практике условия для неё создаются сами системой, и часто это происходит систематически:
- Прогрев кеша при старте или деплое. Приложение поднимается, заранее прогревает N ключей одним циклом (например, топ популярных товаров или конфиги фич), и все они получают одинаковый TTL в один и тот же момент времени. Ровно через T секунд после прогрева все эти записи истекают синхронно.
- Периодические фоновые задачи. Cron-джоб раз в час пересчитывает набор агрегатов и кладёт их в кеш с фиксированным TTL. Следующее истечение всей пачки снова произойдёт одновременно — это встроено в саму схему обновления.
- Несколько инстансов приложения, поднятых одним раскатом. При деплое через оркестратор все поды или контейнеры стартуют почти одновременно и заполняют локальный (in-process) кеш в один и тот же временной интервал — со схожим эффектом на всю группу.
- Круглые значения TTL, привязанные к границам минут. TTL вроде 60, 300 или 3600 секунд сам по себе не опасен, но в сочетании с периодическими клиентами (внешние воркеры, опрашивающие API по расписанию, поллинг из фронтенда с фиксированным интервалом) легко получить резонанс: момент истечения кеша и момент всплеска спроса начинают совпадать не случайно, а систематически.
Важно понимать: чем длиннее TTL, тем реже случается такое событие, но тем «дороже» оно обходится, когда всё же случается — накопленный за T секунд спрос на пересчёт разряжается одним залпом. Короткий TTL синхронизируется реже и на меньшем масштабе просто потому, что интервал накопления спроса короче.
Короткий TTL с равномерным разбросом — почему пиковая нагрузка мягче
Теперь возьмём противоположный сценарий. TTL — 5 секунд, но ключи создавались (то есть впервые запрашивались и клались в кеш) в случайные моменты — как это обычно и бывает при живом органическом трафике без единого события-триггера вроде общего деплоя. Тогда истечение каждой записи привязано к моменту её собственного создания, а не к общему для всех событию. Записи «расфазированы» естественным образом — просто потому что пользователи обращались к системе не строем, а вразнобой.
В такой картине суммарное число обращений к источнику за час действительно выше, чем при TTL в 60 секунд — это прямое следствие математики из первого раздела. Но каждое отдельное обращение — это, как правило, один запрос на пересчёт одного ключа, а не десятки одновременных. Нагрузка на источник выглядит не как редкие острые пики, а как более частый, но тонкий и почти непрерывный фон.
Здесь и находится суть парадокса, вынесенного в заголовок: для capacity planning почти всегда важнее не среднее число запросов к источнику в секунду, а максимальная одновременная нагрузка, которую источник должен выдержать в худший момент. БД или внешний API может спокойно нести условные 50 пересчётов в минуту, равномерно растянутых во времени, и захлебнуться, получив те же 50 пересчётов, слипшихся в одну секунду. Инфраструктура почти всегда закладывает запас по среднему, а не по десятикратному мгновенному всплеску — именно поэтому «редкий, но синхронный» вариант на практике часто оказывается опаснее «частого, но размазанного».
Это не аргумент в пользу «всегда ставьте короткий TTL» — короткий TTL просто по своей природе реже создаёт условия для резонанса, а не потому, что он магическим образом лучше длинного. Правильный вывод — не отказ от длинного TTL, а разрыв синхронизации, о котором ниже.
Jitter: случайный разброс времени жизни записи
Первая и самая дешёвая техника смягчения — добавить в TTL небольшой случайный разброс (jitter), чтобы записи, созданные в один момент времени с одинаковым базовым TTL, всё равно истекали не одновременно, а размазанно по некоторому окну.
import random
def set_with_jitter(cache, key, value, base_ttl=60, jitter_ratio=0.15):
jitter = int(base_ttl * jitter_ratio)
ttl = base_ttl + random.randint(-jitter, jitter)
cache.set(key, value, ex=ttl)
Здесь TTL в 60 секунд на самом деле ложится в диапазон примерно 51–69 секунд для каждой конкретной записи. Если вы прогреваете сотню ключей одним циклом, они истекут не одним залпом, а размазанно на протяжении почти 20-секундного окна — и источник получит не стадо одновременных запросов, а последовательность из небольших групп, для каждой из которых нагрузка кратно ниже пиковой.
Пара практических нюансов:
- Размер jitter имеет смысл задавать в процентах от базового TTL (10–20%), а не фиксированным числом секунд — иначе для коротких TTL разброс окажется слишком грубым, а для длинных — незаметным.
- На уровне HTTP-кеширования тот же принцип реализуется через заголовок
Cache-Control, если он формируется на стороне приложения: значениеmax-ageможно слегка варьировать между ответами вместо жёсткой константы. - Nginx как прокси-кеш не умеет случайно варьировать
proxy_cache_validдля одного и того же location из коробки — разброс там стоит закладывать на уровне бэкенда (в заголовке ответа) или переносить логику пересчёта в приложение.
Jitter не устраняет эффект стада полностью — он снижает вероятность и масштаб совпадения, но не гарантирует его отсутствие: у популярного «горячего» ключа с высокой параллельной нагрузкой даже небольшое окно совпадения может собрать заметное число одновременных запросов. Поэтому вторая техника — не альтернатива jitter, а его обязательное дополнение для по-настоящему нагруженных ключей.
Блокировка пересчёта: single-flight и «отдать старое, пока считаем новое»
Вторая техника решает проблему в лоб: даже если несколько запросов одновременно обнаружили промах кеша, к источнику должен уйти только один запрос на пересчёт. Остальные либо ждут его результата, либо получают немного устаревшее значение, пока свежее считается в фоне.
На уровне Redis это обычно реализуется распределённой блокировкой через SET ... NX EX:
SET lock:product:1234 1 NX EX 5
Если ключ блокировки успешно установлен — именно этот запрос идёт в источник, пересчитывает значение, кладёт его в кеш и снимает блокировку. Если ключ уже занят — запрос либо коротко ждёт и перечитывает кеш (значение вот-вот появится), либо, если в кеше ещё лежит старое значение с истёкшим TTL, отдаёт его пользователю как временно допустимое — это и есть паттерн stale-while-revalidate: чуть устаревшие данные почти всегда лучше, чем лежащий под нагрузкой источник.
Если кеширование стоит на уровне обратного прокси, Nginx решает ту же задачу штатными директивами, без самодельной блокировки на Redis:
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=60m;
location /api/ {
proxy_cache api_cache;
proxy_cache_valid 200 60s;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_use_stale updating error timeout;
proxy_pass http://backend;
}
proxy_cache_lock on заставляет Nginx пропускать к бэкенду только первый запрос, обнаруживший промах по данному ключу — остальные параллельные запросы к тому же URL ждут (до proxy_cache_lock_timeout), пока первый не заполнит кеш. proxy_cache_use_stale updating идёт дальше и позволяет отдавать другим клиентам старую закешированную копию прямо во время пересчёта, вместо того чтобы держать их в очереди ожидания. Тот же принцип на уровне HTTP выражается заголовком Cache-Control: max-age=60, stale-while-revalidate=30 — поддерживающие его CDN и браузеры отдают устаревший ответ ещё до 30 секунд после истечения max-age, параллельно обновляя копию в фоне, не размножая запросы к источнику. Базовую настройку самого прокси-кеша под такие директивы разумно свести в единый конфиг — например, отталкиваясь от статьи про настройку кеширования в Nginx, а для варианта с распределённой блокировкой пригодится пошаговая установка и настройка Redis под такой сценарий.
Если пересчёт происходит не в прокси, а прямо в коде приложения, тот же принцип называется single-flight: несколько одновременных вызовов с одинаковым ключом схлопываются в один реальный вызов источника, а остальные получают тот же результат, когда он будет готов. В большинстве экосистем для этого есть готовые библиотеки — не обязательно писать блокировку с нуля поверх Redis, если приложение работает в рамках одного процесса или одного шарда.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Так какой TTL правильный — 5 секунд или 60?
Вопрос сформулирован неверно: сам по себе размер TTL — не главная переменная. Правильная комбинация — TTL, подобранный по допустимой свежести данных и нагрузке на источник, плюс jitter и блокировка пересчёта поверх него. Длинный TTL с этими двумя техниками почти всегда безопаснее и выгоднее, чем короткий TTL без них.
Jitter не усложняет отладку — TTL ведь становится непредсказуемым?
Немного усложняет, но предсказуемо: вы точно знаете диапазон (например, ±15% от базового значения), и это стоит зафиксировать в комментарии к коду или конфигу. Цена такой небольшой неопределённости несравнима с ценой незапланированного всплеска нагрузки на БД.
У меня один инстанс и небольшая нагрузка — нужен ли мне jitter вообще?
При низком RPS вероятность реального стада действительно мала, но добавить jitter почти ничего не стоит, а страхует систему на будущее — при росте трафика или при добавлении периодических batch-джобов, которые массово прогревают кеш.
А если источник не переживёт даже один параллельный всплеск, независимо от TTL?
Тогда jitter — не решающая мера, а блокировка пересчёта (single-flight или proxy_cache_lock) обязательна вне зависимости от длины TTL. Именно она физически гарантирует не больше одного одновременного похода к источнику на ключ.
Меняется ли что-то, если кеш локальный (in-memory) на каждом инстансе, а не общий Redis?
Да: без общей точки координации между инстансами блокировка через Redis не сработает сама по себе — либо переносите блокировку в общее хранилище, либо, как минимум, добавляйте jitter к TTL в каждом инстансе отдельно, чтобы снизить вероятность синхронного пересчёта сразу на всех узлах одновременно.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →