Redis Lua Scripting: Atomic Operations
• Lua scripting in Redis guarantees atomic execution of complex operations, eliminating race conditions that plague multi-command transactions in distributed systems
Key Insights
• Lua scripting in Redis guarantees atomic execution of complex operations, eliminating race conditions that plague multi-command transactions in distributed systems • Scripts execute with O(1) network overhead regardless of operation complexity, reducing latency by up to 90% compared to multiple round-trips for equivalent MULTI/EXEC blocks • Redis evaluates Lua scripts in a sandboxed environment with access to 200+ built-in functions, enabling server-side data transformations without client-side processing overhead
Why Lua Scripting Matters for Redis
Redis transactions using MULTI/EXEC provide atomicity but lack conditional logic. You cannot make decisions based on intermediate values during execution. Lua scripts solve this limitation by executing arbitrary logic atomically on the Redis server.
Consider a rate limiter implementation. With MULTI/EXEC, you’d need to GET the current count, check it client-side, then conditionally INCR—creating a race condition. Lua scripting eliminates this entirely:
-- rate_limiter.lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
return 0
end
redis.call('INCR', key)
redis.call('EXPIRE', key, window)
return 1
Execute this script with the EVAL command:
redis-cli EVAL "$(cat rate_limiter.lua)" 1 user:123:requests 100 60
The script runs atomically. No other command executes between GET and INCR, guaranteeing accurate rate limiting even under high concurrency.
Script Loading and Execution Patterns
Redis offers two execution methods: EVAL for ad-hoc execution and EVALSHA for cached scripts. Loading scripts into Redis cache improves performance by avoiding script transmission on every call.
import redis
import hashlib
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# Load script and get SHA1 hash
script = """
local key = KEYS[1]
local value = ARGV[1]
local ttl = tonumber(ARGV[2])
redis.call('SET', key, value)
redis.call('EXPIRE', key, ttl)
return redis.call('GET', key)
"""
sha = r.script_load(script)
print(f"Script SHA: {sha}")
# Execute using EVALSHA
result = r.evalsha(sha, 1, 'test:key', 'test_value', 300)
print(f"Result: {result}")
For production systems, implement fallback logic handling script cache eviction:
def execute_script_safe(redis_client, sha, script, num_keys, *args):
try:
return redis_client.evalsha(sha, num_keys, *args)
except redis.exceptions.NoScriptError:
# Script not in cache, load and retry
redis_client.script_load(script)
return redis_client.evalsha(sha, num_keys, *args)
Atomic Counter Operations with Conditional Logic
Distributed counters often require bounds checking or conditional increments. This pattern appears in inventory systems, quota management, and resource allocation.
-- conditional_decrement.lua
local key = KEYS[1]
local decrement_by = tonumber(ARGV[1])
local minimum = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or 0)
if current - decrement_by < minimum then
return {0, current} -- Failed, return current value
end
local new_value = redis.call('DECRBY', key, decrement_by)
return {1, new_value} -- Success, return new value
Node.js implementation with error handling:
const redis = require('redis');
const fs = require('fs');
const client = redis.createClient();
const script = fs.readFileSync('conditional_decrement.lua', 'utf8');
async function reserveInventory(productId, quantity) {
await client.connect();
const result = await client.eval(script, {
keys: [`inventory:${productId}`],
arguments: [quantity.toString(), '0']
});
const [success, value] = result;
if (success === 1) {
console.log(`Reserved ${quantity} units. Remaining: ${value}`);
return true;
} else {
console.log(`Insufficient inventory. Available: ${value}`);
return false;
}
}
reserveInventory('prod_123', 5);
Building a Distributed Lock with Lua
Distributed locks prevent concurrent access to shared resources. A correct implementation requires atomic set-if-not-exists with expiration—perfect for Lua scripting.
-- acquire_lock.lua
local key = KEYS[1]
local token = ARGV[1]
local ttl = tonumber(ARGV[2])
local result = redis.call('SET', key, token, 'NX', 'EX', ttl)
if result then
return 1
else
return 0
end
Release requires token verification to prevent unlocking by wrong clients:
-- release_lock.lua
local key = KEYS[1]
local token = ARGV[1]
local current_token = redis.call('GET', key)
if current_token == token then
redis.call('DEL', key)
return 1
else
return 0
end
Python wrapper implementing retry logic:
import redis
import uuid
import time
class RedisLock:
def __init__(self, redis_client, lock_name, ttl=10):
self.client = redis_client
self.lock_name = f"lock:{lock_name}"
self.token = str(uuid.uuid4())
self.ttl = ttl
self.acquire_script = """
local key = KEYS[1]
local token = ARGV[1]
local ttl = tonumber(ARGV[2])
if redis.call('SET', key, token, 'NX', 'EX', ttl) then
return 1
else
return 0
end
"""
self.release_script = """
local key = KEYS[1]
local token = ARGV[1]
if redis.call('GET', key) == token then
redis.call('DEL', key)
return 1
else
return 0
end
"""
def acquire(self, timeout=5):
start_time = time.time()
while time.time() - start_time < timeout:
result = self.client.eval(
self.acquire_script,
1,
self.lock_name,
self.token,
self.ttl
)
if result == 1:
return True
time.sleep(0.1)
return False
def release(self):
return self.client.eval(
self.release_script,
1,
self.lock_name,
self.token
) == 1
# Usage
r = redis.Redis()
lock = RedisLock(r, 'critical_section', ttl=30)
if lock.acquire(timeout=10):
try:
# Critical section code
print("Lock acquired, performing work...")
time.sleep(2)
finally:
lock.release()
print("Lock released")
Complex Data Transformations
Lua scripts excel at server-side data processing, reducing network transfer and client-side computation. This pattern suits leaderboard updates, analytics aggregation, and batch operations.
-- update_leaderboard.lua
local leaderboard_key = KEYS[1]
local user_id = ARGV[1]
local score = tonumber(ARGV[2])
local max_entries = tonumber(ARGV[3])
-- Add or update score
redis.call('ZADD', leaderboard_key, score, user_id)
-- Trim to top N entries
redis.call('ZREMRANGEBYRANK', leaderboard_key, 0, -(max_entries + 1))
-- Get user rank and top 10
local rank = redis.call('ZREVRANK', leaderboard_key, user_id)
local top_players = redis.call('ZREVRANGE', leaderboard_key, 0, 9, 'WITHSCORES')
return {rank, top_players}
Go implementation with struct mapping:
package main
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
"strconv"
)
type LeaderboardResult struct {
Rank int64
TopPlayers []redis.Z
}
func updateLeaderboard(ctx context.Context, rdb *redis.Client, userId string, score float64) (*LeaderboardResult, error) {
script := `
local leaderboard_key = KEYS[1]
local user_id = ARGV[1]
local score = tonumber(ARGV[2])
local max_entries = tonumber(ARGV[3])
redis.call('ZADD', leaderboard_key, score, user_id)
redis.call('ZREMRANGEBYRANK', leaderboard_key, 0, -(max_entries + 1))
local rank = redis.call('ZREVRANK', leaderboard_key, user_id)
local top_players = redis.call('ZREVRANGE', leaderboard_key, 0, 9, 'WITHSCORES')
return {rank, top_players}
`
result, err := rdb.Eval(ctx, script, []string{"game:leaderboard"}, userId, score, 1000).Result()
if err != nil {
return nil, err
}
data := result.([]interface{})
rank := data[0].(int64)
return &LeaderboardResult{
Rank: rank,
}, nil
}
Performance Considerations
Lua scripts block Redis during execution. Keep scripts lightweight and avoid expensive operations. Benchmark script execution time:
import redis
import time
r = redis.Redis()
script = """
local iterations = tonumber(ARGV[1])
for i = 1, iterations do
redis.call('GET', 'dummy_key')
end
return iterations
"""
start = time.time()
r.eval(script, 0, 10000)
duration = time.time() - start
print(f"10,000 iterations took {duration*1000:.2f}ms")
Optimize by minimizing Redis calls inside loops and using pipelining where atomicity isn’t required. Scripts exceeding 5ms execution time should be refactored or moved to client-side processing.