«Хранимые процедуры» в Redis

  • Tutorial
image

Многие знают про возможность хранить процедуры в sql базах данных, про это написано немало пухлых руководств и статей. Однако мало кто знает, что схожие возможности имеются и в Redis, начиная с версии 2.6.0. Но так как Redis не является реляционной БД, то и принципы описания хранимых процедур достаточно сильно отличаются. Хранимые процедуры в Redis — практически полноценные Lua скрипты (на момент написания статьи в качестве интерпретатора используется Lua 5.1).

Дальнейшее повествование предполагает базовое знакомство с API Redis, а также, что процесс redis-server запущен на localhost:6379. Если вы новичок в Redis, то вам стоит перед прочтением следующего материала ознакомиться с краткой информацией о том, что такое Redis. А также пройти, хотя бы частично данное интерактивное руководство.

Hello world!


Используя redis-cli вернём из базы строку «Hello world!»:
redis-cli EVAL 'return "Hello world!"' 0

Результат:
"Hello world!"

Давайте разберёмся, что только что произошло:
  1. Вызов встроенной в Redis команды EVAL с двумя аргументами. Первый
    return "Hello world!"
    — тело функции Lua.
    0
    — количество ключей Redis, которое будет передано в качестве параметров нашей функции. Пока мы не передаём ключи redis в качестве параметров, т.е. указываем 0.
  2. Интерпретация текста программы на сервере и возврат Lua-string значения
  3. Преобразование Lua-string в redis bulk reply
  4. Получение результата в redis-cli
  5. redis-cli выводит bulk reply на stdout


Хранимые процедуры в Redis это обычные функции Lua, а следовательно и принцип получения и возврата аргументов аналогичен.
Замечание: Lua поддерживает mul-return (возврат более чем одного результата из функции). Но чтобы возвратить несколько значений из redis, нужно использовать multi bulk reply, а из Lua в него отображаются таблицы, пример ниже не будет работать так, как вы возможно ожидаете:
redis-cli EVAL 'return "Hello world!", "test"' 0

"Hello world!"

Результат усекается до одного возвращаемого значения (первого).

Hello %username%!


Двигаемся дальше. Так как функции без аргументов особого интереса не представляют, добавим обработку аргументов в нашу функцию.
Согласно документации функция, выполняемая через EVAL, может принимать произвольное количество аргументов через Lua таблицы KEYS и ARGV. Воспользуемся этим, чтобы поприветствовать %username%, если строка, содержащая его имя, передана в качестве аргумента, а иначе поприветствуем Habr.

Вызываем без аргументов, массив-таблица ARGV в Lua пустая, т.е и ARGV[1] вернёт nil
redis-cli EVAL 'return "Hello " .. (ARGV[1] or "Habr") .. "!"' 0

Результат:
"Hello Habr!"

А теперь в качестве параметра передадим строку «Иннокентий»:
redis-cli EVAL 'return "Hello " .. (ARGV[1] or "Habr") .. "!"' 0 'Иннокентий'

Результат:
"Hello \xd0\x98\xd0\xbd\xd0\xbd\xd0\xbe\xd0\xba\xd0\xb5\xd0\xbd\xd1\x82\xd0\xb8\xd0\xb9!"

Замечание: Redis хранит строки в utf8 и для того, чтобы избежать каких-либо проблем на стороне клиента в redis-cli символы, не входящие в ascii, выводятся в виде escape последовательностей. Чтобы увидеть читаемую строку в bash можно сделать так:
echo -e $(redis-cli EVAL 'return "Hello " .. ARGV[1] .. "!"' 0 'Иннокентий')


Доступ к API Redis из скриптов


В каждый Lua скрипт интерпретатор загружает эти библиотеки:
string, math, table, debug, cjson, cmsgpack

Первые 4 — стандартные для Lua. 2 последние — для работы с json и msgpack соответственно.

Для того чтобы взаимодействовать с данными в нашем хранилище в Lua экспортирован модуль 'redis'. Воспользовавшись функцией call в данном модуле, мы можем выполнять команды в формате, соответствующем командам из redis-cli.

