LUA в nginx: горячий кеш в памяти


Решил пополнить копилку статей на Хабре про такой замечательный ЯП, как lua, парой примеров его использования под капотом nginx. Разбил на два независимых поста, второй тут.

В этом посте nginx используется как «горячий кеш» неких постоянно пополняемых данных, запрашиваемых клиентами по интервалу с опциональным группированием (некий аналог BETWEEN и GROUP BY/AGGREGATE из SQL). Подгрузка данных в кеш осуществляется самим же lua+nginx из Redis. Исходные данные в Redis складываются ежесекундно, а клиенты хотят их от сих до сих (интервал в секундах, минутах, часах…) с агрегацией по N (1<=N<=3600) секунд, отсортированные по дате и в json формате.
С хорошим hitrate на имеющейся машине получается обеспечить 110-130к «хотелок» в секунду, правда с плохим — только 20-30к. Что, в общем-то, тоже приемлемо для нас на одной инстанции nginx.

Из некоего источника ежесекундно приходят данные, которые складываются в Redis ZSET. Важным моментом является привязка данных именно ко времени — выборка будет идти по временным интервалам. Пришел один клиент — «дай мне от сих до сих посекундно», пришел другой — «а мне вот этот интервальчик, но давай с часовой агрегацией», третьему понадобилась одна последняя секунда, четвертому за сутки с аггрегацией по 27 секунд, ну и т.д… Стучаться за данными непосредственно в Redis нереально. Заранее кешировать подготовленные данные весьма проблематично, т.к. требуемые интервалы и шаг агрегации в общем случае у каждого клиента/запроса свой и могут произвольно варьироваться. Сервер должен быть готов быстро ответить на любой разумный запрос.

Первоначально была идея выполнять агрегацию на стороне Redis, вызывая через EVAL redis-lua код из nginx-lua кода. Данная «технология We need to go deeper» не подошла из-за однопоточной природы самого Redis: по быстрому отдать «сырые данные» выходит значительно быстрее, чем сгруппировать и выпихнуть готовый результат.

Данные в Redis хранятся поэлементно уже в json формате вида:

ZADD ns:zs:key 1386701764 "{\"data100500\":\"hello habr\",\"dt\":\"10.12.2013 10:05:00\",\"smth\":\"else\"}"

Ключем является timestamp, в dt строковый эквивалент по версии «наполняльщика».
Соответственно, выборка диапазона:

ZREVRANGEBYSCORE ns:zs:data:sec 1386701764 1386700653 WITHSCORES

И на lua через resty Redis:

local redis = require 'redis' local R, err = redis:new() R:connect('12.34.56.78', 6379) R:zrevrangebyscore('ns:zs:data:sec', to, from, 'WITHSCORES') -- и т.п. 

Про пул коннектов в resty Redis

Важно, что Resty использует настраиваемый пул коннектов к Redis и R:connect() в общем случае не создает новое соединение. Возврат соединения после использования НЕ выполняется автоматически, его нужно выполнить вызовом R:set_keepalive(), возвращающим соединение обратно в пул (после возврата использовать его без повторного R:connect() уже нельзя). Счетчик доставаний текущего коннекта из пула можно узнать через R:get_reused_times(). Если >0 — значит это уже ранее созданное и настроенное соединение. В таком случае не нужно повторно слать AUTH и т.п.

Собираем nginx (lua-nginx-module + lua-resty-redis), бегло настраиваем:

 http {     lua_package_path '/path/to/lua/?.lua;;';     init_by_lua_file '/path/to/lua/init.lua';     lua_shared_dict ourmegacache 1024m;      server {         location = /data.js {             content_by_lua_file '/path/to/lua/get_data.lua';         }     } } 

Про работу с shared dict

В конфиге указывается shared dict «ourmegacache», который будет доступен в lua как таблица (словарь, хеш). Данная таблица одна для всех worker процессов nginx и операции на ней атомарны для нас.
Доступ к таблице прост:

local cache = ngx.shared.ourmegacache cache:get('foo') cache:set('bar', 'spam', 3600) -- и т.п. см. документацию 

При исчерпании свободного места в памяти, начинается чистка по методу LRU, что в нашем случае подходит. Кому не подходит — смотрите в сторону методов safe_add, flush_expired, и т.п. Так же стоит учитывать еще, вроде как, не решенный официально баг в nginx, связанный с хранением больших элементов в данном shared dict.

