Ключи к высокой производительности: `shared dict` и `lru` кэш

API7.ai

December 22, 2022

OpenResty (NGINX + Lua)

В предыдущей статье я представил методы оптимизации OpenResty и инструменты для настройки производительности, которые включают string, table, Lua API, LuaJIT, SystemTap, flame graphs и другие.

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

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

Кэш

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

Здесь я рекомендую вам изучить различные механизмы кэширования MySQL перед тем, как проектировать собственное кэширование. Материал, который я рекомендую, — это отличная книга High Performance MySQL: Optimization, Backups, and Replication. Когда я занимался базами данных много лет назад, эта книга принесла мне много пользы, и многие другие сценарии оптимизации позже также заимствовали дизайн MySQL.

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

В общем, кэширование имеет два принципа.

  • Первый: чем ближе к запросу пользователя, тем лучше. Например, не отправляйте HTTP-запросы, если можно использовать локальный кэш. Отправляйте запрос на исходный сайт, если можно использовать CDN, и не отправляйте его в базу данных, если можно использовать кэш OpenResty.
  • Второй: старайтесь использовать этот процесс и локальный кэш для решения задачи. Потому что при переходе между процессами, машинами и даже серверными комнатами сетевые накладные расходы кэширования будут очень большими, что будет очень заметно в сценариях с высокой нагрузкой.

В OpenResty дизайн и использование кэша также следуют этим двум принципам. В OpenResty есть два компонента кэширования: shared dict кэш и lru кэш. Первый может кэшировать только строковые объекты, и данные кэша существуют в единственном экземпляре, доступном каждому воркеру, поэтому он часто используется для обмена данными между воркерами. Второй может кэшировать все Lua-объекты, но они доступны только в рамках одного процесса воркера. Количество кэшированных данных равно количеству воркеров.

Следующие две простые таблицы иллюстрируют разницу между shared dict и lru кэшем:

Название компонента кэшаОбласть доступаТип данных кэшаСтруктура данныхМожно получить устаревшие данныеКоличество APIИспользование памяти
shared dictМежду несколькими воркерамистроковые объектыdict, queueда20+один экземпляр данных
lru cacheВ рамках одного воркеравсе Lua-объектыdictнет4n экземпляров данных (N = количество воркеров)

shared dict и lru кэш не являются хорошими или плохими. Они должны использоваться вместе в зависимости от вашего сценария.

  • Если вам не нужно обмениваться данными между воркерами, то lru может кэшировать сложные типы данных, такие как массивы и функции, и имеет наивысшую производительность, поэтому это первый выбор.
  • Но если вам нужно обмениваться данными между воркерами, вы можете добавить shared dict кэш на основе lru кэша, чтобы сформировать двухуровневую архитектуру кэширования.

Далее давайте подробно рассмотрим эти два способа кэширования.

Кэш Shared dict

В статье о Lua мы уже подробно рассказывали о shared dict, здесь кратко рассмотрим его использование:

$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs dict:set("Tom", 56) print(dict:get("Tom"))'

Вам нужно заранее объявить зону памяти dogs в конфигурационном файле NGINX, а затем её можно использовать в Lua-коде. Если вы обнаружите, что выделенного пространства для dogs недостаточно, вам нужно сначала изменить конфигурационный файл NGINX, а затем перезагрузить NGINX, чтобы изменения вступили в силу. Потому что мы не можем расширять или уменьшать пространство во время выполнения.

Теперь давайте сосредоточимся на нескольких вопросах, связанных с производительностью в кэше shared dict.

Сериализация кэшированных данных

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

resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs dict:set("Tom", require("cjson").encode({a=111})) print(require("cjson").decode(dict:get("Tom")).a)'

Однако такие операции сериализации и десериализации очень затратны по CPU. Если так много операций выполняется на каждый запрос, вы можете увидеть их потребление на flame graph.

Итак, как избежать этого потребления в shared dictionaries? Здесь нет хорошего способа, либо избегать размещения массива в shared dictionary на уровне бизнес-логики; либо вручную склеивать строки в формате JSON. Конечно, это также приведет к затратам на производительность из-за склейки строк и может скрыть больше ошибок.

Большую часть сериализации можно разобрать на уровне бизнес-логики. Вы можете разбить содержимое массива и сохранить его в shared dictionary в виде строк. Если это не работает, вы также можете кэшировать массив в lru, используя пространство памяти в обмен на удобство и производительность программы.