Рассмотрим использование redis.call на примере скрипта, который проверяет, существует ли пользователь в нашей базе, а если существует, то проверяет соответствие пары логин — пароль.

Создадим в нашей базе тестовый набор данных, содержащий пары логин — пароль.
redis-cli HMSET 'users' 'ivan' '12345' 'maria' 'qwerty' 'oleg' '1970-01-01'

OK


Убедимся, что всё действительно ОК:
redis-cli HGETALL 'users'

1) "ivan"
2) "12345"
3) "maria"
4) "qwerty"
5) "oleg"
6) "1970-01-01"


На вход скрипту будем подавать один аргумент, json строку в формате:
{
"login":"userlogin",
"password":"userpassword"
}


Скрипт, должен возвращать 1, если пользователь существует и пароль в json совпал с паролем в базе, иначе 0. Если входной формат ошибочен, например не был передан аргумент скрипту (ARGV[1] == nil) или в json отсутствует одно из требуемых полей, возвратим читаемую строку, содержащую информацию об ошибке.

Для разбора и упаковки json redis экспортирует в Lua модуль cjson. В нашем скрипте мы воспользуемся функцией decode из данного модуля. В качестве параметра функция принимает Lua-string, в которой содержится json, а возвращаемым значением является Lua-таблица, строковыми ключами которой являются json-поля.

Создадим файл login.lua со следующим содержимым.
Код скрипта login.lua
local jsonPayload = ARGV[1]

if not jsonPayload then
    return 'No such json data'
end

local user = cjson.decode(jsonPayload)

if not user.login then
    return 'User login is not set'
end

if not user.password then
    return 'User password is not set'
end

-- вызов redis API из Lua аналогичен стандартному API redis.
local expectedPassword = redis.call('HGET', 'users', user.login)
if not expectedPassword then
    return 0
end

if expectedPassword ~= user.password then
    return 0
end

return 1



Примеры использования:
  1. Пароли совпадают
    redis-cli EVAL "$(cat login.lua)" 0 '{"login":"maria","password":"qwerty"}'
    

    (integer) 1
    

  2. Пароли не совпадают
    redis-cli EVAL "$(cat login.lua)" 0 '{"login":"maria","password":"12345"}'
    

    (integer) 0
    

  3. В json отсутствует поле с паролем
    redis-cli EVAL "$(cat login.lua)" 0 '{"login":"maria","pwd":"12345"}'
    

    "User password is not set"
    

  4. Не передан аргумент, содержащий json
    redis-cli EVAL "$(cat login.lua)" 0
    

    "No such json data"
    



Замечание: Всё ключи в Redis, а также работа с ними через SET и GET, имеют строковое представление. В Redis нет типа integer, и float тоже нет. Важно это понимать. В следующем примере мы возвращаем значение ключа test как строку:
redis-cli SET test 5

OK

Узнаем тип хранимого значения:
redis-cli TYPE test

string

Вернём, но уже через скрипт:
redis-cli EVAL "return redis.call('GET', 'test')" 0

"5"


При этом нам никто не запрещает вернуть integer (в качестве integer bulk reply):
redis-cli EVAL "return tonumber(redis.call('GET', 'test'))" 0

(integer) 5


Будьте осторожны с передачей Lua-number в качестве параметра функции redis.call:
redis-cli EVAL "return redis.call('SET', 'test', 5.6)" 0

OK

Значение усекается до меньшего целого
redis-cli EVAL "return tonumber(redis.call('GET', 'test'))" 0

(integer) 5

Но что же там действительно внутри:
redis-cli GET test

"5.5999999999999996"

Как «правильно»:
redis-cli EVAL "return redis.call('SET', 'test', tostring(5.6))" 0

OK

redis-cli GET test

"5.6"


По всей видимости преобразование Lua-number идёт не в интерпретаторе Lua, а в нативной части Redis, написанной на Си.

На сегодня всё.

Смотрите также