Для разнообразия границы запрашиваемого интервала и шаг агрегации будем получать из GET параметров запроса from, to и step. С данным соглашением примерный формат запроса к сервису будет таким:

/data.js?step=300&from=1386700653&to=1386701764

local args = ngx.req.get_uri_args() local from = tonumber(args.from) or 0 ... 

Итак, у нас есть поэлементные json записи, хранящиеся в Redis, которые мы можем оттуда получать. Как их лучше кешировать и отдавать клиентам?

  • Можно хранить посекундные записи в таблице по отдельности. Однако, как показала практика, выполнение уже нескольких десятков запросов к таблице крайне негативно сказывается на производительности. А если придет запрос на сутки, то ответа с небольшим таймаутом можно и не дождаться;
  • Записи можно хранить блоками, объединяя через некий общий разделитель или сериализуя их хоть в тот же json. А при запросе нужно разбербанивать по разделителю или десериализовывать. Так себе вариант;
  • Хранить данные иерархически, с частичными повторами на разных уровнях аггрегации. Используются блоки кеша разного размера: 1 секунда (одиночная запись), 10 секунд, 1 минута, 10 минут, час. В каждом блоке содержатся данные всех его секунд. Самое важное, что содержимое блока никак не меняется и не отдается кусками: или целиком как есть или никак.

Выбран последний вариант, потребляющий больше памяти, но значительно уменьшающий число обращений к таблице. Используются блоки кеша разного размера: 1 секунда (одиночная запись), 10 секунд, 1 минута, 10 минут, час. В каждом блоке содержатся данные всех его секунд. Каждый блок выровнен на границу своего интервала, например первый элемент 10 секундного интервала всегда имеет timestamp, имеющий десятичный остаток 9 (сортировка по убыванию, как хотят клиенты), а часовой блок содержит элементы 59:59, 59:58,… 00:00. При объединении элементов, они сразу склеиваются с разделителем — запятой, что позволяет отдавать данные блоки клиенту одним действием: ‘[‘, block, ‘]’, а также быстро объединять их в более крупные куски.

Для покрытия запрошенного интервала выполняется разбиение на максимально возможные блоки с достройкой по краям более мелкими блоками. Т.к. у нас есть единичные блоки, то всегда возможно полное покрытие требуемого интервала. Для запроса интервала 02:29:58… 03:11:02 получаем раскладку по кешам:

 1сек  - 03:11:02 1сек  - 03:11:01 1сек  - 03:11:00 1мин  - 03:10:59 .. 03:10:00 10мин - 03:09:59 .. 03:00:00 30мин - 02:59:59 .. 02:30:00 1сек  - 02:29:59 1сек  - 02:29:58 

Это лишь пример. Реальные вычисления выполняют на timestamp’ах.
Выходит, что нужны 8 запросов к локальному кешу. Или к Redis, если локально их уже/еще нет. А чтобы не ломиться за одинаковыми данными из разных worker’ов/connect’ов, можно использовать атомарность операций с shared dict для реализации блокировок (где key — строковый ключ кеша, содержащий в себе сведения о интервале и шаге агрегации):

local chunk local lock_ttl = 0.5 -- пытаемся получить блокировку не дольше, чем полсекунды local key_lock = key .. ':lock'  local try_until = ngx.now() + lock_ttl local locked while true do     locked = cache:add(key_lock, 1, lock_ttl)     chunk = cache:get(key)     if locked or chunk or (try_until < ngx.now()) then         break     end     ngx.sleep(0.01) -- ожидание, не блокирующее nginx evloop end  if locked then     -- удалось получить блокировку. делаем, что собирались elseif chunk then     -- лок получить не удалось, но в кеш положили нужные нам данные end  if locked then     cache:delete(key_lock) end 

Имея нужную раскладку по кешам, возможность выбора нужного диапазона из Redis, и логику агрегации (тут очень специфично, не привожу примера), получаем отличный кеширующий сервер, который, после прогрева, стучится в Redis только раз в секунду за новым элементом + за старыми, если они еще не выбирались или были выброшены по LRU. И не забываем про ограниченный пул коннектов в Redis.
В нашем случае прогрев выглядит как кратковременный скачок входящего трафика порядка 100-110Мб/сек на несколько секунд. По cpu на машине с nginx прогрева вообще почти не заметно.

Изображение в шапке взято отсюда.

ссылка на оригинал статьи http://habrahabr.ru/post/215237/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *