MULTI / EXEC
A Redis transaction is a batch of commands queued with MULTI and executed atomically by EXEC. Combine with WATCH for optimistic concurrency, and use Lua scripts when you need conditional logic in a single round trip.
MULTI/EXEC, WATCH, Lua, idempotency
EXAMPLE
// 1) Plain MULTI / EXEC — atomic batch, no rollback
> MULTI
OK
> INCR likes:post:42
QUEUED
> ZADD trending NX 1 post:42
QUEUED
> EXPIRE trending 3600
QUEUED
> EXEC
1) (integer) 17
2) (integer) 1
3) (integer) 1
// All commands are queued, then run in order with no interleaving.
// A bad command syntax aborts BEFORE EXEC. A runtime error on ONE command
// (e.g. type mismatch) does NOT roll back the others — Redis has no rollback.
// 2) Discard a queued batch
> MULTI
> SET foo bar
> DISCARD
// 3) Optimistic concurrency with WATCH
// Classic check-and-set: increment a balance only if it hasn't moved.
> WATCH balance:user:1
> GET balance:user:1
"100"
> MULTI
> DECRBY balance:user:1 30
> EXEC
// If any other client modifies balance:user:1 between WATCH and EXEC,
// EXEC returns (nil) and nothing happens — your code retries.
// 4) WATCH retry loop in Node (ioredis)
import Redis from 'ioredis';
const redis = new Redis();
async function withdraw(userId, cents, max = 5) {
const key = `balance:user:${userId}`;
for (let attempt = 0; attempt < max; attempt++) {
await redis.watch(key);
const bal = parseInt(await redis.get(key) ?? '0', 10);
if (bal < cents) {
await redis.unwatch();
throw new Error('insufficient');
}
const res = await redis.multi()
.decrby(key, cents)
.lpush(`history:user:${userId}`, JSON.stringify({ at: Date.now(), cents }))
.ltrim(`history:user:${userId}`, 0, 99)
.exec();
if (res !== null) return true; // success — array of [err, val]
// res === null means WATCH fired — retry
}
throw new Error('contention — try again later');
}
// 5) Lua script — atomic AND conditional in one round trip
const LUA_WITHDRAW = `
local bal = tonumber(redis.call('GET', KEYS[1]) or '0')
local amt = tonumber(ARGV[1])
if bal < amt then
return { -1, bal }
end
redis.call('DECRBY', KEYS[1], amt)
redis.call('LPUSH', KEYS[2], ARGV[2])
redis.call('LTRIM', KEYS[2], 0, 99)
return { 0, bal - amt }
`;
const sha = await redis.scriptLoad(LUA_WITHDRAW);
const [code, newBal] = await redis.evalsha(
sha, 2,
`balance:user:1`, `history:user:1`,
String(30),
JSON.stringify({ at: Date.now(), cents: 30 }),
);
if (code === -1) throw new Error('insufficient');
console.log('new balance =', newBal);
// Why Lua over WATCH/MULTI:
// • Single round trip (one TCP exchange instead of WATCH + GET + MULTI + EXEC)
// • No retry loop — the script holds the slot atomically
// • Cleaner expression of guards and effects together
// 6) Idempotency — make commands safe to retry
// Pair a request with a key:
const key = `idem:order:${idempotencyKey}`;
const ok = await redis.set(key, '1', 'EX', 86400, 'NX');
if (!ok) return cached(idempotencyKey); // duplicate — return prior result
await actuallyCreateOrder();
// 7) Pipelines vs transactions
// Pipeline: many commands in one round trip, NO atomicity (interleaving with other clients possible).
// Transaction (MULTI/EXEC): atomic, isolated. Slower than pipeline because it queues.
const pipe = redis.pipeline();
for (let i = 0; i < 100; i++) pipe.incr(`ctr:${i}`);
await pipe.exec(); // 100 commands, no isolation
// 8) Distributed locks — Redlock or simple SET NX EX
const token = crypto.randomUUID();
const gained = await redis.set('lock:job:42', token, 'EX', 30, 'NX');
if (!gained) return; // someone else has the lock
try {
// ... protected work ...
} finally {
// Release only if token matches — Lua to make it atomic
const RELEASE = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else return 0 end`;
await redis.eval(RELEASE, 1, 'lock:job:42', token);
}
// Note: simple locks are NOT safe under failover. Use Redlock library + odd # of nodes
// or accept the failure mode and design for it.
// 9) Cluster mode — keys in one transaction must share a slot
// Use hash tags: {user:1}:balance and {user:1}:history hash to the same slot.
// Without tags, MULTI/EXEC across keys can land on different nodes and fail.
// 10) Transactions in Redis Streams / Pub/Sub
// Transactions QUEUE the commands but PubSub PUBLISH and SUBSCRIBE notifications
// happen in normal order; subscribers may see published messages BEFORE other clients
// observe the side-effects of the transaction. Don't rely on ordering across these.
// 11) Performance + observability
// CLIENT LIST shows clients in MULTI state.
// MONITOR — debug-only — shows every command, including QUEUED.
// SLOWLOG GET 10 — catch long-running EXEC and Lua scripts.
// 12) Common bugs
// • Expecting rollback on a runtime error — Redis transactions don't roll back
// • Forgetting to retry after EXEC returns nil with WATCH — silently does nothing
// • WATCH after MULTI — WATCH must be BEFORE MULTI
// • Long-running Lua → blocks the whole Redis instance (single-threaded)
// • Multi-key transaction in cluster without hash tags → CROSSSLOT error
// • Using MULTI/EXEC for read-only batches — pipelines are simpler and faster
Why it matters
Use MULTI/EXEC for atomic batches, layer in WATCH for compare-and-swap, and reach for Lua when you need conditional logic in one round trip. Redis has no rollback, so design every transaction so partial application is either safe or impossible — idempotency keys and tokenised locks beat ad-hoc retries every time.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
MULTI
INCR counter
LPUSH log "+1"
EXEC # atomic
# Optimistic locking
WATCH counter
MULTI; INCR counter; EXEC
Try it Yourself »
Exercise
Open a transaction.
Five letters.
Discussion
Loading…