iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Lua Scripting

Redis Lua scripts run atomically on the server — safe for multi-step operations that should be all-or-nothing. EVAL runs inline; EVALSHA caches by hash. Common for rate limits, atomic move/dedup, counters with caps.

EVAL, EVALSHA, atomic patterns

EXAMPLE
# 1) Basic EVAL
EVAL "return 'hello'" 0
# Result: 'hello'

# Numbers
EVAL "return 42" 0
# Result: 42

# Tables (arrays)
EVAL "return {1, 2, 3}" 0
# Result: 1) 1  2) 2  3) 3

# 2) Pass KEYS + ARGV
EVAL "return redis.call('GET', KEYS[1])" 1 mykey

EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey 'hello'
# Returns 'OK'

EVAL "return redis.call('INCRBY', KEYS[1], ARGV[1])" 1 counter 10
# Returns new value

# 3) Atomicity guarantee — script runs as ONE Redis command
# No other client can interleave between calls within the script.
# IMPORTANT: this is single-threaded; long scripts block the entire server.

# === Real recipes ===

# 4) Atomic compare-and-set
EVAL "
local val = redis.call('GET', KEYS[1])
if val == ARGV[1] then
    redis.call('SET', KEYS[1], ARGV[2])
    return 1
end
return 0
" 1 mykey oldvalue newvalue
# Returns 1 if swap happened, 0 if mykey wasn't 'oldvalue'

# 5) Rate limit — fixed window with auto-expire
EVAL "
local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
    return 0
end
return 1
" 1 rate:user:42 60 100
# KEYS[1] = key, ARGV[1] = window seconds, ARGV[2] = max count
# Returns 1 (allowed) or 0 (denied)

# 6) Sliding-window rate limit (more accurate)
EVAL "
local now    = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit  = tonumber(ARGV[3])

-- Remove entries older than window
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window * 1000)

-- Count current requests in window
local count = redis.call('ZCARD', KEYS[1])

if count >= limit then
    return 0
end

-- Add current request
redis.call('ZADD', KEYS[1], now, now)
redis.call('EXPIRE', KEYS[1], window)
return 1
" 1 rate:user:42 1700000000000 60 100

# 7) Atomic INCR with cap
EVAL "
local v = redis.call('INCR', KEYS[1])
if v > tonumber(ARGV[1]) then
    redis.call('DECR', KEYS[1])
    return -1
end
return v
" 1 inventory:item:A100 100
# If current value > 100, decrement and return -1 (rejected); else return new value

# 8) Atomic LPUSH + LTRIM (bounded list)
EVAL "
redis.call('LPUSH', KEYS[1], ARGV[1])
redis.call('LTRIM', KEYS[1], 0, tonumber(ARGV[2]) - 1)
return redis.call('LLEN', KEYS[1])
" 1 feed:user:42 'new event data' 100
# Keeps only the most recent 100 entries

# 9) Distributed lock (Redlock-style basics)
# Acquire
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
    return 0
end
" 1 lock:resource unique-token-abc123 30000

# Release (only if we still hold it)
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end
" 1 lock:resource unique-token-abc123

# 10) Cache stampede protection — early refresh
EVAL "
local v = redis.call('GET', KEYS[1])
local ttl = redis.call('TTL', KEYS[1])
if not v or ttl < 30 then
    redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]))
    return ARGV[1]
end
return v
" 1 cache:slow_query 'new fresh value' 300
# If cached value is close to expiring, refresh it; otherwise return existing

# 11) Move with dedup — atomic LPOP + check for duplicate
EVAL "
local item = redis.call('RPOPLPUSH', KEYS[1], KEYS[2])
if item and redis.call('SISMEMBER', KEYS[3], item) == 1 then
    redis.call('LREM', KEYS[2], 1, item)
    return nil
end
redis.call('SADD', KEYS[3], item)
return item
" 3 queue:in queue:processing seen:items

