Магия взаимодействия между работниками NGINX: одна из важнейших структур данных `shared dict`

API7.ai

October 27, 2022

OpenResty (NGINX + Lua)

Как мы говорили в предыдущей статье, table — это единственная структура данных в Lua. Это соответствует shared dict, которая является самой важной структурой данных, которую вы можете использовать в программировании на OpenResty. Она поддерживает хранение данных, чтение, атомарные счетчики и операции с очередями.

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

Несколько способов обмена данными

При написании кода на Lua для OpenResty вы неизбежно столкнетесь с необходимостью обмена данными между разными Workerами на разных этапах запроса. Вам также может понадобиться обмениваться данными между кодом на Lua и C.

Поэтому, прежде чем мы официально представим API shared dict, давайте сначала разберемся с распространенными способами обмена данными в OpenResty и узнаем, как выбрать более подходящий метод обмена данными в зависимости от текущей ситуации.

Первый способ — переменные в NGINX. Они могут обмениваться данными между модулями NGINX на C. Естественно, они также могут обмениваться данными между модулями на C и модулем lua-nginx-module, предоставляемым OpenResty, как в следующем коде.

location /foo { set $my_var ''; # эта строка необходима для создания $my_var во время конфигурации content_by_lua_block { ngx.var.my_var = 123; ... } }

Однако использование переменных NGINX для обмена данными медленное, так как это связано с хэш-поиском и выделением памяти. Кроме того, этот подход имеет ограничение: он может использоваться только для хранения строк и не поддерживает сложные типы данных Lua.

Второй способ — ngx.ctx, который может обмениваться данными между разными этапами одного запроса. Это обычная Lua table, поэтому она быстрая и может хранить различные объекты Lua. Ее жизненный цикл ограничен запросом; когда запрос завершается, ngx.ctx уничтожается.

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

location /test { rewrite_by_lua_block { ngx.ctx.host = ngx.var.host } access_by_lua_block { if (ngx.ctx.host == 'api7.ai') then ngx.ctx.host = 'test.com' end } content_by_lua_block { ngx.say(ngx.ctx.host) } }

В этом случае, если вы используете curl для доступа.

curl -i 127.0.0.1:8080/test -H 'host:api7.ai'

Тогда он выведет test.com, показывая, что ngx.ctx обменивается данными на разных этапах. Конечно, вы также можете изменить приведенный выше пример, сохраняя более сложные объекты, такие как table, вместо простых строк, чтобы увидеть, соответствует ли это вашим ожиданиям.

Однако здесь важно отметить, что, поскольку жизненный цикл ngx.ctx ограничен запросом, он не кэшируется на уровне модуля. Например, я допустил ошибку, используя это в своем файле foo.lua.

local ngx_ctx = ngx.ctx local function bar() ngx_ctx.host = 'test.com' end

Мы должны вызывать и кэшировать на уровне функции.

local ngx = ngx local function bar() ngx_ctx.host = 'test.com' end

У ngx.ctx есть еще много деталей, которые мы продолжим изучать позже в разделе оптимизации производительности.

Третий подход использует переменные уровня модуля для обмена данными между всеми запросами в рамках одного Worker. В отличие от предыдущих переменных NGINX и ngx.ctx, этот подход немного менее понятен. Но не волнуйтесь, концепция абстрактна, и код идет первым, поэтому давайте рассмотрим пример, чтобы понять переменную уровня модуля.

-- mydata.lua local _M = {} local data = { dog = 3, cat = 4, pig = 5, } function _M.get_age(name) return data[name] end return _M

Конфигурация в nginx.conf следующая.

location /lua { content_by_lua_block { local mydata = require "mydata" ngx.say(mydata.get_age("dog")) } }

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

Естественно, переменная data в модуле mydata является переменной уровня модуля, расположенной на верхнем уровне модуля, то есть в начале модуля, и доступна для всех функций.

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

Мы можем прочувствовать это на следующем упрощенном примере.

-- mydata.lua local _M = {} local data = { dog = 3, cat = 4, pig = 5, } function _M.incr_age(name) data[name] = data[name] + 1 return data[name] end return _M

В модуле мы добавляем функцию incr_age, которая изменяет данные в таблице data.

Затем в вызывающем коде мы добавляем самую важную строку ngx.sleep(5), где sleep — это операция yield.

location /lua { content_by_lua_block { local mydata = require "mydata" ngx.say(mydata. incr_age("dog")) ngx.sleep(5) -- yield API ngx.say(mydata. incr_age("dog")) } }

Без этой строки кода sleep (или других неблокирующих операций ввода-вывода, таких как доступ к Redis и т.д.) не было бы операции yield, не было бы конкуренции, и конечный вывод был бы последовательным.

Но когда мы добавляем эту строку кода, даже если это всего лишь 5 секунд сна, другой запрос, скорее всего, вызовет функцию mydata.incr_age и изменит значение переменной, что приведет к тому, что конечные числа вывода будут несогласованными. В реальном коде логика не так проста, и ошибку гораздо сложнее обнаружить.

Поэтому, если вы не уверены, что между операциями не будет yield, который передаст управление циклу событий NGINX, я рекомендую оставлять ваши переменные уровня модуля доступными только для чтения.

Четвертый и последний подход использует shared dict для обмена данными, которые могут быть общими для нескольких Workerов.

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

lua_shared_dict dogs 10m;

Shared dict также кэширует только данные типа string и не поддерживает сложные типы данных Lua. Это означает, что когда мне нужно хранить сложные типы данных, такие как table, мне придется использовать JSON или другие методы для их сериализации и десериализации, что, естественно, приведет к значительной потере производительности.

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

Shared dict

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

На самом деле, нет. Подумайте об этом: почему в OpenResty существует shared dict? Вспомните, что первые три метода обмена данными ограничены уровнем запроса или уровнем отдельного Worker. Поэтому в текущей реализации OpenResty только shared dict может обеспечить обмен данными между Workerами, что позволяет им взаимодействовать, и это его ценность.

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

Возвращаясь к shared dict, он предоставляет более 20 Lua API, все из которых являются атомарными, поэтому вам не нужно беспокоиться о конкуренции в случае нескольких Workerов и высокой нагрузки.

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

Далее давайте продолжим рассматривать API shared dict, которые можно разделить на три категории: чтение/запись словаря, операции с очередью и управление.

Чтение/запись словаря

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

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

Помимо set, OpenResty также предоставляет четыре метода записи: safe_set, add, safe_add и replace. Значение префикса safe здесь заключается в том, что если память заполнена, вместо удаления старых данных по LRU, запись завершится неудачей и вернет ошибку no memory.

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

value, flags, stale = ngx.shared.DICT:get_stale(key)

Вы также можете вызвать метод delete, чтобы удалить указанный ключ, что эквивалентно set(key, nil).

Операции с очередью

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

typedef struct { ngx_queue_t queue; uint32_t value_len; uint8_t value_type; u_char data[1]; } ngx_http_lua_shdict_list_node_t;

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

Однако в документации нет соответствующих примеров кода для следующих пяти API для работы с очередями, поэтому я кратко представлю их здесь.

  • lpush``/``rpush означает добавление элементов на обоих концах очереди.
  • lpop``/``rpop, который извлекает элементы с обоих концов очереди.
  • llen, который возвращает количество элементов в очереди.

Не будем забывать еще один полезный инструмент, который мы обсуждали в прошлой статье: тестовые примеры. Обычно мы можем найти соответствующий код в тестовом примере, если его нет в документации. Тесты, связанные с очередью, находятся в файле 145-shdict-list.t.

=== TEST 1: lpush & lpop --- http_config lua_shared_dict dogs 1m; --- config location = /test { content_by_lua_block { local dogs = ngx.shared.dogs local len, err = dogs:lpush("foo", "bar") if len then ngx.say("push success") else ngx.say("push err: ", err) end local val, err = dogs:llen("foo") ngx.say(val, " ", err) local val, err = dogs:lpop("foo") ngx.say(val, " ", err) local val, err = dogs:llen("foo") ngx.say(val, " ", err) local val, err = dogs:lpop("foo") ngx.say(val, " ", err) } } --- request GET /test --- response_body push success 1 nil bar nil 0 nil nil nil --- no_error_log [error]

Управление

Финальные API управления также являются более поздним дополнением и являются популярным запросом в сообществе. Один из самых типичных примеров — использование общей памяти. Например, если пользователь запрашивает 100M пространства в качестве shared dict, достаточно ли этого 100M? Сколько ключей хранится в нем, и какие это ключи? Это все реальные вопросы.

Для такого рода проблем официальные представители OpenResty рекомендуют пользователям использовать flame graphs для их решения, то есть неинвазивным способом, сохраняя код эффективным и чистым, вместо предоставления инвазивного API для возврата результатов напрямую.

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

Первый — get_keys(max_count?), который по умолчанию возвращает только первые 1024 ключа; если вы установите max_count в 0, он вернет все ключи. Затем идут capacity и free_space, оба из которых являются частью репозитория lua-resty-core, поэтому вам нужно require их перед использованием.

require "resty.core.shdict" local cats = ngx.shared.cats local capacity_bytes = cats:capacity() local free_page_bytes = cats:free_space()

Они возвращают размер общей памяти (размер, указанный в lua_shared_dict) и количество свободных байтов страниц. Поскольку shared dict выделяется постранично, даже если free_space возвращает 0, в выделенных страницах может быть место. Следовательно, его возвращаемое значение не отражает, сколько общей памяти занято.

Итог

На практике мы часто используем многоуровневое кэширование, и официальный проект OpenResty также имеет пакет кэширования. Можете ли вы найти, какие это проекты? Или знаете ли вы другие библиотеки lua-resty, которые инкапсулируют кэширование?

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