Command Palette

Search for a command to run...

Hectal
PHASE 3Intermediate ~17 min· topic 3 of 5

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.

0/5 · 0%

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

  1. 01

    EVAL script numkeys key1 ... arg1 ...: keys arrive as KEYS[1..n], other arguments as ARGV[1..m] (always strings, so convert with tonumber). 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.

  2. 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.

  3. 03

    Script cache: SCRIPT LOAD returns a SHA1; EVALSHA sha ... runs it without resending the body. If the cache was flushed (restart, failover, SCRIPT FLUSH), EVALSHA returns NOSCRIPT and the client falls back to EVAL. Most client libraries do this automatically (register_script in redis-py, RedisScript in Spring).

  4. 04

    Scripts block Redis while running. After busy-reply-threshold (default 5,000 ms; older name lua-time-limit), Redis starts answering other clients with BUSY errors. SCRIPT KILL stops a script only if it hasn't written anything; otherwise the only way out is SHUTDOWN NOSAVE. Keep scripts O(small), avoid loops over unbounded data.

  5. 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_RO and EVALSHA_RO (7.0) run read-only scripts, which can go to replicas.

  6. 06

    Security: scripts run with the calling user's ACL permissions, can't access the filesystem or network, and the sandbox restricts globals. Treat EVAL as 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

rate-limit-fixed.lualua

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])}     -- allowed
run-script.redisredis
127.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.
RateLimiter.javajava

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

01

What if Redis is down? Should the API reject everything?

02

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

01

What's the difference between redis.call and redis.pcall?

02

Why are Lua scripts safe to replicate even when they use the current time?

Practice

01

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.

02

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.