# 12) EVALSHA — cache the script
# Load script once, get SHA1 back
SCRIPT LOAD "return redis.call('INCR', KEYS[1])"
# Returns SHA1 like 'e0e1f9fabfc9d4800c877a703b823ac0578ff831'

EVALSHA e0e1f9fabfc9d4800c877a703b823ac0578ff831 1 counter

# Saves network bandwidth (don't send script text repeatedly)
# If 'NOSCRIPT' error → re-load script

# 13) Lua API inside Redis scripts
redis.call('GET', KEYS[1])               -- Throws Lua error on Redis error
redis.pcall('GET', KEYS[1])              -- Returns error object
redis.status_reply('OK')                  -- Wrap a status reply
redis.error_reply('My error message')     -- Wrap an error
redis.log(redis.LOG_WARNING, 'msg')       -- Log to Redis log

# Type conversion:
redis.call returns:
    string   → Lua string
    int      → Lua number
    table    → Lua table (1-indexed)
    nil      → false (NOT nil in Lua!)

Lua → Redis:
    nil/false → null reply
    number    → integer
    string    → bulk string
    table     → array

# 14) Lua built-ins available
tostring(), tonumber(), type()
string.format(), string.sub(), string.upper()
table.insert(), table.remove(), table.concat()
math.floor(), math.ceil(), math.random()
ipairs(), pairs()
string.find(), string.match(), string.gmatch()

# NOT available: os, io, network — sandboxed by design

# 15) Functions (Redis 7+) — replacement for EVAL with cleaner deployment
FUNCTION LOAD "#!lua name=mylib
redis.register_function('rate_limit', function(KEYS, ARGV)
    local current = redis.call('INCR', KEYS[1])
    if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
    return current
end)"

FCALL rate_limit 1 mykey 60

# Functions are persisted (replicated, survive restart) — better for production than EVAL.

# 16) Node + ioredis
import Redis from 'ioredis';
const redis = new Redis();

// Define the script
const script = `
local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
`;

// Define a custom command
redis.defineCommand('rateLimit', {
    numberOfKeys: 1,
    lua: script,
});

// Use it
const count = await redis.rateLimit('rate:user:42', 60);

// 17) Python + redis-py
import redis
r = redis.Redis()

rate_limit = r.register_script('''
local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
''')

count = rate_limit(keys=['rate:user:42'], args=[60])

# 18) Performance
#   ✅ Server-side atomicity — no network round-trips
#   ❌ Blocks Redis while running (single-threaded)
#   ❌ Long scripts slow ALL clients
#   ✅ EVALSHA saves bandwidth
#   ✅ Functions (Redis 7+) persist + replicate

# 19) Common bugs
#   • Forgetting that Redis-nil becomes Lua false (NOT nil)
#   • Lua tables 1-indexed, not 0
#   • Long-running script blocks server (max ~5s with lua-time-limit setting)
#   • Modifying KEYS in script that aren't in the KEYS argument → CLUSTER errors
#   • Forgetting to convert ARGV strings to numbers (tonumber)
#   • Script SHA changes if you tweak whitespace → use Functions for stability

# 20) Best practices
#   ✅ Pass ALL keys via KEYS, all values via ARGV (cluster safety)
#   ✅ Keep scripts SHORT — they block the server
#   ✅ Use EVALSHA for repeated scripts (less bandwidth)
#   ✅ Migrate to Functions (Redis 7+) for production logic
#   ✅ Test scripts with edge cases — Lua nil semantics surprise people
#   ✅ Use redis.pcall for error handling within scripts
#   ✅ For complex multi-key transactions, consider MULTI/EXEC instead of Lua

Why it matters

Lua scripts make multi-step Redis ops atomic. Pass ALL keys via KEYS for cluster safety; keep them short (they block the server); use Redis Functions (7+) for stable, persistent server-side logic.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
EVAL "redis.call('SET', KEYS[1], ARGV[1]); return redis.call('GET', KEYS[1])" 1 myKey "42"
Try it Yourself »

Discussion

Loading…