RE:NODE

Транзакции и Lua-скрипты в Valkey: MULTI, WATCH, EVAL

Автор Команда RE:NODE
17 мин чтения


Valkey выполняет команды по одной, поэтому каждая отдельная команда уже атомарна. Проблемы начинаются, когда вашей логике нужно несколько команд - прочитать значение, принять решение, записать результат, - потому что между шагами в дело может вмешаться другой клиент. Для этого есть четыре инструмента, по возрастанию мощности: одна атомарная команда вроде INCR или SET ... NX, транзакция MULTI/EXEC, оптимистичная блокировка через WATCH и Lua-скрипт, запущенный через EVAL. Берите самый простой из них, который закрывает зазор. Для большинства сценариев "проверить, потом изменить" это короткий Lua-скрипт; для простого набора записей, которые нельзя перемешивать с чужими, это MULTI/EXEC; а WATCH нужен, когда решение приходится принимать в коде приложения.

В этой статье разобрано, что именно гарантирует каждый из инструментов, какие гарантии люди предполагают, хотя их нет (отката не существует), и несколько атомарных паттернов - счётчики, compare-and-delete, блокировки, уменьшение остатка на складе, ключи идемпотентности, - которых хватает для большинства реальных задач. Предполагается, что подключиться вы уже умеете; если нет, начните с первых шагов с Valkey.

Почему одиночные команды безопасны, а последовательности - нет#

Valkey исполняет команды в одном главном потоке. Пока выполняется одна команда, ни одна команда другого клиента не выполняется, поэтому INCR counter никогда не потеряет обновление так, как это делает "прочитать, прибавить один, записать" в коде приложения. Проблема в вашем коде. Представьте двух воркеров, которые уменьшают остаток на складе:

code
worker A: GET stock:42      -> 1worker B: GET stock:42      -> 1worker A: SET stock:42 0    (sold the last item)worker B: SET stock:42 0    (sold the same item again)

Каждая команда была атомарной. Последовательность - нет. Valkey сделал ровно то, что ему велели, в порядке, который ваш код не контролировал. Решение никогда не в том, чтобы "работать быстрее"; нужно либо сделать чтение, решение и запись одним целым, либо заметить, что данные изменились у вас под руками, и повторить попытку.

Прежде чем браться за что-то тяжёлое, проверьте, не справляется ли одна команда. Многие справляются:

ЗадачаОдна атомарная команда
Считать без потерянных обновленийINCR, INCRBY, HINCRBY, ZINCRBY
Создать, только если ключа нетSET key value NX
Создать с временем жизни за один шагSET key value NX EX 30
Прочитать и удалитьGETDEL
Прочитать и обновить TTLGETEX key EX 60
Переместить между спискамиLMOVE source dest LEFT RIGHT
Извлечь участника с наименьшим счётомZPOPMIN

Команда SET key value NX EX 30 особенно важна: старая двухшаговая форма, SETNX и затем EXPIRE, может оставить ключ без срока жизни, если клиент умрёт между двумя вызовами. Одна команда так не умеет. Там, где есть одна команда, предпочитайте её; остальная часть статьи - для случаев, когда её нет. Типы данных, на которых работают эти команды, описаны в статье типы данных Valkey.

MULTI и EXEC: очередь, а не изоляция по логике#

MULTI открывает транзакцию. Каждая следующая команда не выполняется, а ставится в очередь, и сервер отвечает QUEUED. Затем EXEC выполняет всю очередь по порядку, одним непрерывным блоком: ни одна команда другого клиента не исполняется между первой и последней командой очереди. Попробовать можно в valkey-cli, где при открытой транзакции в приглашении появляется (TX):

code
127.0.0.1:6379> MULTIOK127.0.0.1:6379(TX)> DECR stock:42QUEUED127.0.0.1:6379(TX)> LPUSH orders:pending "order-9001"QUEUED127.0.0.1:6379(TX)> EXEC1) (integer) 02) (integer) 1

EXEC возвращает по одному ответу на каждую команду из очереди, по порядку. DISCARD вместо этого выбрасывает очередь и ничего не меняет.

