Introduction
Lua scripts in Redis enable patterns that are impossible or unsafe to implement with plain commands or MULTI/EXEC. This lesson covers the most common production patterns: conditional updates, counter operations with bounds, and caching with computation.
Key Concepts
- Conditional update — check a value then write, atomically; the classic check-and-set done server-side
- Bounded counter — increment only if below a ceiling; useful for rate limiting
- Atomic cache-aside — check cache, compute if missing, store, return; eliminates the cache stampede
- redis.pcall() — protected call; returns error table instead of raising, letting the script handle errors
Real World Context
A rate limiter needs to: read the current request count, check it against the limit, increment if under limit, and set expiry on first increment — all atomically. A Lua script does this in one EVAL, eliminating race conditions that would exist with separate commands.
Deep Dive
Pattern 1 — Conditional update (update only if value matches expected):
lua-- KEYS[1] = target key, ARGV[1] = expected value, ARGV[2] = new value local current = redis.call('GET', KEYS[1]) if current == ARGV[1] then redis.call('SET', KEYS[1], ARGV[2]) return 1 end return 0
Pattern 2 — Rate limiter with bounded increment:
lua-- KEYS[1] = rate limit key, ARGV[1] = limit, ARGV[2] = window_seconds local count = redis.call('INCR', KEYS[1]) if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end if count > tonumber(ARGV[1]) then return 0 -- rate limited end return 1 -- allowed
Pattern 3 — Error handling with pcall:
lua-- Try to increment; if key holds wrong type, return error gracefully local ok, err = pcall(function() return redis.call('INCR', KEYS[1]) end) -- Note: in Redis Lua, use redis.pcall() directly: local result = redis.pcall('INCR', KEYS[1]) if type(result) == 'table' and result.err then return redis.error_reply('Key has wrong type') end return result
Common Pitfalls
- Treating redis.call() errors as table returns. redis.call() raises a Lua error, not a table. Use redis.pcall() if you want to inspect error details without aborting the script.
- Blocking scripts with long loops. A script that loops over thousands of keys blocks Redis for all other clients. Use SCAN-based patterns in application code instead.
- Not converting types. Redis returns strings; Lua comparisons are type-strict. Always use tonumber() for numeric comparisons.
Best Practices
- Use redis.pcall() for operations that might fail on wrong key types, then return a meaningful error.
- Favor short, single-purpose scripts over long scripts that do multiple unrelated things.
- Document each script with a header comment listing KEYS[n] and ARGV[n] meanings.
Summary
- Conditional update: GET → compare → SET atomically in one EVAL
- Bounded counter: INCR → compare → conditionally EXPIRE, all in one script
- redis.pcall() returns error as table instead of raising, enabling graceful error handling
- Always use tonumber() when comparing Redis string results as numbers
- Keep scripts focused and fast to minimize the time other clients are blocked
Code Examples
lua
-- Rate limiter: KEYS[1]=limit key, ARGV[1]=limit, ARGV[2]=window_seconds
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if count > tonumber(ARGV[1]) then
return 0 -- rate limited
end
return 1 -- allowed