Pipelining, MULTI/EXEC and Lua: when is a sequence of Redis commands truly atomic? Intermediate
Estimated reading time: 9 min read
The problem
Section titled “The problem”A flash sale API keeps SKU 42’s stock in Redis: stock:{sku42} (units left) and buyers:{sku42} (the set of buyers). 10 units go on sale and 50 requests arrive at once.
The first version of the code: GET the stock, and if there is some left, SET it minus 1, then SADD.
The demo below lets 50 people buy 10 units. Separately, the job that loads 10,000 keys before the sale opens takes about a second, because it sends one command at a time and waits for each reply.
Both problems share one question: what does batching Redis commands buy you: speed, running back to back without interleaving, or conditional atomicity?
What to choose? (summary first)
Section titled “What to choose? (summary first)”| Situation | Choose | Condition |
|---|---|---|
| Many independent commands (loading keys) | Pipeline | Other clients’ commands can interleave |
| Several commands must run back to back, no need to read intermediate results | MULTI/EXEC |
Runtime errors do not roll back |
| Check then write, where the written value depends on the read value | Lua (EVALSHA or FUNCTION) |
Short script; keys declared via KEYS |
| Same as above, logic must live in the app, low contention | WATCH + retry |
Cap retries; switch to Lua under high contention |
A single command is enough (INCR, SET NX) |
One command | Each command is already atomic |
Core concepts
Section titled “Core concepts”| No interleaving by other commands | Condition on a read value | Rollback | |
|---|---|---|---|
| Pipeline | No | No | No |
MULTI/EXEC |
Yes | No | No |
WATCH + MULTI/EXEC |
Yes, aborted if the key changed | In the app, with retries | Aborted before running |
| Lua / Function | Yes | On the server | No |
All four cut round trips (RTT). Atomic here means no other client can slip in midway, not “undo on error”.
How it works
Section titled “How it works”Why does GET then SET oversell even though each Redis command is atomic? Scaled down to 1 unit and 2 clients:
One unit left. A calls GET stock:{sku42}, receives 1, and starts processing in the app.
| Step | stock |
buyers |
|---|---|---|
A and B GET |
1 | empty |
A SET 0, SADD |
0 | user-a |
B SET 0, SADD |
0 | user-a, user-b (oversold) |
| Script A (B waits) | 0 | user-a |
| Script B: reads 0, writes nothing | 0 | user-a |
The gap lies between the two commands, on the app side: a lost update.
Pipeline: fewer RTTs, same gap
Section titled “Pipeline: fewer RTTs, same gap”Loading diagram…
The server still runs each command as a standalone command, so other clients’ commands can interleave. A side benefit per the Redis docs: fewer read()/write() calls.
MULTI/EXEC: queue, then run back to back
Section titled “MULTI/EXEC: queue, then run back to back”MULTI: every subsequent command returnsQUEUEDand does not run yet.EXEC: runs the whole queue in one go, with no other client interleaving.- A syntax error while queueing:
EXECreturnsEXECABORT, and no command runs. - A runtime error (wrong type): only that command fails; the other commands still run.
Commands inside MULTI have no results yet, so you cannot write “if stock > 0 then decrement”. The condition has to live in WATCH or Lua.
WATCH: optimistic locking
Section titled “WATCH: optimistic locking”If a WATCHed key changes before EXEC, EXEC returns nil and no command runs; the app rereads and retries. This is Redis’s form of optimistic locking.
Lua: logic running on the server
Section titled “Lua: logic running on the server”A script receives keys via KEYS and arguments via ARGV, and runs atomically: every other client waits until the script finishes (see Redis event loop).
-- KEYS[1] = stock:{sku42}, KEYS[2] = buyers:{sku42}, ARGV[1] = user idlocal stock = tonumber(redis.call('GET', KEYS[1]) or '0')if stock <= 0 then return 0 endif redis.call('SADD', KEYS[2], ARGV[1]) == 0 then return -1 end -- duplicate purchaseredis.call('DECR', KEYS[1])return 1Scripts do not roll back either: if an error occurs midway, earlier writes remain (tested: SET then a failing INCR, the key still holds x). Check every condition before the first write.
Operational details
Section titled “Operational details”Per redis.io, verified on Redis 7.4.9:
SCRIPT LOAD/EVALSHA: call a script by its SHA1. The script cache is lost on restart, failover, orSCRIPT FLUSH;EVALSHAthen returnsNOSCRIPT No matching script. The client must fall back toEVAL(ioredisdefineCommanddoes this automatically).FUNCTION LOAD(Redis 7.0+): libraries are stored with the data (RDB, AOF) and replicated; call withFCALL.busy-reply-threshold(the Redis 7.0+ name forlua-time-limit;CONFIG GETon either returns5000): past 5 seconds, other clients getBUSY. The script keeps running.- Declaring keys: every key a script uses must go through
KEYS; Cluster relies on this for routing. - Cluster: every key in a
MULTIor a script must be in the same hash slot. The hash tag{sku42}makes Redis hash only the part inside the braces. - Large pipelines: the server buffers replies in memory; split into batches (the docs use 10,000 commands as an example).
Tested on a separate cluster node (Redis 7.4.9):
CLUSTER KEYSLOT stock:sku42 → 7674 CLUSTER KEYSLOT buyers:sku42 → 8214CLUSTER KEYSLOT stock:{sku42} → 6177 CLUSTER KEYSLOT buyers:{sku42} → 6177EVAL ... 2 stock:sku42 buyers:sku42 u1 → CROSSSLOT Keys in request don't hash to the same slotMULTI / DECR stock:sku42 / SADD buyers:sku42 u1 / EXEC → CROSSSLOTReal-world example
Section titled “Real-world example”Demo examples/src/redis-pipelining-lua/demo.ts (ioredis, prefix demo:redis-pipelining:, cleans up when done):
docker compose -f examples/docker-compose.yml up -d redisnpx --prefix examples ts-node examples/src/redis-pipelining-lua/demo.tsReal output (Redis 7.4.9 in Docker Desktop, Node on the same macOS arm64 machine, via 127.0.0.1:6380):
GET/SET from app: stock=9, buyers=50, oversold=40Lua EVALSHA: stock=0, buyers=10, oversold=0, returned 1=10, returned 0=40WATCH + MULTI/EXEC: stock=0, buyers=10, oversold=0, times EXEC returned null (retries)=254Loading 10000 SETs: sequential=992 ms, pipeline=20 ms, x48.9 fasterpipeline: 20000 INCR then GET in the same batch → GET=8236 (expected 20000)multi: 20000 INCR then GET in the same batch → GET=20000 (expected 20000)MULTI per-command results: [0] OK [1] error: ERR value is not an integer or out of range [2] OK after EXEC: a=1, b=2FCALL buy user-1: 1st call=1, 2nd call (duplicate purchase)=-1, stock=9Reading the output:
- GET/SET: all 50 clients read 10 and all write 9. Stock is left at 9, with 50 people who “bought”.
- Timing: three runs gave 992/1059/1064 ms sequential and 16–20 ms pipelined. This is localhost through Docker; with real network RTT, the gap is even larger.
- A pipeline is not atomic: another client runs
SET counter 0midway, and theGETat the end of the pipeline returns 8236 (other runs: 9905, 9132). InsideMULTIit is always 20000. EXECABORT(tested withredis-cli): anINCRBYmissing an argument insideMULTImakesEXECreturnEXECABORT Transaction discarded because of previous errors., and the precedingSETdoes not run.
What the demo leaves out for production: timeouts, an idempotency key for repeated requests, and durably recording the order outside Redis.
Trade-offs
Section titled “Trade-offs”Gains
- A pipeline cuts RTT dozens of times over for independent commands
- Lua/Function: check-then-write in one round trip, no retries
WATCHkeeps the logic in the app and does not block the server
Costs
- No mechanism rolls back a runtime error midway
- Slow Lua blocks every client; logic is split between app and server
WATCHretries explode when many clients contend for one key
Production checklist
Section titled “Production checklist”- Name keys with hash tags (
stock:{sku42}) from the start, even before using Cluster. - Pass every key through
KEYS; never build key names inside Lua. - Use
FUNCTION LOAD, or a client that automatically falls back fromNOSCRIPTtoEVAL. - Keep scripts short and watch
SLOWLOG;busy-reply-thresholdis only a safety net. - Scripts check every condition before the first write.
WATCHhas a retry cap and backoff with jitter.- Split large pipelines into batches and check each reply for errors.
Common mistakes
Section titled “Common mistakes”The most common one: treating MULTI like SQL’s BEGIN.
Reading stock, then decrementing inside MULTI
Common
const stock = Number(await redis.get(STOCK)); // outside the transactionif (stock > 0) await redis.multi().decr(STOCK).sadd(BUYERS, user).exec();Prefer
await redis.evalsha(buySha, 2, STOCK, BUYERS, user); // read, check, write on the serverWhy
MULTI only keeps DECR and SADD running back to back. The gap between GET and MULTI is still there.
The second trap is in error handling.
| Misconception | Consequence | What to do |
|---|---|---|
| A pipeline is a transaction | Other clients’ commands interleave | MULTI or Lua |
| Redis is single-threaded, so GET-SET is safe | Overselling | Combine read and write in Lua |
EVALSHA always works |
NOSCRIPT after restart/failover |
Fall back to EVAL or use Functions |
Self-check
Section titled “Self-check”- Why can the
GETat the end of a pipeline of 20,000INCRs return less than 20,000, but not insideMULTI? - A
MULTIcontainsSET a, anINCRon"abc",SET b: afterEXEC, doaandbexist? What if it is anINCRBYmissing an argument? - Why does
WATCHneed retries while a Lua script does not? - Why can’t
stock:sku42andbuyers:sku42be used in one script on Cluster?
Summary
Section titled “Summary”References
Section titled “References”- Pipelining: RTT, syscalls, batching.
- Transactions:
EXECABORT, no rollback,WATCH. - Scripting with Lua: atomicity,
KEYS,NOSCRIPT. - Functions:
FUNCTION LOAD, persistence and replication. - Cluster specification: hash slots, hash tags.