Transactions, Lua scripting and atomicity
MULTI and EXEC, WATCH for optimistic locking, EVAL with correct KEYS usage, Redis Functions, and the patterns that need real atomicity.
MULTI, EXEC and WATCH
MULTI
INCR balance:42
DECR balance:7
EXEC
# optimistic locking: abort if the watched key changed
WATCH inventory:sku1
val = GET inventory:sku1
MULTI
DECRBY inventory:sku1 3
EXEC # returns nil if anything watched changed since WATCH
# be honest about what MULTI gives you
MULTI
SET a 1
LPUSH a 2 # wrong type: not reported here
EXEC # error reported here, but other commands still ran- MULTI queues commands and EXEC runs them without interruption, but there is no rollback: if one command fails at runtime the others still take effect.
- Syntax errors are detected at queue time and abort the whole transaction; runtime errors such as WRONGTYPE do not.
- WATCH is compare-and-set: it fails the transaction if a watched key changed. Your client must retry the whole read-modify-write loop on a nil reply.
- A transaction cannot read a value and branch on it - the commands are queued, not executed. That is exactly the gap Lua fills.
💡
Since Redis 7,
MULTI is executed in a single blocking step, so transactions are simpler than the old optimistic model. The commands inside must still be independent: the server does not roll back.Lua scripting
-- rate limit: increment and set a TTL only on the first hit
-- 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])
end
if current > tonumber(ARGV[1]) then
return 0
end
return 1redis-cli --eval ratelimit.lua counter:user:42 , 100 60
# or inline
EVAL "return redis.call('GET', KEYS[1])" 1 mykey
# load once, run by hash: the preferred production pattern
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
EVALSHA <sha1> 1 mykey
SCRIPT EXISTS <sha1>| Rule | Why |
|---|---|
All keys must be in KEYS | Cluster routing and replication need to know the keys statically |
| No wall-clock or random input | Replicas re-run the script; TIME and RANDOMKEY are replaced by deterministic values |
| No blocking loops | The whole server waits; lua-time-limit only makes the script killable with SCRIPT KILL |
| Small scripts | Every call adds latency to every other client |
| Return simple types | Lua numbers become integers; return redis.call(...) preserves the reply |
- A script is atomic: nothing else runs between its commands. That is the real reason to use one, not performance alone.
- Passing a key in
ARGVinstead ofKEYSworks in a standalone instance and breaks immediately in cluster mode. EVALSHAavoids resending the body; onNOSCRIPTyour client must fall back toSCRIPT LOADand retry.- Scripts are cached by SHA and are not part of persistence. After a restart the cache is empty, so a robust client always handles
NOSCRIPT.
Redis Functions and atomic patterns
#!lua name=mylib
redis.register_function('claim_job', function(keys, args)
local job_id = redis.call('LMOVE', keys[1], keys[2], 'LEFT', 'RIGHT')
if not job_id then return false end
redis.call('HSET', keys[3], job_id, args[1])
return job_id
end)FUNCTION LOAD "#!lua name=mylib\n..."
FCALL claim_job 3 queue:pending queue:processing jobs:leases worker-1
FUNCTION LIST
FUNCTION STATS| Pattern | Mechanism | Note |
|---|---|---|
| Compare and set | WATCH + MULTI | Client retries on failure |
| Read then write | EVAL | Single round trip, atomic |
| Immutable deploy | FUNCTION LOAD REPLACE | Library persists with the dataset |
| Conditional delete | Lua comparing before DEL | Solves the lock-release race |
| Idempotent operation | A version or token check inside the script | Safe to retry after a timeout |
-- release a lock only if you still own it: the classic compare-and-delete
-- KEYS[1] = lock key, ARGV[1] = my token
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0Any read-then-write sequence issued as two round trips is a race. If the correctness of your feature depends on the pair being atomic, it must be a script or a function - the network is not part of your transaction.
FAQ
EVAL or Functions?
Functions are the modern option: the library is stored with the dataset, survives restarts, and can be updated as a unit. Use
EVAL for one-off scripts and when your client must work against older servers.Can a Lua script block the server?
Yes, for its entire duration. Keep scripts to a handful of commands, never loop over a large collection, and set
lua-time-limit so a runaway script can be stopped with SCRIPT KILL.Related
Distributed locks and rate limiting Data types and the commands that matter
Last refreshed 2026-09-18.