Что это даёт: изоляцию (никто не видит наполовину выполненное состояние) и выполнение всего за один присест. Чего это не даёт - и здесь люди ошибаются чаще всего:

  • Нельзя использовать прочитанное в следующих записях. Внутри MULTI вы получаете QUEUED, а не значение. Нельзя сделать GET числа и подставить его в следующую команду очереди, потому что GET ещё не выполнился. Все решения нужно принять до того, как вы откроете транзакцию.
  • Нет отката. Если одна команда упала во время выполнения, остальные всё равно выполнятся. Транзакция с INCR по ключу, в котором лежит текст, сообщит об ошибке для этой одной команды и спокойно применит остальные.
  • Два вида сбоев обрабатываются по-разному. Если команда некорректна уже при постановке в очередь (неизвестная команда, неверное число аргументов), сервер запоминает ошибку, и EXEC целиком отказывается выполнять транзакцию с EXECABORT. Если команда корректна, но падает при выполнении (значение не того типа), падает только она.
code
127.0.0.1:6379> SET name "alice"OK127.0.0.1:6379> MULTIOK127.0.0.1:6379(TX)> INCR nameQUEUED127.0.0.1:6379(TX)> SET other 1QUEUED127.0.0.1:6379(TX)> EXEC1) (error) ERR value is not an integer or out of range2) OK

Ключ other был записан, хотя INCR name упал. Если вы пришли из SQL-базы, к этому сюрпризу надо быть готовым: транзакции Valkey - это пакет, который нельзя прервать, а не единица работы, которую можно отменить. Причина в выбранном дизайне: для отката нужен журнал отмены, а проект предпочитает быстрый и простой сервер, исходя из того, что сбой команды во время выполнения - это ошибка программиста, которую надо ловить на разработке.

MULTI - это не просто конвейер

Конвейер (pipelining) отправляет много команд, не дожидаясь ответа на каждую; он экономит round trip, но команды других клиентов могут выполниться между вашими. MULTI/EXEC добавляет гарантию, что этого не произойдёт. Большинство клиентских библиотек смешивают два понятия, так что проверьте, что используете вы:

transaction.js
// ioredis: .multi() is a real MULTI/EXEC; .pipeline() is notconst results = await valkey  .multi()  .decr("stock:42")  .lpush("orders:pending", "order-9001")  .exec();// results: [[null, 0], [null, 1]]  (error, value) per command
transaction.py
# redis-py: transaction=True (the default) wraps the pipeline in MULTI/EXECpipe = r.pipeline(transaction=True)pipe.decr("stock:42")pipe.lpush("orders:pending", "order-9001")print(pipe.execute())        # [0, 1]

В redis-py вызов pipeline(transaction=False) отправляет те же команды вообще без MULTI. Это быстрее и вполне правильно для набора независимых записей, и неправильно для всего, что требует гарантии отсутствия вмешательства.

WATCH: оптимистичная блокировка#

Одного MULTI недостаточно для "прочитать баланс и вычесть, только если его хватает". Недостающий элемент даёт WATCH. Вы сообщаете серверу, какие ключи вас интересуют; если любой из них изменит другой клиент до вашего EXEC, транзакция не выполняется, а EXEC возвращает пустой (null) ответ. Тогда вы читаете заново и повторяете. Блокировка называется оптимистичной, потому что ничего не блокируется: вы исходите из того, что никто не помешает, и обрабатываете случай, когда помешали.

Последовательность всегда одна и та же:

  1. Выполните WATCH для ключей, которые собираетесь читать.
  2. Прочитайте их обычными командами (вне MULTI) и примите решение в своём коде.
  3. MULTI, поставьте записи в очередь, EXEC.
  4. Если EXEC вернул null, кто-то изменил отслеживаемый ключ. Вернитесь к шагу 1.
reply arraynull, retryYour codedecides in the appGET keyread and decideMULTI / EXECwrite if unchangedCommittedno one touched itWATCH keystart watching
Транзакция с WATCH и её повтор

На Python это выглядит так - снятие средств, которое отказывается уходить ниже нуля:

withdraw.py
import redisdef withdraw(r, account, amount):    key = f"balance:{account}"    with r.pipeline() as pipe:        while True:            try:                pipe.watch(key)                balance = int(pipe.get(key) or 0)   # immediate mode after WATCH                if balance < amount:                    pipe.unwatch()                    return False                pipe.multi()                pipe.decrby(key, amount)                pipe.execute()                      # raises WatchError if changed                return True            except redis.WatchError:                continue                            # someone wrote to it - retry