Кроме того, ключ в кэше также должен быть как можно короче и осмысленным, чтобы экономить пространство и облегчить последующую отладку.

Устаревшие данные

В shared dict также есть метод get_stale для чтения данных. По сравнению с методом get, он имеет дополнительное возвращаемое значение для устаревших данных:

resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs dict:set("Tom", 56, 0.01) ngx.sleep(0.02) local val, flags, stale = dict:get_stale("Tom") print(val)'

В приведенном выше примере данные кэшируются в shared dict только на 0.01 секунды, и через 0.02 секунды после установки данные истекли. В этом случае данные не будут получены через интерфейс get, но устаревшие данные могут быть получены через get_stale. Причина, по которой я использую слово "возможно", заключается в том, что пространство, занимаемое устаревшими данными, имеет определенный шанс быть освобожденным и использованным для других данных. Это алгоритм LRU.

Увидев это, у вас могут возникнуть сомнения: зачем получать устаревшие данные? Не забывайте, что в shared dict мы храним кэшированные данные. Даже если кэшированные данные истекли, это не означает, что исходные данные обязательно обновились.

Например, источник данных хранится в MySQL. После того, как мы получили данные из MySQL, мы установили тайм-аут в пять секунд в shared dict. Затем, когда данные истекут, у нас есть два варианта:

  • Когда данные отсутствуют, снова запросить их в MySQL и поместить результат в кэш.
  • Определить, изменились ли данные в MySQL. Если данные не изменились, прочитать устаревшие данные в кэше, изменить их время истечения и сделать их снова действительными.

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

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

Кэш lru

В lru кэше есть только 5 интерфейсов: new, set, get, delete и flush_all. Только интерфейс get связан с вышеуказанной проблемой. Давайте сначала разберемся, как используется этот интерфейс:

resty -e 'local lrucache = require "resty.lrucache" local cache, err = lrucache.new(200) cache:set("dog", 32, 0.01) ngx.sleep(0.02) local data, stale_data = cache:get("dog") print(stale_data)'

Вы можете видеть, что в lru кэше второе возвращаемое значение интерфейса get — это непосредственно stale_data, вместо того чтобы разделяться на два разных API, get и get_stale, как в shared dict. Такая инкапсуляция интерфейса более удобна для использования устаревших данных.

В реальных проектах мы обычно рекомендуем использовать номера версий для различения разных данных. Таким образом, после изменения данных их номер версии также изменится. Например, измененный индекс в etcd можно использовать как номер версии, чтобы отметить, изменились ли данные. С концепцией номера версии мы можем сделать простую вторичную инкапсуляцию lru кэша. Например, посмотрите на следующий псевдокод, взятый из lrucache

local function (key, version, create_obj_fun, ...) local obj, stale_obj = lru_obj:get(key) -- Если данные не истекли и версия не изменилась, возвращаем кэшированные данные напрямую if obj and obj._cache_ver == version then return obj end -- Если данные истекли, но их все еще можно получить, и версия не изменилась, возвращаем устаревшие данные из кэша if stale_obj and stale_obj._cache_ver == version then lru_obj:set(key, obj, item_ttl) return stale_obj end -- Если устаревшие данные не найдены или номер версии изменился, получаем данные из источника local obj, err = create_obj_fun(...) obj._cache_ver = version lru_obj:set(key, obj, item_ttl) return obj, err end

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

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

Мы знаем, что более традиционный подход — это записать номер версии в ключ. Например, значение ключа будет key_1234. Такая практика очень распространена, но в среде OpenResty это расточительно. Почему?

Приведу пример, и вы поймете. Если номер версии изменяется каждую минуту, то key_1234 станет key_1235 через одну минуту, и за один час будет сгенерировано 60 разных ключей и 60 значений. Это также означает, что Lua GC должен будет утилизировать Lua-объекты за 59 парами ключ-значение. Создание объектов и GC будут потреблять больше ресурсов, если обновления происходят чаще.

Конечно, этих затрат можно избежать, просто переместив номер версии из ключа в значение. Независимо от того, как часто обновляется ключ, существует только два фиксированных Lua-объекта. Видно, что такие техники оптимизации очень изящны. Однако за простыми и изящными техниками стоит глубокое понимание API OpenResty и механизмов кэширования.

Заключение

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

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