Transactions

intermediate redis transactions concurrency

Redis transactions are nothing like SQL transactions. In simple language: a Redis “transaction” is just a way to queue up commands and run them back-to-back without anyone else’s commands sneaking in between. That’s it. No rollback. No real isolation levels. No ACID in the textbook sense.

Once you accept that, they’re actually useful.

The four commands

  • MULTI — start queuing commands
  • EXEC — run all queued commands as one atomic batch
  • DISCARD — throw away the queue
  • WATCH — optimistic lock on keys (abort if they change)

How it works

MULTI
SET balance:alice 100
INCRBY balance:bob 50
DECRBY balance:alice 50
EXEC

Between MULTI and EXEC, commands aren’t executed — they’re queued and Redis replies QUEUED. When EXEC fires, Redis runs the whole batch in one shot. No other client can interleave a command in the middle.

What “atomic” actually means here

It means nothing else runs in between. It does NOT mean “if one fails, all roll back”. Watch this:

MULTI
SET foo "bar"
INCR foo          # foo isn't a number — this WILL fail
SET baz "qux"
EXEC
# foo is "bar", baz is "qux", INCR errored but the others ran

There’s no rollback. If a command errors at runtime, the other commands in the batch still executed and stay committed. This is the biggest gotcha and a favorite interview trap.

MULTI/EXEC vs Lua

MULTI / EXEC
• Static command list
• No conditionals/loops
• No rollback on error
• Use WATCH for CAS
Lua (EVAL)
• Full scripting (if/loops)
• Read-then-write logic
• Atomic from outside
• Preferred for complex ops

Optimistic locking with WATCH

WATCH is Redis’s compare-and-swap. We watch one or more keys; if anyone modifies them before we call EXEC, the transaction aborts and returns nil. Then we retry.

Classic example: decrement inventory only if it’s positive.

WATCH inventory:sku-42
val = GET inventory:sku-42
# decide in client code
if val > 0:
  MULTI
  DECR inventory:sku-42
  EXEC   # nil if someone else changed inventory:sku-42 first
else:
  UNWATCH

In code:

async function buy(sku) {
  while (true) {
    await redis.watch(`inventory:${sku}`);
    const stock = parseInt(await redis.get(`inventory:${sku}`));
    if (stock <= 0) {
      await redis.unwatch();
      return false;
    }
    const result = await redis.multi().decr(`inventory:${sku}`).exec();
    if (result !== null) return true; // committed
    // result === null means WATCH fired — retry
  }
}

This is optimistic concurrency: we assume no conflict, detect it cheaply, retry on collision. Works great when contention is low.

Why not just use Lua?

Honestly, for anything beyond a static command list, Lua scripts are usually cleaner than MULTI/WATCH/retry loops. Lua runs atomically too, with full branching and looping. We cover EVAL in the next note. MULTI/EXEC is the older, simpler tool — still fine for grouping a known sequence of writes.

Quick recap

  • Transactions queue commands and run them in a single atomic batch.
  • No rollback. Runtime errors don’t undo prior commands.
  • WATCH gives us optimistic locking — retry loop on conflict.
  • For complex read-then-write logic, prefer Lua.