iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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.

Test yourself

Q1. A Redis transaction begins with…
Q2. Optimistic-lock by watching keys with…
Q3. Atomic execution of queued commands is…

Discussion

Loading…