Transactions
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The# cli/...transcript is one session in document order, replayed by the course harness; the “attempts” total in the script output depends on timing and will differ on your machine.
What is a Redis transaction?
Section titled “What is a Redis transaction?”A transaction is a sequence of commands that are queued and then executed as one uninterrupted block: no other client’s command runs between them once EXEC fires. You open one with MULTI, issue commands (each replies QUEUED), and run them with EXEC or throw them away with DISCARD. The full behaviour is in the Transactions documentation.
127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET balance 100QUEUED127.0.0.1:6379> DECRBY balance 30QUEUED127.0.0.1:6379> GET balanceQUEUED127.0.0.1:6379> EXEC1) OK2) (integer) 703) "70"127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET temp "draft"QUEUED127.0.0.1:6379> DISCARDOK127.0.0.1:6379> EXISTS temp(integer) 0127.0.0.1:6379> SET mystr helloOK127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET k1 aQUEUED127.0.0.1:6379> INCR mystrQUEUED127.0.0.1:6379> SET k2 bQUEUED127.0.0.1:6379> EXEC1) OK2) (error) ERR value is not an integer or out of range3) OK127.0.0.1:6379> GET k1"a"127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET k3 cQUEUED127.0.0.1:6379> SET k4(error) ERR wrong number of arguments for 'set' command127.0.0.1:6379> SET k5 eQUEUED127.0.0.1:6379> EXEC(error) EXECABORT Transaction discarded because of previous errors.127.0.0.1:6379> GET k3(nil)127.0.0.1:6379> EXEC(error) ERR EXEC without MULTI127.0.0.1:6379> MULTIOK127.0.0.1:6379> WATCH stock(error) ERR WATCH inside MULTI is not allowed127.0.0.1:6379> DISCARDOK127.0.0.1:6379> SET stock 5OK127.0.0.1:6379> WATCH stockOK127.0.0.1:6379> MULTIOK127.0.0.1:6379> DECRBY stock 1QUEUED127.0.0.1:6379> EXEC1) (integer) 4127.0.0.1:6379> UNWATCHOKWhat the session shows:
- Inside
MULTIevery command repliesQUEUED; the real results arrive as one list fromEXEC. - A runtime error does not roll back.
INCR mystrfailed (the value is not a number) butk1andk2were still written: Redis has no rollback, by design. Check every element of theEXECreply. - A queue-time error aborts everything. A
SETwith a missing argument was rejected immediately, andEXECansweredEXECABORT: nothing ran (GET k3is(nil), althoughSET k3 chad been queued). This is the one case that behaves like an all-or-nothing abort. EXECwithoutMULTIis an error, andWATCHcannot be issued insideMULTI; it must come before it.
MULTI
SET balance 100
DECRBY balance 30
GET balance
EXECMULTI
SET temp "draft"
DISCARD
EXISTS tempOptimistic locking with WATCH
Section titled “Optimistic locking with WATCH”WATCH key marks keys to monitor. If any watched key is modified by anyone (another client, or your own connection outside the transaction) before EXEC, the whole transaction is skipped and EXEC returns (nil). That is a check-and-set (CAS): read, compute, write only if nothing changed, otherwise retry. UNWATCH (and EXEC, and DISCARD) clears the watches. The last lines of the transcript are the uncontended path; the contended path needs two connections:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const redis = new Redis(url);const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// 1) WATCH aborts the transaction when another connection touches the keyconst other = new Redis(url);await redis.set('b3:stock', '5');await redis.watch('b3:stock');const seen = Number(await redis.get('b3:stock'));await other.decr('b3:stock'); // a second client sells one item in betweenconst aborted = await redis.multi().set('b3:stock', String(seen - 1)).exec();console.log('EXEC after another client changed the key ->', aborted, '| stock =', await redis.get('b3:stock'));
// 2) errors: queue-time error aborts everything, runtime error does not roll backawait redis.set('b3:str', 'hello');const runtime = await redis.multi().set('b3:k1', 'a').incr('b3:str').set('b3:k2', 'b').exec();console.log('runtime error, per-command tuples:', JSON.stringify(runtime!.map(([e, r]) => [e ? e.message : null, r])));console.log(' k1 and k2 were written anyway:', await redis.get('b3:k1'), await redis.get('b3:k2'));try { await redis.multi().set('b3:k3', 'c').call('SET', 'b3:k3').exec(); // SET with a missing argument} catch (e) { console.log('queue-time error -> exec() rejects:', (e as Error).message, '| k3 =', await redis.get('b3:k3'));}
// 3) optimistic locking done right: one connection PER attempt, retry on nullasync function buy(): Promise<{ ok: boolean; attempts: number }> { const conn = redis.duplicate(); // WATCH state lives on the connection, so it must not be shared try { for (let attempt = 1; attempt <= 50; attempt++) { await conn.watch('b3:cas'); const stock = Number(await conn.get('b3:cas')); if (stock <= 0) { await conn.unwatch(); return { ok: false, attempts: attempt }; } const res = await conn.multi().set('b3:cas', String(stock - 1)).exec(); if (res) return { ok: true, attempts: attempt }; // null = someone else changed it, try again await sleep(Math.random() * 5); } return { ok: false, attempts: 50 }; } finally { conn.disconnect(); }}await redis.set('b3:cas', '10');const buyers = await Promise.all(Array.from({ length: 20 }, buy));console.log('WATCH + retry, 20 buyers for 10 items: sold', buyers.filter((b) => b.ok).length, '| stock left', await redis.get('b3:cas'), '| total attempts', buyers.reduce((n, b) => n + b.attempts, 0));
// 4) the same code on ONE shared connection oversellsasync function buyShared(): Promise<boolean> { await redis.watch('b3:shared'); const stock = Number(await redis.get('b3:shared')); if (stock <= 0) return false; const res = await redis.multi().set('b3:shared', String(stock - 1)).exec(); return res !== null;}await redis.set('b3:shared', '10');const sold = (await Promise.all(Array.from({ length: 20 }, buyShared))).filter(Boolean).length;console.log('shared connection, 20 buyers for 10 items: sold', sold, '| stock left', await redis.get('b3:shared'));
await redis.del('b3:stock', 'b3:str', 'b3:k1', 'b3:k2', 'b3:k3', 'b3:cas', 'b3:shared');other.disconnect();await redis.quit();Real output (this Mac):
EXEC after another client changed the key -> null | stock = 4runtime error, per-command tuples: [[null,"OK"],["ERR value is not an integer or out of range",null],[null,"OK"]] k1 and k2 were written anyway: a bqueue-time error -> exec() rejects: EXECABORT Transaction discarded because of previous errors. | k3 = nullWATCH + retry, 20 buyers for 10 items: sold 10 | stock left 0 | total attempts 88shared connection, 20 buyers for 10 items: sold 20 | stock left 9What it proves:
- The abort. The other connection decremented
stock(5 to 4) after ourWATCH; ourEXECreturnednulland wrote nothing (the stock stays 4). - The two error kinds in ioredis. A runtime error comes back inside the
exec()result as an[err, result]tuple and the other commands still ran; a queue-time error makesexec()reject withEXECABORT. - A correct CAS loop. 20 buyers competing for 10 items each used their own connection (
redis.duplicate()), retried onnull, and exactly 10 sold with stock ending at 0. The 88 attempts are the price of contention: losers retry. Under heavy contention on one key this gets expensive; then use a Lua script (Lua lesson) or an atomic command likeDECR. - A shared connection breaks it. The same code on one shared connection sold 20 of 10 items and left stock at 9.
WATCHstate belongs to the connection, andEXECclears all watches of that connection: the first buyer’sEXECun-watched the key for everyone else who had read the same stock, so their transactions were never aborted and they all wrotestock - 1from a stale read.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant writes a “safe” stock decrement with optimistic locking:
🐛 Spot the bug
const redis = new Redis(); // one client shared by every request
async function buy(): Promise<boolean> {await redis.watch('stock');const stock = Number(await redis.get('stock'));if (stock <= 0) return false;const res = await redis.multi().set('stock', String(stock - 1)).exec();return res !== null; // null means someone else won; caller may retry}Show the answer
The logic looks like the textbook CAS loop, but WATCH is tied to the connection, and a Node.js Redis client is one connection shared by every concurrent request. Requests interleave their WATCH/GET/EXEC, the first EXEC clears the watch for all of them, and the rest commit a stale stock - 1. In the run above, 20 buyers on one connection sold 20 of 10 items; with a connection per attempt exactly 10 sold. Take a dedicated connection for each attempt and disconnect it (or return it to a pool) afterwards, and add a retry limit:
const conn = redis.duplicate(); // WATCH state lives on this connectiontry { /* watch, get, multi().exec(), retry on null */ } finally { conn.disconnect(); }Mental Model
Section titled “Mental Model”MULTI/EXEC is a sealed envelope handed to the clerk: everything inside is processed together, nobody cuts in. It is not an undo button: if one instruction inside is impossible, the clerk does the rest anyway. WATCH is a note pinned to the counter saying “cancel my envelope if anyone touches this key first”, and the note belongs to you at that window, not to the shop.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
MULTI/EXEC | Queued commands run back-to-back with no interleaving | No rollback for runtime errors; no branching on intermediate results |
WATCH (optimistic CAS) | Detects a race without locking; cheap at low contention | You write the retry loop and hold a connection per attempt; retries pile up under contention |
| Lua script instead | Real branching, atomic, no retries | More code to maintain, blocks the server while running |
Plain atomic command (INCR, DECR, SET NX) | Simplest and fastest | Only works when one command expresses the change |
Common Misconceptions
Section titled “Common Misconceptions”- “
MULTI/EXECrolls back like SQL.” Only queue-time errors discard everything; a runtime error leaves the other commands applied. - “
WATCHis a lock.” It is a check atEXECtime; nothing is blocked, andEXECreturningnullmeans retry. - “One shared client is fine for
WATCH.” Watches belong to the connection; sharing it oversold 20 of 10. - “
EXECreturns an error when a command fails.” It returns a list that contains the error at that position; only queue-time failures reject in ioredis.
Common mistakes
Section titled “Common mistakes”- Expecting rollback — check every element of the
EXECresult. WATCHon a shared connection — oversells (proven).- No retry (or unbounded retry) when
EXECisnull— silently lost updates or a livelock; retry a few times with a small random delay. - Reading the value before
WATCH— the check must come afterWATCH, or the value can change unnoticed. - Using
WATCHwhere a singleDECRwould do.
Real-world example
Redis’s own documentation presents
WATCHas optimistic locking “very similar to a check-and-set (CAS) operation” and tells the client to retry whenEXECfails (Transactions).