В прошлом уроке покупка книги потребовала 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 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.