Lua Scripting, MULTI/EXEC & True Atomicity in Redis
MULTI/EXEC provides false atomicity — runtime errors inside a transaction do not abort the batch, silently leaving the dataset partially mutated. Lua scripts provide true all-or-nothing atomicity but block the entire server event loop for their duration. This article deconstructs the MULTI/EXEC partial-failure trap, WATCH optimistic locking, Lua server-side atomicity guarantees, EVALSHA script caching, and the Redis 7.0 FUNCTION LOAD upgrade path.
Lua Scripting, MULTI/EXEC & True Atomicity in Redis
Redis is not a cache you bolt onto a slow database — it is a data structure server with a precisely bounded contract: sub-millisecond latency, in-memory semantics, and optional persistence. Atomicity is the most misunderstood dimension of that contract. Engineers reach for MULTI/EXEC expecting database transaction semantics: if anything fails, the entire batch rolls back. This expectation is wrong. MULTI/EXEC executes a queued sequence of commands and returns a response array — but runtime errors in individual commands do not abort the batch. The preceding commands have already applied. The dataset is in a partially mutated state. There is no rollback, no compensation, and no log entry. This article deconstructs both Redis atomicity mechanisms — MULTI/EXEC and Lua scripts — and explains exactly what guarantee each one makes.
This is Part 6 of the Redis Mastery series. It can be read after Part 4 (Cluster) or Part 5 (Streams).
On-ramp: The Distributed Locking article already contains production-complete Lua patterns: atomic lock release, watchdog lease renewal, and a full sliding-window rate limiter. This article does not repeat those patterns. It explains the execution engine they run on — the guarantees and non-guarantees of the Lua runtime and the MULTI/EXEC transaction model — so you can write new atomic operations with confidence.
1. MULTI/EXEC: The Partial-Failure Trap
1.1 How MULTI/EXEC Works
MULTI begins a transaction block. Commands sent after MULTI are not executed immediately — they are queued in the client's transaction buffer. EXEC sends all queued commands to the server in a single network write and receives an array of results.
The server processes both commands atomically in the sense that no other client command interleaves between Command 1 and Command 2. This is the atomicity guarantee MULTI/EXEC provides.
1.2 What MULTI/EXEC Does NOT Provide
MULTI/EXEC provides isolation (no interleaving), not atomicity in the ACID sense (all-or-nothing). The distinction:
1.3 Two Types of Errors in MULTI/EXEC
| Error type | When detected | Effect on EXEC |
|---|---|---|
| Syntax error (wrong arg count, unknown command) | At queue time (before EXEC) | ✅ EXEC is aborted entirely — no commands run |
| Runtime error (WRONGTYPE, out-of-range, etc.) | During EXEC execution | ❌ Preceding commands already applied; EXEC continues |
The most dangerous scenario: a type error on the second or third command in a long transaction. Commands before the error have applied. Commands after the error also apply (EXEC continues past runtime errors). Only the specific failing command is skipped. Engineers who test with consistent data never hit this path — it surfaces months later when data corruption causes a business logic error.
2. WATCH / MULTI / EXEC: Optimistic Locking (CAS)
WATCH implements Compare-and-Swap (CAS) optimistic locking. If any watched key is modified between WATCH and EXEC by any client, EXEC returns null (nil) — aborting the entire transaction without applying any commands.
2.1 The ABA Problem with WATCH
WATCH detects any modification to the watched key — including a modification that sets the key back to the value it had when WATCH was called.
For most use cases this is acceptable — Client A's retry will read the correct current value. For cases where the value identity matters (not just the current value), combine WATCH with a monotonic version counter.
3. Lua Scripts: True All-or-Nothing Atomicity
Lua scripts provide genuine atomic execution. A Lua script runs on the Redis server inside the event loop — no other command executes while the script is running. If the script encounters an error and calls error(), it aborts without applying any preceding redis.call() — true rollback.
3.1 EVAL Syntax and Argument Passing
3.2 redis.call() vs redis.pcall()
| Function | Error handling | When to use |
|---|---|---|
redis.call(cmd, ...) |
Propagates errors to the caller — aborts the script and returns an error reply | Default — fail loudly on unexpected errors |
redis.pcall(cmd, ...) |
Captures errors as return values — script continues executing | When a specific command failure is an expected, handleable condition |
3.3 The Event Loop Blocking Constraint
A Lua script blocks the Redis event loop for its entire duration. While a script runs, no other command — GET, SET, PING, nothing — is processed. All connected clients queue up waiting for the script to complete.
For a Redis instance serving 10,000 requests/second, a 30ms script stalls every request for 30ms — a 300,000ms cumulative client wait for a single script invocation.
Script duration targets:
| Target | Acceptable use case |
|---|---|
| < 1ms | Most atomic operations (CAS, counter increments, conditional sets) |
| 1–5ms | Small loops over bounded key sets |
| 5–50ms | Acceptable maximum — monitor with SLOWLOG |
| > 50ms | lua-time-limit watchdog fires — SCRIPT KILL available |
> lua-time-limit (default 5000ms) |
Server returns BUSY errors to all clients; only SCRIPT KILL or SHUTDOWN NOSAVE unblocks |
4. EVALSHA: Script Caching in Production
EVAL transmits the full script body with every invocation. For scripts of 200+ lines invoked thousands of times per second, this is significant bandwidth overhead. EVALSHA decouples script loading from execution.
5. FUNCTION LOAD: Persistent Server-Side Logic (Redis 7.0+)
SCRIPT LOAD / EVALSHA has a critical limitation: loaded scripts are ephemeral. They are cleared by SCRIPT FLUSH, DEBUG RELOAD, and (in some configurations) server restart. Function Libraries fix this.
5.1 Creating a Function Library
5.2 FUNCTION LOAD vs SCRIPT LOAD
| Concern | SCRIPT LOAD + EVALSHA |
FUNCTION LOAD + FCALL |
|---|---|---|
| Persistence | ❌ Cleared by SCRIPT FLUSH, DEBUG RELOAD | ✅ Persists across SCRIPT FLUSH; included in RDB/AOF |
| Naming | SHA1 hash only — opaque | Named function — human-readable |
| Library scoping | No namespacing | Library → function hierarchy |
| Cluster | Must load on each node individually | Replicates to replicas automatically |
| Redis version | Available since Redis 2.6 | Available since Redis 7.0 |
For new server-side logic: use FUNCTION LOAD. It survives restarts (because Function Libraries are included in RDB snapshots and AOF), replicates automatically to replicas, and is named instead of SHA1-addressed. SCRIPT LOAD is appropriate only for compatibility with Redis < 7.0.
6. Cluster Compatibility: Lua and Hash Slots
In a Redis Cluster, Lua scripts and MULTI/EXEC blocks have a critical constraint: all keys accessed must reside in the same hash slot. The server cannot proxy commands to other nodes mid-script.
All key names passed in KEYS[] to a Lua script must hash to the same slot. Redis validates this at script execution time in cluster mode. Design your key schemas with hash tags before writing multi-key Lua scripts — retrofitting hash tags requires renaming every affected key.
Summary
| Concept | Rule |
|---|---|
| MULTI/EXEC atomicity | Provides isolation (no interleaving), not rollback. Runtime errors partially apply the batch — preceding commands are committed. Always check per-command result pairs in the EXEC response array. |
| Syntax vs runtime errors | Syntax errors in queued commands abort EXEC entirely. Runtime errors (WRONGTYPE, out-of-range) do not — preceding commands already applied. |
| WATCH CAS | EXEC returns null if any watched key is modified between WATCH and EXEC. Retry with fresh reads. Handles ABA: any modification triggers abort, even if value is restored. |
| Lua atomicity | True all-or-nothing — no other command runs during a script; redis.call() errors abort the script without applying preceding writes. |
| Lua event loop cost | A script blocks all clients for its duration. Target < 5ms. Monitor with SLOWLOG. Scripts with writes cannot be killed — only server shutdown unblocks them. |
| EVALSHA in production | Load once at startup with SCRIPT LOAD; execute by SHA1 with EVALSHA. Handle NOSCRIPT errors by reloading. |
| FUNCTION LOAD (Redis 7.0+) | Persistent, named, cluster-replicating alternative to SCRIPT LOAD. Use for all new server-side logic. |
| Cluster slot constraint | All keys in a Lua script or MULTI/EXEC must hash to the same slot. Use hash tags ({tag}) to enforce co-location. |
What's Next
In Part 7: Production Redis — Observability, Security, Connection Management & the Valkey Decision, we close the series with the operational layer: ACL-based security, TLS hardening,
SLOWLOGandLATENCY HISTORYfor latency forensics, hot/big key detection tools, connection pool sizing, and the 2025 Valkey vs. Redis licensing decision that every team running managed cloud Redis needs to make.
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.