Ключи к высокой производительности: `shared dict` и `lru` кэш
API7.ai
December 22, 2022
В предыдущей статье я представил методы оптимизации 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 | нет | 4 | n экземпляров данных (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? Поделитесь с нами в комментариях, и не забудьте поделиться этой статьей, чтобы мы могли учиться и развиваться вместе.