Несколько правил, которые сэкономят время на отладке:

  • WATCH сбрасывается командами EXEC, DISCARD, UNWATCH и закрытием соединения. Выполнилась транзакция или прервалась, наблюдение снято, поэтому при повторе WATCH нужно вызвать снова.
  • WATCH принадлежит соединению. При пуле соединений WATCH, чтения и EXEC должны идти по одному и тому же соединению - именно это делает контекст pipeline() выше. Вызвать WATCH через одно соединение из пула, а MULTI через другое - классическая ошибка: ни сообщения, ни защиты.
  • Любая запись в отслеживаемый ключ считается изменением, даже если записано то же значение. Горячий ключ при высокой конкуренции заставляет цикл повтора крутиться впустую; ограничьте число попыток и завершайтесь понятной ошибкой, а не зацикливайтесь навсегда.
  • Следить за ключом, которого ещё нет, можно, и это полезно: создание ключа считается изменением, так вы обнаруживаете гонку двух клиентов, которые инициализируют одно и то же.

WATCH - правильный выбор, когда для решения нужна логика приложения, которую неразумно уместить в Lua: вызов другого сервиса, разбор JSON вашими библиотеками. Для логики, которая помещается в несколько строк Lua, скрипт проще и вообще не требует цикла повтора, потому что его ничто не может прервать.

EVAL: выполнить Lua на сервере#

EVAL отправляет серверу Lua-скрипт, который выполняется как один атомарный шаг. Пока он работает, другие команды не исполняются, поэтому чтение, решение и запись нельзя перемешать с чужими - без WATCH, без повторов, за один round trip. Команда выглядит так:

code
EVAL script numkeys key [key ...] arg [arg ...]

numkeys сообщает, сколько следующих слов - имена ключей; в скрипте они доступны как массив KEYS, а всё, что идёт после них, - как ARGV. Нумерация обоих массивов начинается с 1, как это принято в Lua. Скрипт, который уменьшает остаток, только если его достаточно:

take_stock.lua
-- KEYS[1] stock key; ARGV[1] quantity wantedlocal have = tonumber(redis.call("GET", KEYS[1]) or "0")local want = tonumber(ARGV[1])if have < want then  return -1endreturn redis.call("DECRBY", KEYS[1], want)

Из командной строки его запускают через --eval. Обратите внимание на одиночную запятую, которая отделяет ключи от аргументов, с пробелом с каждой стороны - это синтаксис valkey-cli, а не Lua:

bash
$ valkey-cli -h db.example.net -p 6380 --askpass --eval take_stock.lua stock:42 , 3(integer) 17

Или встроенным способом, где numkeys указываете вы сами:

bash
$ valkey-cli -h db.example.net -p 6380 --askpass \    EVAL "return redis.call('GET', KEYS[1])" 1 stock:42"20"

Внутри скрипта команды Valkey вызываются через redis.call(command, arg, ...). Свежие версии Valkey принимают и псевдоним server.call, но redis.call работает и на Valkey, и на старом Redis, поэтому это более безопасная запись, если скрипты могут переезжать. Несколько деталей, которые важны на практике:

  • Объявляйте в `KEYS` каждый ключ, который трогаете. Скрипты, собирающие имена ключей внутри Lua-кода, работают на одиночном сервере, но ломаются в кластере, где серверу нужно знать, какие ключи использует скрипт, чтобы его маршрутизировать. Даже если кластер вам не грозит, объявление ключей ничего не стоит.
  • Значения превращаются в типы Lua и обратно в ответы. Число Lua преобразуется в целочисленный ответ (дробная часть отбрасывается), таблица Lua - в массив, false - в nil. Если нужна десятичная дробь, верните её строкой через tostring.
  • `tonumber` перед арифметикой. Всё в ARGV - строки, как и ответы GET.
  • Ошибки. redis.call останавливает скрипт и возвращает клиенту ошибку, если команда не удалась. redis.pcall возвращает ошибку таблицей, так что скрипт может её обработать. Ни один из них не откатывает записи, которые скрипт уже сделал, - как и с MULTI, скрипт, упавший на полпути, оставляет прежние записи на месте. Сначала проверки, записи в конце.
  • Передавайте время аргументом, а не полагайтесь на часы внутри скрипта, когда это возможно: тогда скрипт - чистая функция своих входов, и его гораздо проще тестировать.

