`lua-resty-*` Инкапсуляция освобождает разработчиков от многоуровневого кэширования
API7.ai
December 30, 2022
В предыдущих двух статьях мы изучили кэширование в OpenResty и проблему кэш-шторма, которые относятся к базовым аспектам. В реальной разработке проектов разработчики предпочитают использовать готовые библиотеки, где все детали обработаны и скрыты, что позволяет сразу приступить к написанию бизнес-кода.
Это преимущество разделения труда: разработчики базовых компонентов сосредотачиваются на гибкой архитектуре, высокой производительности и стабильности кода, не заботясь о верхнеуровневой бизнес-логике; в то время как инженеры приложений больше озабочены реализацией бизнес-логики и быстрой итерацией, надеясь не отвлекаться на различные технические детали нижнего уровня. Пробел между ними может быть заполнен обёрточными библиотеками.
Кэширование в OpenResty сталкивается с той же проблемой. shared dict и lru caches достаточно стабильны и эффективны, но в них слишком много деталей, которые нужно учитывать. "Последняя миля" для инженеров разработки приложений может быть сложной без полезной инкапсуляции. Здесь важную роль играет сообщество. Активное сообщество будет активно находить пробелы и быстро их заполнять.
lua-resty-memcached-shdict
Вернёмся к инкапсуляции кэша. lua-resty-memcached-shdict — это официальный проект OpenResty, который использует shared dict для создания слоя инкапсуляции для memcached, обрабатывая такие детали, как кэш-шторм и устаревшие данные. Если ваши кэшированные данные хранятся в memcached на бэкенде, то вы можете попробовать использовать эту библиотеку.
Это официальная библиотека, разработанная OpenResty, но она не включена в пакет OpenResty по умолчанию. Если вы хотите протестировать её локально, вам нужно сначала загрузить её исходный код в локальный путь поиска OpenResty.
Эта библиотека инкапсуляции использует тот же подход, который мы упоминали в предыдущей статье. Она использует lua-resty-lock для обеспечения взаимного исключения. В случае сбоя кэша только один запрос идёт в memcached для получения данных, чтобы избежать кэш-шторма. Устаревшие данные возвращаются конечной точке, если последние данные не были получены.
Однако эта библиотека lua-resty, хотя и является официальным проектом OpenResty, не идеальна:
- Во-первых, у неё нет тестовых случаев, что означает, что качество кода не может быть гарантировано.
- Во-вторых, она предоставляет слишком много параметров интерфейса: 11 обязательных и 7 необязательных.
local memc_fetch, memc_store = shdict_memc.gen_memc_methods{ tag = "my memcached server tag", debug_logger = dlog, warn_logger = warn, error_logger = error_log, locks_shdict_name = "some_lua_shared_dict_name", shdict_set = meta_shdict_set, shdict_get = meta_shdict_get, disable_shdict = false, -- optional, default false memc_host = "127.0.0.1", memc_port = 11211, memc_timeout = 200, -- in ms memc_conn_pool_size = 5, memc_fetch_retries = 2, -- optional, default 1 memc_fetch_retry_delay = 100, -- in ms, optional, default to 100 (ms) memc_conn_max_idle_time = 10 * 1000, -- in ms, for in-pool connections,optional, default to nil memc_store_retries = 2, -- optional, default to 1 memc_store_retry_delay = 100, -- in ms, optional, default to 100 (ms) store_ttl = 1, -- in seconds, optional, default to 0 (i.e., never expires) }
Большинство параметров можно упростить, создав новый обработчик memcached. Текущий способ инкапсуляции всех параметров, выбрасывая их на пользователя, не является дружественным, поэтому я приветствую заинтересованных разработчиков, которые могут внести PR для оптимизации этого.
Также в документации этой библиотеки упоминаются дальнейшие оптимизации:
- Использование
lua-resty-lrucacheдля увеличения кэша на уровнеWorker, а не только на уровне сервера сshared dict. - Использование
ngx.timerдля асинхронных операций обновления кэша.
Первое направление — это очень хорошее предложение, так как производительность кэша внутри worker'а выше; второе предложение нужно рассматривать в зависимости от вашего реального сценария. Однако я не рекомендую второе, не только из-за ограничения на количество таймеров, но и потому что, если логика обновления здесь сломается, кэш больше никогда не обновится, что имеет большое влияние.
lua-resty-mlcache
Теперь давайте представим другую библиотеку для кэширования, часто используемую в OpenResty: lua-resty-mlcache, которая использует shared dict и lua-resty-lrucache для реализации многоуровневого механизма кэширования. Давайте рассмотрим, как эта библиотека используется, на примере двух фрагментов кода.
local mlcache = require "resty.mlcache" local cache, err = mlcache.new("cache_name", "cache_dict", { lru_size = 500, -- размер L1 (Lua VM) кэша ttl = 3600, -- время жизни кэша 1 час neg_ttl = 30, -- время жизни для промахов 30 секунд }) if not cache then error("failed to create mlcache: " .. err) end
Давайте рассмотрим первый фрагмент кода. В начале этого кода подключается библиотека mlcache и задаются параметры для инициализации. Обычно мы помещаем этот код в фазу init и выполняем его только один раз.
Помимо двух обязательных параметров — имени кэша и имени словаря — третий параметр представляет собой словарь с 12 опциями, которые являются необязательными и используют значения по умолчанию, если не указаны. Это гораздо более элегантно, чем в lua-resty-memcached-shdict. Если бы мы сами проектировали интерфейс, лучше было бы принять подход mlcache — сохранить интерфейс как можно более простым, сохраняя при этом достаточную гибкость.
Вот второй фрагмент кода, который представляет собой логику обработки запроса.
local function fetch_user(id) return db:query_user(id) end local id = 123 local user , err = cache:get(id , nil , fetch_user , id) if err then ngx.log(ngx.ERR , "failed to fetch user: ", err) return end if user then print(user.id) -- 123 end
Как видите, многоуровневый кэш скрыт, поэтому вам нужно использовать объект mlcache для получения кэша и установки функции обратного вызова при истечении срока действия кэша. Сложная логика за этим может быть полностью скрыта.
Вам может быть интересно, как эта библиотека реализована внутри. Давайте рассмотрим архитектуру и реализацию этой библиотеки. На следующем изображении представлен слайд из доклада Thibault Charbonnier, автора mlcache, на OpenResty Con 2018.

