При истечении записи кэша тысячи запросов одновременно обращаются к базе. Какой механизм превращает обычный промах кэша во всплеск нагрузки?
Это эффект лавины промахов кэша, или cache stampede: множество запросов одновременно обнаруживает отсутствие одной записи и независимо запускает одинаковое дорогое восстановление из базы. Кэш перестаёт сглаживать нагрузку, а сама база получает кратковременный всплеск запросов, способный вызвать рост задержек и каскадные ошибки.
Кэширование применяют, чтобы уменьшить число обращений к медленным или дорогим источникам данных и сократить задержку чтения. Однако обычная схема «при промахе прочитать источник и положить результат в кэш» не учитывает конкуренцию между запросами.
Проблема особенно заметна у популярных ключей, срок жизни которых истекает одновременно. Поэтому появились специальные приёмы защиты: single-flight, распределённые блокировки, вероятностное обновление, продление устаревших значений и случайное расхождение сроков жизни записей.
Пусть один ключ обслуживает значительный поток запросов. Пока запись есть в кэше, запросы почти не нагружают базу, но после истечения срока жизни все они видят промах и начинают получать одно и то же значение независимо друг от друга.
Если восстановление занимает заметное время, число параллельных обращений к базе растёт пропорционально входному потоку. База может исчерпать пул соединений или процессор, после чего увеличатся задержки, начнут срабатывать таймауты, а новые запросы усилят перегрузку.
Основной механизм защиты — коалесцирование запросов: для каждого ключа только один запрос выполняет восстановление значения, а остальные ждут его результат или получают ранее сохранённое значение. После успешного чтения результат помещается в кэш, и ожидающие запросы используют общий результат.
В распределённой системе координация должна работать между экземплярами сервиса. Для этого применяют распределённую блокировку, атомарную операцию резервирования обновления или специализированный механизм single-flight. Блокировка должна иметь срок действия, иначе отказ владельца блокировки оставит ключ заблокированным надолго.
Часто используют stale-while-revalidate: пока новое значение загружается, пользователям временно возвращают ещё допустимое устаревшее значение, а обновление выполняют в фоне. Это уменьшает задержку и защищает базу, но требует явного допуска к устаревшим данным и правил для случая, когда фоновое обновление не удалось.
Полезен и jitter — небольшое случайное расхождение сроков жизни записей. Оно снижает вероятность одновременного истечения большого набора ключей, но не устраняет проблему полностью: один очень популярный ключ всё равно может вызвать лавину.
У каждого варианта есть компромиссы. Ожидание результата обновления сохраняет свежесть, но добавляет задержку при первом запросе; устаревшие данные дают лучшую доступность, но снижают актуальность; распределённая блокировка уменьшает дублирование работы, однако сама становится зависимостью и источником задержек. Ошибки загрузки нельзя бездумно кэшировать надолго: это может надолго скрыть восстановление базы или закрепить временный сбой.
В каталоге товаров срок действия популярных записей истекал почти одновременно после массового обновления. Вариант с простым повторным чтением из базы был самым лёгким, но создавал пики нагрузки. Увеличение размера кэша проблему не решало, потому что причина состояла не в нехватке места, а в одновременном обновлении одного и того же ключа.
Команда могла выбрать только случайное продление TTL, только предварительное обновление или single-flight. Был выбран single-flight вместе с небольшим разбросом TTL: первый при промахе обновлял запись, остальные запросы переиспользовали его результат, а разброс уменьшал синхронное истечение связанных ключей.
Для критичных страниц дополнительно разрешили короткую выдачу устаревшего значения при временной недоступности базы. В результате устранено дублирование одинаковых запросов, а отказ базы перестал немедленно превращаться в лавину запросов; цена решения — контролируемая кратковременная устарелость данных и необходимость мониторить зависшие обновления.
1. Достаточно ли поставить блокировку на ключ, чтобы полностью решить проблему?
Нет. Блокировка предотвращает дублирование обновления только при корректной координации и ограниченном времени её жизни. Нужно учитывать падение владельца, истечение блокировки во время долгой загрузки, разделение сети и ситуацию, когда ожидающие запросы одновременно сдаются по таймауту.
Кроме того, нельзя удерживать блокировку дольше необходимого: медленный источник увеличит очередь ожидающих запросов. На практике задают таймаут ожидания, ограничивают число ожидающих и предусматривают возврат устаревшего значения либо контролируемую ошибку.
2. Почему случайный TTL не гарантирует отсутствие всплеска нагрузки?
Jitter только уменьшает корреляцию моментов истечения разных записей. Для одного горячего ключа срок жизни всё равно истечёт в конкретный момент, и множество запросов может одновременно обнаружить промах.
Поэтому jitter обычно дополняют single-flight, фоновым обновлением или предварительной загрузкой. Это разные уровни защиты: jitter распределяет события во времени, а коалесцирование ограничивает число одинаковых операций.
3. Как выбрать между ожиданием свежего значения и возвратом устаревшего?
Выбор определяется допустимой устарелостью и критичностью операции. Для цены, остатка товара или прав доступа устарелое значение может быть опасным, поэтому предпочтительнее ограниченное ожидание свежего результата и явная ошибка при превышении таймаута.
Для рекомендаций, публичного каталога или справочной информации короткая устарелость часто приемлема. Тогда stale-while-revalidate лучше защищает доступность и задержку, но срок допустимой устарелости должен быть измеримым, контролируемым и отражённым в мониторинге.