EVALSHA и кэш скриптов

Отправлять полный текст скрипта при каждом вызове расточительно. SCRIPT LOAD сохраняет скрипт на сервере и возвращает его хэш SHA1, а EVALSHA запускает скрипт по хэшу:

bash
$ valkey-cli -h db.example.net -p 6380 --askpass SCRIPT LOAD "$(cat take_stock.lua)""9a7f1c0e5b2d8a64c3e19f0b7d25a8c4e6f13b02"$ valkey-cli -h db.example.net -p 6380 --askpass \    EVALSHA 9a7f1c0e5b2d8a64c3e19f0b7d25a8c4e6f13b02 1 stock:42 3(integer) 14

Хэш выше приведён для примера, у вас он будет другим. Три факта о кэше скриптов, из-за которых случаются настоящие инциденты:

  • Он живёт в памяти и не сохраняется механизмами персистентности, поэтому после перезапуска EVALSHA отвечает NOSCRIPT. О том, что перезапуск сохраняет, читайте в статье про персистентность AOF и RDB.
  • Клиентские библиотеки справляются с этим так: сначала пробуют EVALSHA, а при NOSCRIPT повторяют через EVAL с полным текстом. Если вы вызываете EVALSHA вручную, делайте то же самое.
  • SCRIPT EXISTS sha [sha ...] показывает, есть ли хэши в кэше, SCRIPT FLUSH очищает кэш, а сам кэш относится к серверу, а не к базе данных.

Функции в Redis 7 и Valkey

Новые версии добавляют FUNCTION LOAD и FCALL: они хранят на сервере именованные версионируемые библиотеки Lua-функций, и эти функции сохраняются вместе с данными и переживают перезапуск. Так решается проблема NOSCRIPT, но ценой дополнительного шага развёртывания: библиотеку нужно загружать при деплое, а не позволять клиенту лениво загружать скрипты. Для большинства приложений EVAL через клиентскую библиотеку остаётся более простым путём; о функциях стоит думать, когда одна и та же серверная логика нужна многим сервисам. Прежде чем на них опираться, проверьте версию сервера командой INFO server.

Цена атомарности: медленный скрипт блокирует всех

Поскольку скрипт занимает единственный главный поток, медленный скрипт останавливает всех остальных клиентов. Держите скрипты в пределах нескольких команд с ограниченным объёмом работы. Никогда не обходите внутри скрипта неограниченное множество (KEYS *, огромный список, SMEMBERS на миллионе элементов). У сервера есть лимит времени скрипта, по умолчанию пять секунд (настройка называется lua-time-limit, в новых версиях её называют ещё busy-reply-threshold); после него сервер начинает отвечать остальным клиентам BUSY и разрешает SCRIPT KILL, который работает, только если скрипт ещё ничего не записал. Скрипт, который уже писал, можно остановить лишь командой SHUTDOWN NOSAVE, а она отбрасывает всё, что было после последнего сохранения. Относитесь к ней как к огнетушителю за стеклом и держите скрипты настолько короткими, чтобы он не понадобился.

Атомарные паттерны, которые стоит знать#

Счётчик с потолком

Типичная проверка лимита - "увеличить, но не выше 100" - как раз тот случай чтения-решения-записи, который один INCR выразить не может:

incr_max.lua
-- KEYS[1] counter; ARGV[1] maximum; ARGV[2] ttl in secondslocal n = redis.call("INCR", KEYS[1])if n == 1 then  redis.call("EXPIRE", KEYS[1], tonumber(ARGV[2]))endif n > tonumber(ARGV[1]) then  redis.call("DECR", KEYS[1])  return 0endreturn n

Это строительный блок ограничителя с фиксированным окном. Все варианты, включая скользящие окна и token bucket, разобраны в статье про ограничение частоты запросов в Valkey.

Compare-and-delete: безопасное снятие блокировки

Блокировка в Valkey - это ключ, который создаётся командой SET lock:report <token> NX PX 30000: она срабатывает, только если блокировку никто не держит, и истекает через 30 секунд, чтобы упавший владелец не заблокировал всех навсегда. Токен - случайная строка, уникальная для владельца. Ошибаются на снятии. Если просто выполнить DEL по ключу, можно удалить блокировку, которая уже истекла и была взята кем-то другим. Удалять нужно только если в ключе по-прежнему ваш токен, а это чтение с последующей записью:

