Lua Scripting in Redis: Atomic Operations, EVAL and Redis Functions
How to use Lua scripting in Redis to perform multi-step operations atomically without race conditions: EVAL, EVALSHA for cached scripts, comparison with MULTI/EXEC transactions and the new Redis Functions API (Redis 7+).
The Atomicity Problem in Redis
Redis is single-threaded for command execution: each command comes executed atomically with respect to the others. But when you need to run a sequence of commands as a single atomic operation (read-modify-write), Race condition problems arise between competing clients.
Redis offers three solutions: MULTI/EXEC (optimistic transactions with
WATCH), EVAL (Lua scripting, the most flexible solution) e
the new one Redis Functions API (Redis 7+, Lua scripts as libraries
permanent with versioning).
What You Will Learn
- MULTI/EXEC: Optimistic transactions and limitations
- EVAL: Run Lua scripts directly in Redis
- EVALSHA: Run cached scripts (zero network overhead)
- KEYS and ARGV in Lua: how to pass parameters
- Patterns: compare-and-swap, atomic rate limiting, distributed locking
- Redis Functions: Permanent Lua libraries in Redis 7+
MULTI/EXEC: Optimistic Transactions
MULTI/EXEC groups commands into a queue that executes atomically. WATCH adds optimistic locking: if the watched key is changed from another client before EXEC, the transaction is canceled.
# MULTI/EXEC: transazione base
MULTI
SET counter 0
INCR counter
INCR counter
EXEC
# 1) OK
# 2) (integer) 1
# 3) (integer) 2
# WATCH + MULTI/EXEC: optimistic locking
WATCH balance:user:1001
# Leggi il saldo corrente
GET balance:user:1001 # "1000"
# Se nessuno modifica balance:user:1001 prima di EXEC, la transazione esegue
MULTI
DECRBY balance:user:1001 100
INCRBY balance:user:1002 100
EXEC
# Se balance:user:1001 era stato modificato nel frattempo:
# (nil) -- transazione annullata, il client deve riprovare
# DISCARD: annulla una transazione in corso
MULTI
SET key1 value1
DISCARD # Annulla tutto
The limitation of MULTI/EXEC is that commands are queued but not you can use the result of one command as input to the next in the same command transaction. This is what Lua scripting is for.
EVAL: Atomic Lua Scripting
EVAL runs a Lua script directly on the Redis server.
The script runs atomically: no other command can
interrupt it. You can read values, do calculations and write results
in a single atomic operation.
# EVAL sintassi: EVAL script numkeys key [key ...] arg [arg ...]
# KEYS[1], KEYS[2], ... per i nomi delle chiavi
# ARGV[1], ARGV[2], ... per gli argomenti aggiuntivi
# Esempio 1: incremento condizionale
# Incrementa solo se il valore e' minore di un massimo
EVAL "
local current = redis.call('GET', KEYS[1])
if current == false then
redis.call('SET', KEYS[1], ARGV[1])
return tonumber(ARGV[1])
end
local val = tonumber(current)
local max = tonumber(ARGV[2])
if val < max then
return redis.call('INCR', KEYS[1])
end
return val
" 1 counter:requests 0 100
# Incrementa counter:requests (KEYS[1]) se < 100 (ARGV[2])
# Default iniziale = 0 (ARGV[1])
# Esempio 2: get-set-expire atomico (GETSET + EXPIRE)
EVAL "
local old = redis.call('GET', KEYS[1])
redis.call('SET', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[2])
return old
" 1 session:token:abc123 "new_value" 3600
# Esempio 3: compare-and-swap (CAS)
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
else
return 0
end
" 1 config:version "v1" "v2"
# Ritorna 1 se CAS riuscito, 0 se il valore era gia' cambiato
# Python: EVAL con redis-py
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# Script Lua per compare-and-swap atomico
CAS_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
else
return 0
end
"""
def compare_and_swap(key: str, expected: str, new_value: str) -> bool:
"""Aggiorna key a new_value solo se il valore corrente e' expected."""
result = r.eval(CAS_SCRIPT, 1, key, expected, new_value)
return bool(result)
# Uso
r.set('config:feature-flag', 'disabled')
success = compare_and_swap('config:feature-flag', 'disabled', 'enabled')
print(f"CAS succeeded: {success}") # True
# Secondo tentativo con valore sbagliato
success2 = compare_and_swap('config:feature-flag', 'disabled', 'enabled')
print(f"CAS succeeded: {success2}") # False (era gia' 'enabled')
EVALSHA: Cached Script for Performance
Every time you call EVAL, the script is sent over the network to Redis.
For frequently called scripts, EVALSHA reduces network traffic:
the client sends only the SHA1 hash of the script (40 characters) instead of the entire code.
Redis maintains a cache of scripts loaded with SCRIPT LOAD.
# SCRIPT LOAD: carica lo script nel server, ottieni SHA1
SCRIPT LOAD "
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
else
return 0
end
"
# "7d5c3d9b8e5d5f9b3c3d9b8e5d5f9b3c3d9b8e5d" (SHA1 hash)
# EVALSHA: esegui con solo l'hash (molto piu' efficiente in rete)
EVALSHA "7d5c3d9b8e5d5f9b3c3d9b8e5d5f9b3c3d9b8e5d" 1 key "expected" "new"
# SCRIPT EXISTS: verifica se uno script e' in cache
SCRIPT EXISTS "7d5c3d9b8e5d5f9b3c3d9b8e5d5f9b3c3d9b8e5d"
# 1) (integer) 1
# SCRIPT FLUSH: rimuove tutti gli script dalla cache (utile in test)
SCRIPT FLUSH
# Python: gestione SHA con fallback automatico
import redis
import hashlib
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
class LuaScript:
"""Wrapper per script Lua con caching SHA automatico."""
def __init__(self, script: str):
self.script = script
self.sha = None
def _load(self) -> str:
"""Carica lo script e memorizza lo SHA."""
self.sha = r.script_load(self.script)
return self.sha
def execute(self, keys: list, args: list):
"""Esegue lo script, con fallback a EVAL se SHA non in cache."""
if self.sha is None:
self._load()
try:
return r.evalsha(self.sha, len(keys), *keys, *args)
except redis.exceptions.NoScriptError:
# Script non piu' in cache (SCRIPT FLUSH chiamato)
self._load()
return r.evalsha(self.sha, len(keys), *keys, *args)
# Script rate limiter atomico: sliding window
RATE_LIMIT_SCRIPT = LuaScript("""
local key = KEYS[1]
local window = tonumber(ARGV[1]) -- finestra in secondi
local max_requests = tonumber(ARGV[2])
local now = tonumber(ARGV[3]) -- timestamp corrente in ms
-- Rimuovi entries scadute
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
-- Conta requests nella finestra
local count = redis.call('ZCARD', key)
if count < max_requests then
-- Aggiungi la request corrente
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1 -- Allowed
else
return 0 -- Rate limited
end
""")
def check_rate_limit(user_id: str, window_sec: int = 60, max_req: int = 100) -> bool:
"""Rate limiting sliding window atomico via Lua. Ritorna True se allowed."""
import time
now_ms = int(time.time() * 1000)
key = f"ratelimit:{user_id}"
result = RATE_LIMIT_SCRIPT.execute(
keys=[key],
args=[str(window_sec), str(max_req), str(now_ms)],
)
return bool(result)
# Test
for i in range(5):
allowed = check_rate_limit("user:1001", window_sec=60, max_req=3)
print(f"Request {i+1}: {'ALLOWED' if allowed else 'RATE LIMITED'}")
Pattern: Distributed Lock with Lua
Distributed lock is one of the most important patterns that can be implemented with Lua. The Redlock algorithm uses atomic SETNX + EXPIRE to acquire locks, and a Lua script for issuing that checks the token before deleting.
# Distributed Lock: SETNX + EXPIRE atomici
# SET key value NX PX milliseconds (Redis 2.6.12+)
SET lock:resource:order-1234 "worker-uuid-abc" NX PX 30000
# OK se il lock e' stato acquisito, nil altrimenti
# Script Lua per rilascio ATOMICO (verifica token prima di cancellare)
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
" 1 lock:resource:order-1234 "worker-uuid-abc"
# Python: Distributed Lock context manager
import redis
import uuid
import time
from contextlib import contextmanager
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
RELEASE_LOCK_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
@contextmanager
def distributed_lock(resource: str, timeout_ms: int = 30000, retry_ms: int = 100):
"""
Context manager per distributed lock.
Usa SET NX PX per acquisizione atomica.
Usa Lua per rilascio atomico (evita rilascio di lock altrui).
"""
lock_key = f"lock:{resource}"
token = str(uuid.uuid4())
acquired = False
try:
# Tentativo di acquisizione lock
acquired = r.set(lock_key, token, nx=True, px=timeout_ms)
if not acquired:
raise Exception(f"Could not acquire lock on {resource}")
yield token # Esegui codice protetto
finally:
if acquired:
# Rilascio atomico: cancella solo se il token e' ancora il nostro
released = r.eval(RELEASE_LOCK_SCRIPT, 1, lock_key, token)
if not released:
# Il lock e' scaduto e qualcun altro lo ha acquisito nel frattempo
print(f"WARNING: Lock {lock_key} expired before release")
# Uso
def process_order(order_id: str):
with distributed_lock(f"order:{order_id}", timeout_ms=10000):
# Questo codice e' eseguito in modo esclusivo
print(f"Processing order {order_id}...")
time.sleep(0.5) # Simula lavoro
print(f"Order {order_id} processed")
# Due worker concorrenti
import threading
t1 = threading.Thread(target=process_order, args=("ORD-123",))
t2 = threading.Thread(target=process_order, args=("ORD-123",))
t1.start()
t2.start()
t1.join()
t2.join()
Redis Functions: Permanent Lua Libraries (Redis 7+)
Redis 7.0 introduces Functions, a system for loading Lua libraries like permanent code on the server. Unlike EVAL/EVALSHA, the Functions they survive Redis reboots, support versioning, and enable to organize related scripts into namespaced libraries.
# Redis Functions: caricare una libreria
# Definisci la libreria in Lua (sintassi Redis 7+)
FUNCTION LOAD "#!lua name=mylib\n
local function rate_limit(keys, args)
local key = keys[1]
local window = tonumber(args[1])
local max_req = tonumber(args[2])
local now = tonumber(args[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
local count = redis.call('ZCARD', key)
if count < max_req then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1
end
return 0
end
local function atomic_cas(keys, args)
if redis.call('GET', keys[1]) == args[1] then
redis.call('SET', keys[1], args[2])
return 1
end
return 0
end
redis.register_function('rate_limit', rate_limit)
redis.register_function('atomic_cas', atomic_cas)
"
# Chiama la funzione con FCALL
FCALL rate_limit 1 ratelimit:user:1001 60 100 1710000000000
# Lista librerie caricate
FUNCTION LIST
# Elimina una libreria
FUNCTION DELETE mylib
# Dump e restore (per backup/migration)
FUNCTION DUMP # Ritorna binary dump
FUNCTION RESTORE [FLUSH] [APPEND] [REPLACE] <payload>
# Python: Redis Functions con redis-py
import redis
import time
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
LIBRARY_CODE = """#!lua name=ratelimit_lib
local function sliding_window_rl(keys, args)
local key = keys[1]
local window = tonumber(args[1])
local max_req = tonumber(args[2])
local now = tonumber(args[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
local count = redis.call('ZCARD', key)
if count < max_req then
redis.call('ZADD', key, now, tostring(now))
redis.call('EXPIRE', key, window + 1)
return {1, max_req - count - 1} -- {allowed, remaining}
end
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after = math.ceil((tonumber(oldest[2]) + window * 1000 - now) / 1000)
return {0, 0, retry_after} -- {blocked, remaining, retry_after_sec}
end
redis.register_function('sliding_rl', sliding_window_rl)
"""
def load_library():
"""Carica o ricarica la libreria Functions."""
try:
r.function_load(LIBRARY_CODE)
except redis.exceptions.ResponseError as e:
if 'Library already exists' in str(e):
r.function_load(LIBRARY_CODE, replace=True)
else:
raise
def check_rate_limit_fn(user_id: str, window: int = 60, max_req: int = 10):
"""Usa Redis Function per rate limiting sliding window."""
key = f"rl:user:{user_id}"
now_ms = int(time.time() * 1000)
result = r.fcall('sliding_rl', 1, key, str(window), str(max_req), str(now_ms))
allowed, remaining = result[0], result[1]
retry_after = result[2] if len(result) > 2 else None
return {
'allowed': bool(allowed),
'remaining': remaining,
'retry_after': retry_after,
}
# Setup
load_library()
# Test
for i in range(12):
result = check_rate_limit_fn("user:1001", window=60, max_req=10)
status = "OK" if result['allowed'] else f"BLOCKED (retry in {result['retry_after']}s)"
print(f"Request {i+1}: {status}, remaining: {result['remaining']}")
MULTI/EXEC vs Lua vs Functions: Comparison
Which Mechanism to Choose
- MULTI/EXEC: Simple command sequences without conditional logic. No intermediate results used as input to subsequent commands in the same transaction.
- EVAL: Complex conditional logic, atomic read-modify-write, rapid prototyping. Inline script in application code.
- EVALSHA: Like EVAL but reduces network payload for frequently called scripts. Requires SHA cache management.
- Functions (Redis 7+): Scripts as first class in the server: survive reboots, versionable, organizeable into libraries. For stable, shared code across multiple applications.
Conclusions
Lua scripting in Redis is the definitive tool for complex atomicity. While MULTI/EXEC covers simple sequences, EVAL allows you to implement patterns sophisticated features such as rate limiting, distributed locks and compare-and-swap without never worry about race conditions. Redis Functions brings these patterns to a higher level, treating Lua code as versioned infrastructure instead of hardcoded strings in your application code.
Upcoming Articles in the Redis Series
- Article 4: Advanced Rate Limiting — Token Bucket, Sliding Window on Redis
- Article 5: Session Management and Cache Patterns — Cache-Aside, Write-Through
- Article 6: RedisJSON and RediSearch — JSON documents and Full-Text Search







