Решил пополнить копилку статей на Хабре про такой замечательный ЯП, как 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')
-- и т.п.
Собираем 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';
}
}
}
Доступ к таблице прост:
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 прогрева вообще почти не заметно.
Изображение в шапке взято отсюда.
Автор: AterCattus