release_lock.lua
-- KEYS[1] lock key; ARGV[1] the holder's tokenif redis.call("GET", KEYS[1]) == ARGV[1] then  return redis.call("DEL", KEYS[1])endreturn 0

Та же схема - сравнить, потом действовать - даёт "продлить мою блокировку, если она всё ещё моя" (замените DEL на PEXPIRE) и "подтвердить это задание, если оно всё ещё моё". Такая блокировка защищает от гонки двух воркеров на одном сервере. Она не гарантирует взаимного исключения при разделении сети или долгой паузе владельца, поэтому там, где дубликат работы нанесёт реальный ущерб, делайте идемпотентной и саму работу.

Check-and-set с версией

Оптимистичный контроль конкурентности для одной записи без WATCH: храните номер версии внутри хэша и записывайте только тогда, когда он совпадает с прочитанным.

cas_update.lua
-- KEYS[1] hash; ARGV[1] expected version; ARGV[2] new valuelocal v = redis.call("HGET", KEYS[1], "version")if v ~= ARGV[1] then  return 0endredis.call("HSET", KEYS[1], "value", ARGV[2])redis.call("HINCRBY", KEYS[1], "version", 1)return 1

Приложение читает HGETALL, вносит изменение и вызывает скрипт с той версией, которую увидело. Возврат нуля означает, что кто-то успел раньше, и приложение решает, перечитывать и повторять или нет.

Ключи идемпотентности

Когда запрос может прийти повторно (вебхук, колбэк платёжной системы, сообщение очереди, доставленное дважды), запишите, что вы его уже обработали, и пропускайте повторы. Это делает одна команда:

bash
$ valkey-cli -h db.example.net -p 6380 --askpass SET idem:evt_1042 1 NX EX 86400OK$ valkey-cli -h db.example.net -p 6380 --askpass SET idem:evt_1042 1 NX EX 86400(nil)

OK означает, что вы первый и должны выполнить работу; (nil) - что событие уже занято. Оговорка касается сбоя между захватом и завершением: если воркер умрёт после захвата, повтор будет отклонён. Если это важно, храните статус ("started", "done"), а не просто отметку, и ставьте захвату TTL, достаточно долгий для работы и достаточно короткий, чтобы повторить после сбоя. Как с той же проблемой справляются библиотеки очередей, описано в статье очереди заданий на Valkey.

Перемещение данных между ключами

Перенос элемента из списка ожидающих в список обрабатываемых - это один LMOVE, атомарный сам по себе. Перенос участника между множествами с обновлением счётчика - случай для MULTI/EXEC, поскольку все аргументы известны до начала:

code
MULTISMOVE tasks:todo tasks:doing task-77HINCRBY stats:tasks doing 1EXEC

Если что-то из этого зависело от значения, которое нужно сначала прочитать, используйте скрипт.

Что выбрать#

СитуацияЧто взять
Хватает одного шага (INCR, SET NX)Одна команда
Несколько записей, все входные данные известны заранееMULTI/EXEC
Решению нужен код приложения или внешние вызовыWATCH и цикл повтора
Прочитать, решить, записать в нескольких строкахLua через EVAL
Одна и та же логика у многих сервисовБиблиотеки FUNCTION

Lua - не всегда лучший ответ. Скрипт - это код, живущий на сервере, вне ваших обычных тестов и деплоев, на языке, на котором ваша команда может не писать каждый день. Его сложнее отлаживать, он блокирует сервер во время работы и невидим для того, кто читает только приложение. Применяйте его, когда атомарность действительно этого требует, делайте каждый скрипт небольшим, храните скрипты файлами в репозитории и называйте по тому, что они делают. Если вы пишете сто строк Lua, логике, вероятно, место в приложении с другой моделью данных или в базе данных с настоящими транзакциями. Честный вывод статьи Redis: когда он нужен подходит и здесь: самая надёжная атомарная операция - та, без которой вы обошлись.

Транзакции, репликация и персистентность#

