- PVSM.RU - https://www.pvsm.ru -

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

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

В этом посте 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 [2] + lua-resty-redis [3]), бегло настраиваем:


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 [4] «ourmegacache», который будет доступен в lua как таблица (словарь, хеш). Данная таблица одна для всех worker процессов nginx и операции на ней атомарны для нас.
Доступ к таблице прост:

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

При исчерпании свободного места в памяти, начинается чистка по методу LRU [5], что в нашем случае подходит. Кому не подходит — смотрите в сторону методов safe_add, flush_expired, и т.п. Так же стоит учитывать еще, вроде как, не решенный официально баг в nginx [6], связанный с хранением больших элементов в данном 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 прогрева вообще почти не заметно.

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

Автор: AterCattus

Источник [8]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/nginx/56726

Ссылки в тексте:

[1] тут: http://habrahabr.ru/post/215235/

[2] lua-nginx-module: https://github.com/chaoslawful/lua-nginx-module

[3] lua-resty-redis: https://github.com/agentzh/lua-resty-redis

[4] shared dict: https://github.com/chaoslawful/lua-nginx-module#ngxshareddict

[5] LRU: http://ru.wikipedia.org/wiki/%D0%90%D0%BB%D0%B3%D0%BE%D1%80%D0%B8%D1%82%D0%BC%D1%8B_%D0%BA%D1%8D%D1%88%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F#Least_Recently_Used_.28.D0.92.D1.8B.D1.82.D0.B5.D1.81.D0.BD.D0.B5.D0.BD.D0.B8.D0.B5_.D0.B4.D0.B0.D0.B2.D0.BD.D0.BE_.D0.BD.D0.B5.D0.B8.D1.81.D0.BF.D0.BE.D0.BB.D1.8C.D0.B7.D1.83.D0.B5.D0.BC.D1.8B.D1.85.29

[6] баг в nginx: http://www.mail-archive.com/nginx-devel@nginx.org/msg00661.html

[7] отсюда: http://blog.cloudflare.com/pushing-nginx-to-its-limit-with-lua

[8] Источник: http://habrahabr.ru/post/215237/