Lua*

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

AterCattus 10 марта 2014 в 15:34 23,1k

Решил пополнить копилку статей на Хабре про такой замечательный ЯП, как 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 прогрева вообще почти не заметно.

Изображение в шапке взято отсюда.
Проголосовать:
+58
Сохранить: