Pull to refresh

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

Reading time5 min
Views30K

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

Изображение в шапке взято отсюда.
Tags:
Hubs:
Total votes 60: ↑59 and ↓1+58
Comments3

Articles