Как я уже писал, моя команда делает Радикс - импортозамещенённый Redis поверх Пикодаты. Мы постепенно догоняем обычный редис по функциональности, реализуя некоторые функции лучше, чем это сделано в самом редисе.
В этом релизе мы были сфокусированы на транзакциях в редисе и реализовали оба варианта: MULTI/EXEC
и Lua скрипты. И вот Lua скрипты мы сделали лучше и безопаснее, чем сделано в редисе. Говоря “мы”, я подразумеваю нашего стажёра Виталия, который практически соло затащил такую здоровую фичу. Старшие коллеги помогали только на ревью. Будьте как Виталий, приходите к нам стажироваться.
Например, редис обещает в документации к Lua-скриптам, что они обладают свойством атомарности:
Scripting offers several properties that can be valuable in many cases. These include:
- Providing locality by executing logic where data lives. Data locality reduces overall latency and saves networking resources.
- Blocking semantics that ensure the script’s atomic execution.
- Enabling the composition of simple capabilities that are either missing from Redis or are too niche to be a part of it.
Вот во втором пункте нам и обещают atomic execution
. Что же такое атомарность
в применении к СУБД? Это очень простое правило: скрипт неделим, то есть выполняем либо все команды успешно, либо не выполняется ни одна. Невероятно полезное свойство при работе с СУБД: например, вы хотите сделать программу, которая делает банковские транзакции и вам надо, чтобы списание денег с одного счёта и пополнение второго случались либо оба, либо ни одно.
Давайте возьмём редис 7.4, соберём кластер и посмотрим, как это будет выглядеть на практике. Сбор кластера оставим за кадром, все ленивые типа меня идут и берут готовый в репозитории bitnami, спасибо им.
## (1) подключаемся к кластеру
$ redis-cli -c -p 6300
## (2) убеждаемся, что ключ пустой
127.0.0.1:6300> get my_key
-> Redirected to slot [13711] located at 172.19.0.7:6379
(nil)
## (3) загружаем невалидный скрипт в редис, eval нельзя вызывать через redis.call
172.19.0.7:6379> SCRIPT LOAD "redis.call('SET', KEYS[1], '321'); return redis.call('EVAL', 'return 123', 0)"
"aff82320ad7eadc3b4c30944d0c39edfe81357dd"
## (4) пытаемся выполнить невалидный скрипт
172.19.0.7:6379> evalsha aff82320ad7eadc3b4c30944d0c39edfe81357dd 1 my_key
(error) ERR This Redis command is not allowed from script script: aff82320ad7eadc3b4c30944d0c39edfe81357dd, on @user_script:1.
## (5) проверяем, что ключ остался пустым
172.19.0.7:6379> get my_key
"321"
172.19.0.7:6379>
Что произошло в этом простом примере? Мы подключились к кластеру (1). Убедились, что my_key
не содержит ничего (2). Загрузили (3) и попытались выполнить (4) невалидный скрипт и ожидаем, что в (5) у нас в my_key
всё ещё будет ничего. Но там лежит 321
. То есть, если бы писали функцию банковской транзакции, у нас сумма на одном счёте бы поменялась, а на втором - нет. Беда.
Почему так получилось и откуда там взялось 321
? Правильно, из скрипта: смотрите, мы там первой командой вызываем SET
, это ок, а второй мы вызываем EVAL
, так делать нельзя. Ну нельзя и нельзя, ошибка, ничего ж страшного. Скрипт должен атомарно отработать, верно? Ан нет, после выполнения скрипта 321
так и осталось в нашем ключе навечно.
Получается, что атомарность скриптов в редисе - она такая атомарность, только в документации и если никаких ошибок нет. И следить за этим надо внимательно и при программировании, и при ревью, так и потом ещё и при тестировании, потому что ошибка обнаруживается только на стадии выполнения скрипта.
В Радиксе подобные ошибки обнаруживаются на стадии загрузки скрипта:
## (1) подключаемся к кластеру
$ redis-cli -с -p 7301
## (2) убеждаемся, что ключ пустой
127.0.0.1:7301> get my_key
(nil)
## (3) загружаем невалидный скрипт в редис, eval нельзя вызывать через redis.call
127.0.0.1:7301> SCRIPT LOAD "redis.call('SET', KEYS[1], '321'); return redis.call('EVAL', 'return 123', 0)"
(error) ERR This Redis command is not allowed from script script: eval, on @user_script
## (4) проверяем, что ключ остался пустым
127.0.0.1:7301> get my_key
(nil)
Мы подключились к кластеру (1). Убедились, что my_key
не содержит ничего (2). Загрузили (3) и сразу получаем ошибку. Проверка (4) в целом излишне, вы даже запустить такой скрипт не сможете.
Аналогично мы не даём доступа ни к каким ключам, кроме тех, что переданы в параметры скрипту, как требуется в документации редиса (выделение моё):
Important: to ensure the correct execution of scripts, both in standalone and clustered deployments, all names of keys that a script accesses must be explicitly provided as input key arguments. The script should only access keys whose names are given as input arguments. Scripts should never access keys with programmatically-generated names or based on the contents of data structures stored in the database.
Опять же давайте проверять, как работает кластерный редис 7.4:
## (1) подключаемся к кластеру, '--raw' нужен для нормального отображения UTF-8 символов
$ redis-cli -c -p 6300 --raw
## (2) устанавливаем значение какое-то в ключ
127.0.0.1:6300> set echo_key 'Привет из ключа!'
-> Redirected to slot [4559] located at 172.19.0.5:6379
OK
## (3) пробуем получить значение правильно, работает!
172.19.0.5:6379> eval 'return redis.call("get", KEYS[1])' 1 echo_key
Привет из ключа!
## (4) пробуем получить значение неправильно, тоже работает :(
172.19.0.5:6379> eval 'return redis.call("get", "echo_key")' 0
Привет из ключа!
172.19.0.5:6379>
Из примера мы видим, что редис выполняет даже то, что не должно работать. См. (4), где мы напрямую используем строку, как ключ. Посмотрим на Радикс:
## (1) подключаемся к кластеру, '--raw' нужен для нормального отображения UTF-8 символов
$ redis-cli -c -p 7301 --raw
## (2) устанавливаем значение какое-то в ключ
127.0.0.1:7301> set echo_key 'Привет из ключа!'
OK
## (3) пробуем получить значение правильно, работает!
127.0.0.1:7301> eval 'return redis.call("get", KEYS[1])' 1 echo_key
Привет из ключа!
## (4) пробуем получить значение неправильно, не работает!
127.0.0.1:7301> eval 'return redis.call("get", "echo_key")' 0
LUA_EVAL: failed to evaluate lua script: ERR Script attempted to access a non local key in a cluster node script: -, on @user_script
127.0.0.1:7301>
Радикс ведёт себя следующим образом: когда поступает команда EVAL, мы ограничиваем этому соединению доступ к любым ключам, кроме указанных в параметрах, поэтому выполняется требование из документации, что скрипт не должен обращаться ни к каким ключам, кроме указанных в параметрах.
В будущем, мы подумаем на возможности отключения таких жёстких проверок через настройки плагина, но пока таких запросов от клиентов нет и настройки, соответственно, тоже нет.
comments powered by Disqus