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

Когда нужна не просто пачка команд, а настоящая логика «если..., то...» прямо на сервере — Redis выполняет ваш Lua-скрипт целиком и атомарно, без гонок и лишних обращений по сети.

Lua-скрипт в Redis — это код, который сервер выполняет как одну неделимую операцию: пока скрипт работает, ни одна другая команда не выполняется. Это даёт атомарность и произвольную логику (ветвления, циклы, вычисления) на стороне сервера за один сетевой вызов.

Зачем на практике

Транзакция MULTI/EXEC хороша, когда команды известны заранее. Но как только нужно «прочитать значение и в зависимости от него решить, что писать», транзакции мало: внутри MULTI результаты команд ещё не видны. Классические задачи — атомарный rate-limiter («не больше N запросов в минуту»), захват распределённой блокировки с проверкой владельца, перенос элемента с условием. Всё это требует логики, которая видит данные в момент исполнения.

Lua-скрипт решает это идеально: внутри него вы вызываете команды Redis, читаете их результаты, ветвитесь и пишете обратно — и весь скрипт выполняется атомарно. Бонусом исчезают лишние round-trip'ы: вместо пяти команд по сети — один вызов скрипта.

EVAL: запуск скрипта

Команда EVAL принимает текст скрипта, число ключей и сами аргументы. Ключи передаются через массив KEYS, прочие параметры — через ARGV. Простейший пример — атомарно увеличить счётчик и вернуть, превышен ли лимит.

EVAL "local n = redis.call('INCR', KEYS[1])
      if n > tonumber(ARGV[1]) then return 0 else return 1 end" 1 rate:user:42 5

Здесь 1 — число ключей; rate:user:42 попадает в KEYS[1], а лимит 5 — в ARGV[1]. Внутри redis.call(...) выполняет настоящую команду Redis. Скрипт вернёт 1, если запрос в пределах лимита, и 0, если превышен — и всё это атомарно, без шанса, что между INCR и проверкой кто-то вклинится.

Почему скрипт атомарен

Redis обрабатывает команды в одном потоке. Скрипт для него — такая же «команда»: пока он не завершится, сервер не возьмёт в работу ничего другого. Поэтому любые чтения и записи внутри скрипта происходят на согласованном, не меняющемся под вами снимке данных. Это и есть ключевое преимущество: вы получаете «прочитал → решил → записал» без единой гонки, чего нельзя добиться двумя отдельными командами.

Смоделируем логику того же rate-limiter на Python, чтобы увидеть поведение на серии запросов.

counter = {}

def rate_script(key, limit):
    counter[key] = counter.get(key, 0) + 1   # как redis.call('INCR', key)
    return 1 if counter[key] <= limit else 0  # 1 = пропустить, 0 = отклонить

limit = 3
results = [rate_script("user:42", limit) for _ in range(5)]

print("Лимит:", limit, "запросов")
print("Результаты 5 запросов (1=пропущен, 0=отклонён):", results)
print("Счётчик в конце:", counter["user:42"])

Вывод:

Лимит: 3 запросов
Результаты 5 запросов (1=пропущен, 0=отклонён): [1, 1, 1, 0, 0]
Счётчик в конце: 5

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

KEYS и ARGV: почему это важно

Redis требует разделять имена ключей (KEYS) и обычные данные (ARGV) не для красоты. В кластере по именам ключей вычисляется, на каком узле они лежат; передавая ключи через KEYS, вы позволяете Redis проверить, что все они на одном шарде. Захардкоженные внутри скрипта имена ключей это правило ломают и делают скрипт несовместимым с кластером.

Что передаёмЧерез чтоПример
Имена ключейKEYS[1], KEYS[2], ...имя счётчика, имя списка
Параметры/значенияARGV[1], ARGV[2], ...лимит, TTL, идентификатор владельца

EVALSHA: не гонять текст каждый раз

Слать целый текст скрипта при каждом вызове расточительно. Поэтому скрипт один раз загружают командой SCRIPT LOAD — она возвращает SHA1-хеш, который сервер кеширует. Дальше скрипт вызывают по хешу через EVALSHA: по сети летит лишь 40 символов хеша вместо всего тела.

127.0.0.1:6379> SCRIPT LOAD "return redis.call('INCR', KEYS[1])"
"a0f5e1...c3"        # SHA1 загруженного скрипта
127.0.0.1:6379> EVALSHA a0f5e1...c3 1 visits
(integer) 1

Если узел перезапустился и забыл скрипт, EVALSHA вернёт ошибку NOSCRIPT — тогда клиент повторяет вызов через полный EVAL (или заново делает SCRIPT LOAD). Хорошие клиентские библиотеки делают этот фолбэк автоматически.

Как это работает под капотом

Внутри Redis встроен интерпретатор Lua. При EVAL сервер компилирует тело скрипта, кеширует его под SHA1 и запускает в своём единственном потоке обработки команд. Все redis.call() внутри выполняются синхронно, как если бы это были обычные команды клиента, но без выхода в сеть — прямо в памяти процесса. Поскольку поток один, никакая внешняя команда не исполнится, пока скрипт не вернёт результат. Значения KEYS и ARGV приходят как строки; числа при необходимости приводятся через tonumber(). Чтобы скрипты оставались детерминированными (важно для репликации и persistence), внутри запрещены недетерминированные источники вроде случайных чисел и системного времени в «чистом» виде — Redis предоставляет согласованные обёртки.

Частые ошибки

  • Хардкод имён ключей внутри скрипта. Ломает совместимость с кластером — всегда передавайте ключи через KEYS.
  • Тяжёлая логика в скрипте. Скрипт блокирует весь сервер на время выполнения; длинные циклы по миллионам элементов «подвесят» Redis для всех.
  • Сравнивать числа как строки. ARGV приходит строкой; забыли tonumber() — и сравнение '10' > '9' даст неожиданный результат.
  • Не обрабатывать NOSCRIPT. После рестарта узла EVALSHA упадёт; нужен фолбэк на EVAL.
  • Брать скрипт там, где хватит одной команды. INCR, SETNX, SET ... EX ... NX уже атомарны сами по себе — скрипт нужен для составной условной логики.

Итоги

  • Lua-скрипт выполняется атомарно: пока он работает, ни одна другая команда не вклинится.
  • Это даёт «прочитал → решил → записал» на сервере без гонок и за один сетевой вызов.
  • Имена ключей передавайте через KEYS, остальные данные — через ARGV (нужно для кластера).
  • SCRIPT LOAD + EVALSHA экономят трафик, слая хеш вместо всего текста; готовьте фолбэк на NOSCRIPT.
  • Скрипт уместен для составной условной логики; простые атомарные операции делает одна команда.
Проверьте себя
1. Почему Lua-скрипт в Redis выполняется атомарно?
ARedis оборачивает скрипт в MULTI/EXEC и делает rollback при ошибке
BRedis обрабатывает команды в одном потоке, поэтому пока скрипт не завершится, никакая другая команда не выполняется
CСкрипт запускается в отдельном изолированном процессе с блокировкой ключей
DLua сам по себе потокобезопасен и синхронизирует доступ
2. Зачем имена ключей передавать через массив KEYS, а не хардкодить внутри скрипта?
AЧтобы скрипт быстрее компилировался
BЭто лишь стилевое соглашение и ни на что не влияет
CПо KEYS Redis в кластере определяет, на каком узле лежат ключи, и проверяет, что все они на одном шарде; хардкод ломает совместимость с кластером
DKEYS автоматически преобразует строки в числа