Раздел 37 · Redis с нуля

Lua-скрипты: атомарность и логика на сервере

junior~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке покупка книги потребовала WATCH, чтение в приложении, MULTI и цикл повторов. Работает, но громоздко, а при сильной конкуренции повторы съедают время. В уроке 19 мы уже видели другой путь: скрипт на Lua, который сам читает, решает и пишет на сервере. Тогда мы взяли его для снятия блокировки. Сегодня разбираем скрипты как следует: как передавать данные, что возвращается, что бывает при ошибках и почему скрипт должен быть коротким.

Все сессии ниже сняты с настоящего Redis 7.4. Скрипты из урока можно запускать и в песочнице внизу страницы: она выполняет EVAL, EVALSHA и SCRIPT. Функции FUNCTION и FCALL в ней не работают.

Записка кладовщику

Есть два способа получить книгу со склада. Первый: звонишь кладовщику и спрашиваешь, сколько осталось. Он отвечает «одна». Ты решаешь купить и звонишь снова: «спиши одну». Между звонками кто-то другой мог забрать книгу. Это GET, решение в приложении и DECR, как в уроке 26.

Второй способ: передаёшь кладовщику записку. «Посмотри остаток. Если хватает двух, спиши две и скажи, сколько осталось. Если не хватает, ничего не трогай и скажи “мало”». Кладовщик выполняет записку целиком и только потом берёт следующую. Никто не влезет между «посмотрел» и «списал».

Скрипт Lua и есть такая записка. Redis выполняет его как одну большую команду: пока скрипт работает, другие клиенты ждут.

Что можно передать: KEYS и ARGV

Вспомним формат из урока 19:

EVAL "текст скрипта" число_ключей ключ1 ключ2 ... аргумент1 аргумент2 ...

Внутри скрипта ключи лежат в таблице KEYS, остальные аргументы в ARGV. Нумерация с единицы. Все аргументы приходят строками, а Lua местами превращает их в числа сам:

127.0.0.1:6379> EVAL "return ARGV[1] + ARGV[2]" 0 2 3
(integer) 5
127.0.0.1:6379> EVAL "return ARGV[1] .. ARGV[2]" 0 2 3
"23"

Плюс сложил числа, а две точки склеили строки. Надёжнее приводить явно, через tonumber.

Зачем ключи передавать отдельно? В Redis Cluster данные разложены по нескольким серверам, и перед запуском скрипта Redis должен знать, на каком сервере лежат его ключи. Если скрипт склеит имя ключа сам, как 'book:' .. ARGV[1], на одном сервере это сработает, а в кластере ключ окажется на другом сервере, и скрипт упадёт. Правило: каждый ключ, который трогает скрипт, передаётся в KEYS.

Что возвращается

Lua и Redis говорят на разных языках типов, и на границе случаются сюрпризы. Самый неприятный с дробными числами:

127.0.0.1:6379> EVAL "return 3.99" 0
(integer) 3
127.0.0.1:6379> EVAL "return tostring(3.99)" 0
"3.99"

Дробь молча обрезается до целого. Хочешь вернуть цену или средний рейтинг, преврати число в строку через tostring. Вторая ловушка с таблицами:

127.0.0.1:6379> EVAL "return {1, 'two', 3.5, nil, 5}" 0
1) (integer) 1
2) "two"
3) (integer) 3

Массив оборвался на nil: всё после него потерялось. А 3.5 опять стало тройкой. Остальное преобразуется так:

LuaОтвет Redis
строкастрока
числоцелое, дробная часть отбрасывается
таблица-массивмассив до первого nil
true(integer) 1
false и nil(nil)

В обратную сторону тоже есть особенность. Если redis.call('GET', ...) не нашёл ключ, в Lua приходит не nil, а false. Проверять отсутствие ключа надо так: if v == false then, или просто if not v then.

Ошибки: скрипт атомарен, но не откатывается

Если команда внутри redis.call падает, скрипт прерывается, а ошибка уходит клиенту:

127.0.0.1:6379> SET book:1:title Master
OK
127.0.0.1:6379> EVAL "redis.call('SET', KEYS[1], 'paid') redis.call('INCR', KEYS[2]) return 1" 2 order:15:status book:1:title
(error) ERR value is not an integer or out of range script: 8be285f066d2e45ceb666d48ea955bbb74e66202, on @user_script:1.
127.0.0.1:6379> GET order:15:status
"paid"

Скрипт упал на INCR, но SET до него уже выполнился, и статус заказа остался paid. Как и в MULTI, отката нет. Атомарность скрипта значит «никто не влез посередине», а не «всё или ничего». Отсюда правило: сначала проверки, потом записи.

Если ошибку надо поймать внутри скрипта, вместо redis.call пишут redis.pcall. Он не прерывает скрипт, а возвращает таблицу с полем err:

127.0.0.1:6379> EVAL "local r = redis.pcall('INCR', KEYS[1]) return {'caught', r['err']}" 1 book:1:title
1) "caught"
2) "ERR value is not an integer or out of range"

Свою ошибку скрипт возвращает через redis.error_reply. Клиент получит её как обычную ошибку Redis:

127.0.0.1:6379> EVAL "return redis.error_reply('NOSTOCK not enough books')" 0
(error) NOSTOCK not enough books

Первое слово по обычаю пишут заглавными: по нему код приложения отличает одну ошибку от другой.

урок из курса

Урок из курса «Redis с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.