Topic 3.3
Lua Scripting: Atomic Logic on the Server
In one line
EVAL runs a Lua script atomically on the server: read keys, branch, write, return, in one round trip. It's the standard tool for rate limiters, safe unlocks and conditional updates, as long as scripts stay short, declare their keys, and are loaded efficiently with EVALSHA.
Think of it like this
Instead of phoning a warehouse clerk ten times ("how many left?", "OK, reserve one", "now log it"), you fax them a one-page procedure and they execute it start to finish while nobody else interrupts. One call, no interleaving, no misunderstanding in between.
Key ideas
- 01
EVAL script numkeys key1 ... arg1 ...: keys arrive asKEYS[1..n], other arguments asARGV[1..m](always strings, so convert withtonumber). Inside,redis.call()runs a command and raises an error on failure;redis.pcall()returns the error as a value instead. Return values map to RESP: numbers → integers (floats are truncated, so return strings for decimals), tables → arrays,false→ nil. - 02
Declare every key you touch in
KEYS, never build key names inside the script. Redis Cluster routes the script by its keys and requires them all in one slot; undeclared keys break in Cluster and make scripts impossible to analyse. - 03
Script cache:
SCRIPT LOADreturns a SHA1;EVALSHA sha ...runs it without resending the body. If the cache was flushed (restart, failover,SCRIPT FLUSH),EVALSHAreturnsNOSCRIPTand the client falls back toEVAL. Most client libraries do this automatically (register_scriptin redis-py,RedisScriptin Spring). - 04
Scripts block Redis while running. After
busy-reply-threshold(default 5,000 ms; older namelua-time-limit), Redis starts answering other clients withBUSYerrors.SCRIPT KILLstops a script only if it hasn't written anything; otherwise the only way out isSHUTDOWN NOSAVE. Keep scripts O(small), avoid loops over unbounded data. - 05
Replication: since Redis 5 scripts replicate their effects (the resulting write commands), not the script itself, so non-deterministic code (reading
TIME, random numbers) is safe.EVAL_ROandEVALSHA_RO(7.0) run read-only scripts, which can go to replicas. - 06
Security: scripts run with the calling user's ACL permissions, can't access the filesystem or network, and the sandbox restricts globals. Treat
EVALas a powerful command: restrict it via ACLs for users that don't need it, and keep servers patched (critical Lua-engine vulnerabilities, such as CVE-2025-49844 in 2025, required authenticated access, which ACLs and network isolation limit).
Code & diagrams
Check limit, increment, set expiry and decide, all atomically.
-- KEYS[1] = counter key, ARGV[1] = limit, ARGV[2] = window seconds
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2]) -- first hit starts the window
end
if current > tonumber(ARGV[1]) then
return {0, current, redis.call('TTL', KEYS[1])} -- rejected
end
return {1, current, redis.call('TTL', KEYS[1])} -- allowed127.0.0.1:6379> SCRIPT LOAD "local c = redis.call('INCR', KEYS[1]) if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end if c > tonumber(ARGV[1]) then return 0 end return 1"
"8c3f4e9a1b..."
127.0.0.1:6379> EVALSHA 8c3f4e9a1b... 1 rl:user:42:1727520000 100 60
(integer) 1
127.0.0.1:6379> SCRIPT FLUSH
OK
127.0.0.1:6379> EVALSHA 8c3f4e9a1b... 1 rl:user:42:1727520000 100 60
(error) NOSCRIPT No matching script. Please use EVAL.Spring Data Redis: DefaultRedisScript caches the SHA and falls back to EVAL on NOSCRIPT.
@Component
public class RateLimiter {
private final StringRedisTemplate redis;
private final DefaultRedisScript<List> script = new DefaultRedisScript<>();
public RateLimiter(StringRedisTemplate redis) {
this.redis = redis;
script.setLocation(new ClassPathResource("rate-limit-fixed.lua"));
script.setResultType(List.class);
}
public boolean allow(long userId, int limit, int windowSec) {
long window = Instant.now().getEpochSecond() / windowSec;
String key = "rl:{user:" + userId + "}:" + window;
List<Long> r = redis.execute(script, List.of(key), String.valueOf(limit), String.valueOf(windowSec));
return r.get(0) == 1L;
}
}Interview problem
The problem
Atomic rate limiter
Implement "100 requests per minute per user": check the limit, increment the counter, set the expiry, and return a decision, all atomically. Explain why separate INCR and EXPIRE calls can go wrong, then implement it in Lua.
You're given
- 100 requests/min/user
- Many API servers
- Return remaining quota and reset time
The interviewer follows up
What if Redis is down? Should the API reject everything?
Why must the key be passed in KEYS instead of built inside the script?
When it breaks
A Lua script iterates over a 1M-element set
What you see
Redis is blocked for seconds; after 5 s all other clients get BUSY Redis is busy running a script; health checks fail and a failover may start. If the script already wrote, SCRIPT KILL can't stop it.
Fix & prevent
Keep scripts bounded (process in chunks with a cursor across calls), test with production-sized data, and monitor SLOWLOG for EVAL entries.
App uses EVALSHA only and Redis fails over
What you see
The new primary's script cache is empty; every call returns NOSCRIPT until someone reloads scripts.
Fix & prevent
Use client helpers that fall back to EVAL automatically, or migrate to Functions, which are persisted and replicated.
Explain it without notes
What's the difference between redis.call and redis.pcall?
Why are Lua scripts safe to replicate even when they use the current time?
Practice
Write a Lua script for "set a key to a new value only if its current value equals an expected value" (compare-and-set) and return 1 or 0.
Write a script that decrements a hash field and deletes the field when it reaches 0 (for a cart quantity).
Trade-offs
- ↔
Lua moves logic into Redis: faster and atomic, but harder to test, version and debug than application code.
- ↔
Scripts block the server; keep them O(1) or O(small N), and prefer single commands when one exists.
Done when you can
I can write, load and call a Lua script with KEYS and ARGV.
I can explain the INCR + EXPIRE race and fix it three ways.
I know what BUSY, SCRIPT KILL and NOSCRIPT mean and how to handle them.