Транзакция или скрипт записываются в append-only файл и отправляются репликам в виде тех команд, которые реально выполнились, поэтому после перезапуска журнал воспроизводит эффекты, а не запускает вашу логику заново. Поэтому скрипт, использовавший часы или случайное число, при восстановлении не расходится с исходным состоянием. Из этого следуют два вывода:

  • Транзакция атомарна по отношению к другим клиентам, но не по отношению к сбою. Если сервер умрёт посреди записи, AOF может закончиться на середине транзакции; при старте Valkey либо обрежет хвост, либо откажется загружаться, пока вы не почините файл, как описано в статье про персистентность AOF и RDB. Не стройте на MULTI ничего, где нужна долговечность по принципу "всё или ничего".
  • При настройке по умолчанию appendfsync everysec последняя секунда записей может пропасть при жёстком сбое, включая транзакции.

Контроль доступа тоже действует. Пользователю с ограничениями ACL нужно разрешение на EVAL/EVALSHA (категория @scripting) и на каждую команду и ключ, которых касается скрипт. Если скрипт падает с ошибкой доступа, хотя те же команды вручную работают, смотрите сюда; подробности в статье про безопасность и ACL в Valkey.

Как это работает на сервере Valkey в RE:NODE#

Valkey в линейке баз данных - это open-source форк Redis, он говорит на протоколе Redis и защищён паролем, учётные данные генерируются для каждого сервера. Он сохраняется на диск через AOF и снапшоты, поэтому результаты ваших транзакций и скриптов переживают перезапуск, как описано выше. Подключайтесь через valkey-cli или любой клиент Redis, используя хост, порт и пароль из панели, и всё в этой статье запускается так же: команды выше используют заглушку db.example.net:6380, подставьте свой хост и порт. Тарифы начинаются от $2 в месяц, самый маленький - 256 МБ, этого с запасом хватает для счётчиков, блокировок и ключей идемпотентности. Если скрипт блокирует ваш сервер, это быстро видно на графиках памяти и CPU в консоли относительно лимитов тарифа: линия CPU, упёршаяся в лимит, пока растёт задержка, - типичный признак дорогого скрипта.

FAQ#

Поддерживает ли Valkey откат?

Нет. DISCARD отбрасывает очередь до EXEC, а транзакция с некорректной командой отклоняется целиком, но когда команды уже выполняются, сбой на полпути не отменяет предыдущие. То же верно для Lua-скрипта, упавшего после записи. Сначала проверяйте входные данные, а записи ставьте в конец.

MULTI/EXEC - это то же самое, что конвейер?

Нет. Конвейер лишь группирует отправку команд, чтобы сэкономить round trip, и команды других клиентов могут выполниться между вашими. MULTI/EXEC гарантирует, что команды из очереди выполнятся вместе, без вмешательства. Некоторые библиотеки называют оба варианта "pipeline", поэтому проверьте, действительно ли ваш код отправляет MULTI.

Когда использовать WATCH вместо Lua-скрипта?

Когда решение нельзя принять внутри Lua, потому что между чтением и записью нужен код приложения или обращение к другой системе. Для всего, что помещается в короткий скрипт, Lua проще и не требует цикла повтора. При сильной конкуренции за один ключ скрипт тоже выигрывает, потому что повторы WATCH накапливаются.

Почему после перезапуска я получаю NOSCRIPT?

Кэш скриптов хранится в памяти и не сохраняется, поэтому EVALSHA не находит скрипт, загруженный до перезапуска. Выполните SCRIPT LOAD снова или позвольте клиентской библиотеке откатиться на EVAL с полным текстом - большинство делает это автоматически. Функции, загруженные через FUNCTION LOAD, сохраняются вместе с данными.

Может ли Lua-скрипт вызвать другой скрипт или использовать блокирующие команды?

Скрипт не может вызвать EVAL изнутри себя, а блокирующие команды вроде BLPOP в скрипте не блокируют: они ведут себя так, будто таймаут уже истёк. Оставляйте в скрипте неблокирующие команды, а ожидание выполняйте на стороне клиента.

Сколько может работать скрипт?

Недолго. По умолчанию лимит пять секунд, после которых остальные клиенты получают ошибки BUSY, но любой скрипт, который выполняется дольше нескольких миллисекунд, уже тормозит всех остальных. Ограничивайте объём работы скрипта и переносите большие пакеты в несколько вызовов вместо одного.

Это было полезно?