Как избежать Cache Stampede?

API7.ai

December 29, 2022

OpenResty (NGINX + Lua)

В предыдущей статье мы изучили некоторые методы оптимизации высокой производительности с использованием shared dict и lru cache. Однако мы оставили без внимания важную проблему, которая заслуживает отдельной статьи сегодня — "Cache Stampede" (Шторм кэша).

Что такое Cache Stampede?

Давайте представим сценарий.

Источник данных находится в базе данных MySQL, кэшированные данные хранятся в shared dict, а время жизни кэша составляет 60 секунд. В течение этих 60 секунд все запросы получают данные из кэша, а не из MySQL. Но как только проходит 60 секунд, кэшированные данные истекают. Если в этот момент поступает большое количество одновременных запросов, данные в кэше отсутствуют. Тогда функция запроса данных из источника будет вызвана, и все эти запросы отправятся в базу данных MySQL, что напрямую приведет к блокировке или даже падению сервера базы данных.

Это явление можно назвать "Cache Stampede", и иногда его также называют Dog-Piling. Ни один из примеров кода, связанного с кэшированием, которые появлялись в предыдущих разделах, не имеет соответствующей обработки этой проблемы. Ниже приведен пример псевдокода, который может привести к шторму кэша.

local value = get_from_cache(key) if not value then value = query_db(sql) set_to_cache(value, timeout = 60) end return value

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

Как этого избежать?

Давайте разделим обсуждение на несколько различных случаев.

1. Активное обновление кэша

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

В OpenResty мы можем реализовать это следующим образом.

Сначала мы используем ngx.timer.every для создания периодической задачи, которая будет выполняться каждую минуту, чтобы получать последние данные из базы данных MySQL и помещать их в shared dict:

local function query_db(premature, sql) local value = query_db(sql) set_to_cache(value, timeout = 60) end local ok, err = ngx.timer.every(60, query_db, sql)

Затем в логике кода, обрабатывающего запросы, нам нужно удалить часть, которая обращается к MySQL, и оставить только часть, которая получает данные из кэша shared dict.

local value = get_from_cache(key) return value

Вышеуказанные два фрагмента псевдокода помогают нам обойти проблему шторма кэша. Однако этот подход не идеален: каждый кэш должен соответствовать периодической задаче (в OpenResty есть ограничение на количество таймеров), и время жизни кэша должно соответствовать времени выполнения периодической задачи. Если в этот период произойдет ошибка, запросы могут продолжать получать пустые данные.

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

2. lua-resty-lock

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

Мы можем облегчить эти опасения, используя библиотеку lua-resty-lock в OpenResty для добавления блокировок. lua-resty-lock — это библиотека OpenResty, которая основана на shared dict и предоставляет неблокирующий API для блокировок. Давайте рассмотрим простой пример.

resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock" local lock, err = resty_lock:new("locks") local elapsed, err = lock:lock("my_key") -- query db and update cache local ok, err = lock:unlock() ngx.say("unlock: ", ok)'

Поскольку lua-resty-lock реализован с использованием shared dict, нам нужно сначала объявить имя и размер shdict, а затем использовать метод new для создания нового объекта lock. В приведенном выше фрагменте кода мы передаем только первый параметр — имя shdict. Метод new имеет второй параметр, который можно использовать для указания времени жизни, времени ожидания блокировки и многих других параметров. Здесь мы оставляем значения по умолчанию. Эти параметры используются для предотвращения взаимоблокировок и других исключений.

Затем мы можем вызвать метод lock, чтобы попытаться получить блокировку. Если нам удается получить блокировку, мы можем быть уверены, что только один запрос будет обращаться к источнику данных для обновления данных в один момент времени. Но если блокировка не удалась из-за конкуренции, тайм-аута и т.д., то данные извлекаются из устаревшего кэша и возвращаются запрашивающей стороне. Это подводит нас к API get_stale, который был представлен в предыдущем уроке.

local elapsed, err = lock:lock("my_key") # elapsed to nil означает, что блокировка не удалась. Возвращаемое значение err — это либо timeout, либо locked if not elapsed and err then dict:get_stale("my_key") end

Если lock успешен, то можно безопасно запросить данные из базы данных и обновить результаты в кэше, и, наконец, мы вызываем интерфейс unlock, чтобы освободить блокировку.

Комбинируя lua-resty-lock и get_stale, мы получаем идеальное решение проблемы шторма кэша. Документация lua-resty-lock содержит очень полный код для обработки этой проблемы. Если вам интересно, вы можете ознакомиться с ним здесь.

Давайте углубимся и посмотрим, как интерфейс lock реализует блокировку. Когда мы сталкиваемся с интересной реализацией, мы всегда хотим увидеть, как она реализована в исходном коде, что является одним из преимуществ открытого исходного кода.

local ok, err = dict:add(key, true, exptime) if ok then cdata.key_id = ref_obj(key) self.key = key return 0 end

Как упоминалось в статье о shared dict, все API shared dict являются атомарными операциями, и не нужно беспокоиться о конкуренции. Поэтому использование shared dict для отметки состояния блокировок — это хорошая идея.

Реализация lock выше использует dict:add, чтобы попытаться установить ключ: если ключ не существует в общей памяти, add вернет успех, что означает, что блокировка прошла успешно; другие конкурирующие запросы вернут ошибку, когда достигнут строки кода dict:add, и затем код может выбрать, возвращаться ли сразу или повторить попытку несколько раз на основе возвращенной информации err.

3. lua-resty-shcache

В вышеуказанной реализации lua-resty-lock вам нужно обрабатывать блокировки, разблокировки, получение устаревших данных, повторные попытки, обработку исключений и другие вопросы, что все еще довольно утомительно.

Вот простая обертка для вас: lua-resty-shcache, это библиотека lua-resty от Cloudflare, которая делает слой инкапсуляции поверх shared dictionaries и внешнего хранилища и предоставляет дополнительные функции для сериализации и десериализации, так что вам не нужно заботиться о вышеуказанных деталях:

local shcache = require("shcache") local my_cache_table = shcache:new( ngx.shared.cache_dict { external_lookup = lookup, encode = cmsgpack.pack, decode = cmsgpack.decode, }, { positive_ttl = 10, -- кэшировать хорошие данные на 10 секунд negative_ttl = 3, -- кэшировать неудачные запросы на 3 секунды name = 'my_cache', -- "именованный" кэш, полезно для отладки / отчетов } ) local my_table, from_cache = my_cache_table:load(key)

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

4. Директивы NGINX

Если вы не используете библиотеку lua-resty OpenResty, вы также можете использовать директивы конфигурации NGINX для блокировок и получения устаревших данных: proxy_cache_lock и proxy_cache_use_stale. Однако мы не рекомендуем использовать директивы NGINX здесь, так как они недостаточно гибки, и их производительность не так хороша, как у кода на Lua.

Итог

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

Последний вопрос: Как вы обрабатываете шторм кэша и подобные проблемы в языках и платформах, с которыми вы знакомы? Есть ли способ лучше, чем в OpenResty? Поделитесь со мной в комментариях.