При ревью обработчика чтения профиля обнаружена ветка ниже. Какой механизм может перегрузить базу данных при одновременном истечении TTL и как исправить этот дефект?
profile = cache.get(user_id)
if profile is not found:
profile = database.load(user_id)
cache.set(user_id, profile, ttl=300)
return profile
Это cache stampede: при промахе кэша несколько параллельных запросов одновременно обращаются к базе данных и затем независимо заполняют кэш. Исправление — коалесцировать запросы по ключу: первый запрос загружает данные, остальные ждут его результат; после захвата блокировки нужно повторно проверить кэш.
Паттерн cache-aside появился как простой способ отделить кэширование от основной системы хранения: приложение само читает кэш, а при промахе загружает данные из базы и помещает их в кэш. Такой подход удобен, потому что кэш можно добавлять или заменять независимо от схемы базы.
Однако обычный cache-aside не координирует параллельные промахи. Поэтому при высокой конкуренции один истёкший ключ превращается в множество одинаковых запросов к источнику данных.
Пока значение находится в кэше, запросы обслуживаются быстро и почти не нагружают базу. Когда TTL истекает, все запросы, пришедшие в коротком интервале, видят промах и начинают загрузку независимо друг от друга.
Если загрузка из базы занимает 100 миллисекунд, а за это время приходит 10 000 запросов, база может получить примерно 10 000 одинаковых операций вместо одной. Это увеличивает задержки, создаёт очереди соединений и может вызвать каскадную деградацию всей системы.
Проблема особенно заметна для популярных ключей, при массовом прогреве кэша, после сбоя кэша или если значения получают одинаковый TTL.
Основной механизм — request coalescing, или объединение конкурентных запросов. Для каждого ключа выбирается один запрос-владелец, который загружает значение, а остальные ожидают его завершения.
Минимальная схема выглядит так:
Повторная проверка кэша внутри блокировки обязательна: пока первый запрос ожидал блокировку, другой запрос мог уже загрузить и сохранить значение. В распределённой системе блокировка должна действовать между экземплярами сервиса; локальный mutex защищает только один процесс.
Блокировка должна иметь lease, тайм-аут и корректное освобождение при ошибке, иначе падение владельца оставит ключ заблокированным. Ожидание также ограничивают по времени: при недоступности базы нельзя бесконечно удерживать клиентские запросы.
Дополнительные меры — случайное отклонение TTL, ограничение срока жизни устаревшего значения и отдельное ограничение частоты загрузок из базы. Stale-while-revalidate позволяет временно отдавать немного устаревшие данные, одновременно обновляя их в фоне, но подходит только при допустимой задержке актуальности.
Компромисс состоит в том, что коалесцирование уменьшает нагрузку на базу, но добавляет координацию и задержку для ожидающих запросов. Для редко запрашиваемых ключей сложная распределённая блокировка может быть дороже самой загрузки; поэтому иногда достаточно локального объединения или ограничения конкуренции.
В каталоге товаров после планового обновления кэша популярная карточка товара одновременно запрашивалась тысячами клиентов. Вариант с увеличением размера пула соединений к базе временно повышал пропускную способность, но увеличивал конкуренцию за ресурсы и не устранял повторные чтения.
Второй вариант — заранее прогревать весь каталог. Он снижал число промахов, но требовал много ресурсов и плохо работал при появлении новых популярных товаров.
Выбрали распределённое объединение запросов по ключу, повторную проверку кэша и случайный сдвиг TTL. В результате для одного популярного товара выполнялась одна загрузка за цикл обновления, а остальные запросы получали результат из кэша или ждали короткое время. Решение сохранило актуальность данных без пропорционального роста нагрузки на базу.
Нет, если сервис запущен в нескольких экземплярах. Каждый процесс будет иметь собственную блокировку, поэтому при промахе все экземпляры одновременно обратятся к базе. Для межпроцессной координации нужен общий механизм, например распределённая блокировка в внешнем хранилище, либо архитектура, направляющая операции одного ключа к одному владельцу.
При этом распределённая блокировка сама становится зависимостью. Нужны тайм-аут, lease, защита от захвата просроченной блокировки и понятное поведение при недоступности координирующего хранилища.
Потому что запрос мог ждать блокировку, пока предыдущий владелец уже загрузил данные и записал их в кэш. Без повторной проверки новый владелец снова пойдёт в базу, хотя значение уже доступно.
Такая проверка делает загрузку фактически однократной для конкурентной группы запросов. Она также защищает от ситуации, когда блокировка была освобождена после успешной записи, но ожидающий запрос этого ещё не знал.
Нет. Он объединяет только запросы к одному ключу. Если одновременно истекли тысячи разных ключей, для каждого из них может начаться отдельная загрузка, и суммарная нагрузка на базу всё равно будет высокой.
Для такого случая нужны глобальное или групповое ограничение конкуренции, приоритеты, предварительный прогрев, случайный TTL и контроль допустимой нагрузки на источник. Эти меры ограничивают общий поток запросов, тогда как coalescing устраняет только дублирование внутри одного ключа.