Как видно из диаграммы, mlcache разделяет данные на три уровня: L1, L2 и L3.
Кэш L1 — это lua-resty-lrucache, где каждый Worker имеет свою копию, и с N Workerами есть N копий данных, поэтому существует избыточность данных. Поскольку операции с lrucache внутри одного Worker не вызывают блокировок, он имеет более высокую производительность и подходит в качестве кэша первого уровня.
Кэш L2 — это shared dict. Все Workerы используют одну копию кэшированных данных и будут запрашивать кэш L2, если кэш L1 не сработал. ngx.shared.DICT предоставляет API, который использует спинлоки для обеспечения атомарности операций, поэтому нам не нужно беспокоиться о состояниях гонки здесь.
L3 — это случай, когда кэш L2 также не сработал, и необходимо выполнить функцию обратного вызова для запроса данных из источника, например, внешней базы данных, а затем кэшировать их в L2. Здесь, чтобы избежать кэш-шторма, используется lua-resty-lock, чтобы гарантировать, что только один Worker пойдёт в источник данных для получения данных.
С точки зрения запроса:
- Сначала он запрашивает кэш
L1внутриWorkerи возвращает данные, если кэшL1сработал. - Если кэш
L1не сработал или истёк, он запрашивает кэшL2междуWorkerами. Если кэшL2сработал, он возвращает данные и кэширует результат вL1. - Если кэш
L2также не сработал или истёк, вызывается функция обратного вызова для получения данных из источника и записи их в кэшL2, что является функцией уровня данныхL3.
Из этого процесса видно, что обновление кэша пассивно запускается запросами конечной точки. Даже если запрос не смог получить кэш, последующие запросы всё равно могут запустить логику обновления, чтобы максимизировать безопасность кэша.
Однако, хотя mlcache реализован почти идеально, остаётся одна проблема — сериализация и десериализация данных. Это не проблема mlcache, а разница между lrucache и shared dict, которую мы неоднократно упоминали. В lrucache мы можем хранить различные типы данных Lua, включая table; но в shared dict мы можем хранить только строки.
Кэш L1, lrucache, — это уровень данных, с которым взаимодействуют пользователи, и мы хотим кэшировать в нём все виды данных, включая string, table, cdata и так далее. Проблема в том, что L2 может хранить только строки, и когда данные поднимаются из L2 в L1, нам нужно выполнить преобразование из строк в типы данных, которые мы можем передать пользователю.
К счастью, mlcache учитывает эту ситуацию и предоставляет необязательные функции l1_serializer в интерфейсах new и get, специально предназначенные для обработки данных при переходе из L2 в L1. Мы можем увидеть следующий пример кода, который я извлёк из своего набора тестов.
local mlcache = require "resty.mlcache" local cache, err = mlcache.new("my_mlcache", "cache_shm", { l1_serializer = function(i) return i + 2 end, }) local function callback() return 123456 end local data = assert(cache:get("number", nil, callback)) assert(data == 123458)
Давайте быстро объясню. В этом случае функция обратного вызова возвращает число 123456; в new функция l1_serializer, которую мы задали, добавляет 2 к входящему числу перед установкой кэша L1, что превращает его в 123458. С такой функцией сериализации данные могут быть более гибкими при преобразовании между L1 и L2.
Заключение
С многоуровневым кэшированием можно максимизировать производительность сервера, и многие детали скрыты между уровнями. На этом этапе стабильная и эффективная библиотека-обёртка экономит нам много усилий. Я также надеюсь, что две библиотеки, представленные сегодня, помогут вам лучше понять кэширование.
Наконец, подумайте над этим вопросом: необходим ли уровень кэша с общим словарём? Можно ли использовать только lrucache? Не стесняйтесь оставлять комментарии и делиться своим мнением со мной, а также делитесь этой статьёй с другими, чтобы общаться и развиваться вместе.