redis.io/commands/eval
www.redisgreen.net/blog/intro-to-lua-for-redis-programmers
redislabs.com/blog/5-methods-for-tracing-and-debugging-redis-lua-scripts
Пожалуйста, проголосуйте за тему, которую стоит рассмотреть в следующей статье:
  • 68%Подробнее рассказать про работу с Lua API. Работа со multi-bulk reply48
  • 32%Рассмотреть использование redis в связке с Go, используя radix.v223

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Поделиться публикацией
Похожие публикации
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 44
  • 0
    Таки они хронятся в redis, или только исполняются?
    • +2
      команда EVAL именно интерпретирует строку, т.е. фактически делает то же самое, что и eval во многих скриптовых языках. Однако вы можете сохранять скрипты, и это предполагается делать, если вы используете их в продакшене. Для этого вы должны вызвать SCRIPT LOAD «текст скрипта», он вернёт вам SHA1 строку-хеш, по этому хешу вы потом можете вызвать скрипт, используя EVALSHA %SCRIPT_SHA%, также, как делаете это с EVAL.
      • 0
        То есть «заменить» скрипт нельзя?
        • +1
          Заменить с тем же SHA нельзя, однако можно сбросить скриптовый кеш командой SCRIPT FLUSH, т.е. удалить все скрипты и загрузить заново. Скриптовый кеш живёт до тех пор, пока redis-server не будет перезапущен. Однако это не является большой проблемой, если вы конечно не загружаете скрипты в огромном количестве.
          • +1
            Ну то есть сохранить интерфейс server-side нельзя, это только экономия на размерах вызовов.
            • +2
              Мало того — в случае рестарта сервака их нужно загружать повторно. Напоролся на проде на это :)
        • +1
          p.s.: вот если бы EVALSHA работало без SCRIPT LOAD!!!
          • +2
            Не понимаю, как это возможно. Ведь сначала нужно загрузить скрипт, а уже потом исполнять.
            • –1
              Ну как, SHA1 обращаем и выполняем :)
            • НЛО прилетело и опубликовало эту надпись здесь
        • +2
          Пару месяцев назад пришлось воспользоваться скриптами, чтобы быстро почистить базу от неактуальных данных.

          Так вот в lua-скриптах для Redis есть одна недокументированная особенность: там разделено получение данных и их удаление. То есть нельзя в одном скрипте получить список ключей и тут же вызвать их удаление. Redis — это просто не дает сделать. Пришлось писать два скрипта — один формирует список ключей и потом с помощью xargs передаем его второму скрипту для удаления.

          В документации указана основная причина использовать скрипты — снижение накладных расходов на пересылку данных и как следствие скорость работы по сравнению с программами обращающимися к редис на прямую.

          Для сравнения мной была написанная программа на Go для поиска ключей и удаления. И она выполнялась быстрее lua-скриптов.

          Так что мой опыт показывает, что lua-скрипты в Redis есть больше для галочки. Они не оптимизированы для быстрой работы.
          • НЛО прилетело и опубликовало эту надпись здесь
            • +1
              Я именно что использовал SCAN чтобы найти какие ключи удалить. И после получения списка ключей не мог вызвать HDEL для удаления в этом же скрипте.
              • НЛО прилетело и опубликовало эту надпись здесь
                • +1
                  Нет. У меня была только одна база без слейвов. В ней было 700 млн ключей(примерно 60 Гб), удалил я в итоге 550-570 млн ключей. Версия редиса 2.6.16.
                  • НЛО прилетело и опубликовало эту надпись здесь
                    • 0
                      Вы целое мини-исследование провели :-)
                      • НЛО прилетело и опубликовало эту надпись здесь
          • НЛО прилетело и опубликовало эту надпись здесь
            • 0
              Что касается утечки, то каюсь, не подумал об этом написать, т.к. думал, что это очевидно, ведь это применимо и ко многим sql бд. В частности, если конструировать запросы не через prepare + bind, а в лоб формируя строку запроса, то можно запросто израсходовать лимит дескрипторов в том же oracle, а в дополнение и получить дыру в безопасности, в лице возможности формирования входных данных для sql-инъекции.
              • 0
                Про тип я говорил, как про внутреннее представление данных. То что у нас есть возможность работать со строками как с integer я не отрицаю.
                • НЛО прилетело и опубликовало эту надпись здесь
              • 0
                P.S. дополню свой комментарий, так как сразу не заметил голосование после статьи. Для программы на Go я тоже использовал radix.v2.

                Но где-то недавно была информация что библиотека Redigo быстрее чем radix.v2. Так что может быть можно написать еще более быстрый скрипт для чистки базы, чем получилось у меня.
                • 0
                  Я использую а продакшине Redigo. Доволен очень. Удобный api даже смог красиво прикрутить newrelic на запросы в редис. Одно плохо есть проблема писать struct которые не только string а потом их читать надо их переводит в json а потом обратно. Немножко накдладно было. заменил json на messagepack с статической серилизацией получилось очень быстрая вещь
                  • 0
                    Я не в рамках этой программы сначала использовал для подключения к редис gopkg.in/redis.v3, но этот оказалось полное извращение. (Отдельная функция для каждой команды редис. Причем каждая возвращает свой набор параметров.) Уже потом увидел более-менее сносный radix.v2 По поводу Redigo — его еще не смотрел, только читал, что он быстрее.
                    • 0
                      redigo все проще
                      data, err := c.Do(command, args...)
                      

                  • 0
                    Насколько мне известно, у скриптов в redis одна основная задача, сократить все обращения к базе до одного round-trip при этом обеспечить атомарность. Действительно странно, что у вас получалось достичь большей скорости на go. Ваша программа на go последовательно вызывала комманды или же вы делали через транзакции multi
                • +2
                  Важно ещё помнить про то, что в Lua у вас есть доступ не ко всем функциям редиса. К примеру нельзя использовать SRANDMEMBER, что фактически не позволит вам писать процедуры с логикой «Вернуть N случайных значений из SET» или семейство SCAN функций. Т.е. скажем сделать какой-то аналог «воркера», который выполнит операцию над семейством ключей или полей в LUA не выйдет. Помните, что у вас не будет человеческой отладки, если вы решили писать что-то действительно сложное, что до сих пор нет нормальных библиотек для тестирования LUA процедур. Насколько я знаю ни на одном языке (хорошая идея для проекта на гитхаб). Это совсем не обычная ситуацию, но помните про ограничение с LUAI_MAXCSTACK. Помните, что любые битовые операции в LUA удивят вас тем, что при поддержки 64 битных целых чисел все битовые операции ограничены 32 битными со знаком. Помните, что фактически у вас нет скриптов, если вы используете кластер, т.к. нет авто переадресации запросов на соседние ноды. LUA в редисе прекрасно позволяет решать огромный спектр вопросов, но это совсем не панацея.
                  • НЛО прилетело и опубликовало эту надпись здесь
                    • 0
                      Да, вы правы. С какой логикой это сделал Сальваторе понятно и я считаю, что это скорее ограничение корявой реализации репликации/интеграции LUA для части команд. К примеру встроенный генератор случайных числе из самой LUA вам доступен. И часть танцев с бубном вокруг SRANDMEMBER так и работает — получите весь список, кидайте кубик в LUA и формируйте таблицу выдачи. В redis много такого — в конкретной фиче всё супер, но чего-то, самую малость, не хватает.
                      • НЛО прилетело и опубликовало эту надпись здесь
                        • 0
                          Я понимаю как работает репликация, ровно как и ограничения с этим связанные. Попробуйте посмотреть на это с такой стороны. Сразу после релиза 2.6 (в котором добавили LUA) сразу же подняли вопрос и про RANDOMKEY и про SRANDMEMBER. Это весьма логично, т.к. сама структура SET подразумевает, что вам для широкого спектра бизнес задач нужно получать из неё N случайных элементов (не даром в 2.6 в эту функцию добавили второй аргумент — количество случайных элементов). Когда впервые поднимался этот вопрос Сальваторе предлагали альтернативу с тем, чтобы переписать репликацию для SET с тем, чтобы свести внутреннюю структуру SET на слейве и мастере — тогда возможно было бы обращаться к элементам сета по индексам. Он ответил в духе — «Это не возможно». К чему я так цепляюсь к этому случае — в редис почти всё так. Смотрите, я отвечаю (и читаю) вопросы по редису на stackoverflow. Аудитория продукта (а редис продукт, который Сальваторе продаёт) часто задаёт вопросы, ответ на который — используйте уникальный список с произвольным доступом. Т.е. или в LIST возможность взять индекс по значению (LRANK request) или в SET получить аналог LRANGE (простите на память не помню как назывался тикет на гитхабе). И тогда можно на LUA можно эффективно решать этот класс задач. И на оба вопроса ответ — «Напишите как нить сами на LUA». Это, имхо, вопрос отношения показывающий отношения основного мейнтейнера в своему продукту. К слову, в том же Tarantool Костя Осипов с командой идёт от решения конкретных проблем и кейсов своих пользователей, вместо отсылок в случае редиса. Как и в 2.8 (поправьте если путаю) вхерачили Global variables protection в LUA — на все вопросы оставьте хотя бы в конфиге шанс выключить это ответ «Не, а то вдруг что». Только не дали в замен никаких вариантов шарить между скриптами вспомогательные функции (а их, к слову, превиликое множество получается при написании чего-то длиннее пары строчек). Простите, накипело.
                          • НЛО прилетело и опубликовало эту надпись здесь
                            • 0
                              Поймите, я не хочу сказать, что решения нет. Я хочу сказать, что в соседнем огороде (в том же тарантуле) люди обошлись без костылей. И в редисе могли бы. Проблемы с репликой и LUA были ещё на этапе alpha preview, то, что будут ограничения было понятно ещё до выхода фичи в их стабильную ветку. Я настаиваю на том, что разработчиками стоило бы поправить проблемы в проектировании репликации вместо цикла решений об ограничении функциональности скриптовой части. В любом случае мы с вами про одно и тоже, просто немного с разных сторон.
                              • НЛО прилетело и опубликовало эту надпись здесь
                                • 0
                                  Тарантул вырос как база данных (с транзакциями, репликацией и т.п.), это потом оказалось, что пользователям в Lua-хранимках нужны сокеты, http клиенты и прочее :)
                          • 0
                            Реплицировать вызовы Lua-функции на slave идеологически не правильно. Подобного рода кактусы уже грызли в MySQL со statement-based replication. В Тарантуле мы реплицируем binary log примитивных запросов (insert, replace, update, delete), поэтому никаких проблем с рандомом и прочим просто не существует. Репликация вообще ничего не знает про Lua, сишные хранимки и т.п., т.к. работает на уровне запросов базы данных.
                            • НЛО прилетело и опубликовало эту надпись здесь
                    • 0
                      Дикий ад, не проще ли Tarantool поставить и не мучить себя?
                      • 0
                        Алексей, советую Вам обратить внимание на Tarantool по следующим причинам:

                        1. В Tarantool Lua — first class citizen. Есть интерактивная консоль, можно запускать скрипты с #!/usr/bin/tarantool, из Lua кода есть доступ абсолютно ко всем функциям Tarantool. Вкупе это дает более прозрачный и удобный цикл отладки и разработки.

                        2. Нет проблем с типизацией, т.к. хранилище внутри и так уже использует MsgPack

                        Кроме того, уже есть множество удобных библиотек, начиная от сокетов, заканчивая http сервером.

                        // Disclaimer: разработчик Tarantool
                        • 0
                          Спасибо, уже начал поверхностное изучение.
                        • 0
                          Хочу задать косвенный вопрос комментирующим. Кто-нибудь смотрел на замену Redis в виде SSDB и Ledis? (Обертки над различными базами, например leveldb, понимающие на 90% протокол Redis) Когда редисом хочется пользоваться, но память на сервере не позволяет.

                          Есть какие-то впечатления по ним из опыта?
                          • 0
                            я использовал SSDB полтора года на продакшине, где-то 4 миллиарда ключей, 38Гб места, скорость была очень хорошей. Проблему создавало только обслуживание, например compact на такой базе требовал х2 места. В остальном вроде никаких проблем не было

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.