The Atomicity Problem
MULTI/EXEC gives you transactions, but with caveats:
> MULTI
> SET user:1:balance 100
> GET user:1:balance
> EXEC
If another client writes between GET and EXEC, your transaction doesn't "abort" — you just get the old value. This is Watch-Exec semantics, not true ACID.
Lua scripts are different: they run atomically at the Redis server level, with zero interleaving.
Hello, Lua
A Lua script that increments a key and returns its new value:
-- KEYS[1] is the key name
-- ARGV[1] is the increment amount
local current = redis.call('GET', KEYS[1])
local new = tonumber(current) + tonumber(ARGV[1])
redis.call('SET', KEYS[1], new)
return new
From Node.js (using ioredis):
const result = await redis.eval(script, 1, 'counter', 5);
// args: script, numKeys, key1, arg1
// returns: new value after +5
Real-World: Distributed Lock
A safe lock acquire with TTL:
-- KEYS[1] = lock name
-- ARGV[1] = random token
-- ARGV[2] = TTL in milliseconds
if redis.call('EXISTS', KEYS[1]) == 0 then
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return 1 -- lock acquired
else
return 0 -- lock exists
end
Release with token check:
-- KEYS[1] = lock name
-- ARGV[1] = random token
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1 -- lock released
else
return 0 -- token mismatch (not our lock)
end
The token prevents you from deleting a lock acquired by another client.
Limits
- Scripts have 5-second timeout by default (configurable)
- Can't do blocking operations (BLPOP, etc.)
- Lua 5.1 (older dialect, no table.unpack)
- No access to time; use Redis CLOCK command
For most use cases, these limits don't matter. Script execution is nanoseconds.