Redis data structures and atomicity
Send Redis the change, not the new value: one command is atomic; your read-then-write is not.
Predict first
Before you read: predict
ExamHall's check-in service records which candidates are in room R12. At exam start, 30 candidates check in at once: the service starts all 30 check-in handlers in the same tick, on its one Redis connection. Version 1 stores the room as a JSON string: GET room:R12, parse it, push the candidate's id, then SET room:R12 with the new JSON. Version 2 sends SADD room:R12:present <id>.
After all 30 check-ins, which versions list all 30 candidates?
The 30-second version
Redis runs one command at a time, so each command is atomic. Commands such as INCR, HINCRBY, SADD and ZADD change a value on the server in one step. A client that reads a value, changes it and writes it back sends two commands, and other clients' commands can run between them. MULTI/EXEC or a Lua script keeps several together.
Send Redis the change, not the new value: one command is atomic; your read-then-write is not.
How it works
Step 1 of 11
Analogy: A grain cooperative's weighbridge
The plate takes one lorry at a time: each load is weighed and booked in full before the next lorry drives on. The office keeps a tonnage tally, a card per farm, the lorry queue, the member register, a quality ranking board and a numbered log that inspectors initial.
| At the weighbridge | In Redis |
|---|---|
| a lorry on the plate: 12 t booked to Farm 9's wheat line | HINCRBY, done in one step (step 3) |
| a farmer phones for the tally, adds 12 himself, phones back the total | GET, change, SET: other lorries are booked between the calls (step 4) |
| a convoy booked as one, with no lorry cutting in; a lorry that fails inspection is marked, the rest still booked | MULTI/EXEC (step 8) |
| a standing rule at the plate: "if the store has room, book the lorry; else turn it back" | a Lua script (step 9) |
| a "discard on 1 November" sticker on the whole card | a TTL on the key (step 6) |
| an uninitialled log entry, passed to another inspector | the pending list and XACK (step 10) |
In real terms: Redis applies one command at a time, all of it; logic spanning several commands is atomic only inside MULTI/EXEC or a script.
Breaks down when: a weighmaster would notice a wrong total; Redis stores whatever SET sends. Wrong inference: "the server would notice a lost update." It cannot.
Contrasting case: a ledger on a table that anyone may rub out and rewrite: fine for one person, entries lost with three. That is a JSON string edited by clients.
Examples, run for real
4.1 ExamHall: the dashboard that counted one submission
EXH-704: after ECON-201 closed, the dashboard showed 1 submission, not 200. MR !1031 moved the counters from a JSON string to hash fields.
// before: two commands per submission
const s = JSON.parse(await r.get('exam:ECON-201:stats')); s.submitted += 1;
await r.set('exam:ECON-201:stats', JSON.stringify(s));
// after MR !1031: one command per submission
await r.hIncrBy('exam:ECON-201:stats', 'submitted', 1);
redis 7.4.9; 200 submissions at once on one connection
before JSON string, GET + SET: {"submitted":1,"flagged":3}
after hash, HINCRBY: submitted=200 flagged=3 · replies 1..200 each once: true
one field read: HGET submitted -> 200 · TYPE -> hash
All 200 GETs ran before the first SET, so every handler wrote 0 + 1 (step 4). Each HINCRBY replied a different count, 1 to 200 (steps 2–3).
4.2 ExamHall: the proctor flag queue
Flags are scored severity × 100000 − seconds into the exam: most severe first, then earliest. Two consoles claim with ZPOPMAX at once.
redis 7.4.9
ZRANGE 0 2 REV (top 3): f3 phone seen | f2 second face | f6 copy-paste
ZCARD 6 · rank of "f4 noise" from the top: 4
three rounds, both consoles at once: console A claimed 3, console B 3 · distinct flags 6 of 6 · left in queue 0
The server keeps the ranking (step 5); ZPOPMAX reads and removes in one command, so no flag is claimed twice (step 3). Which console wins varies, so only counts are shown.
4.3 Proof domain, ticketing: holds and TTLs
Times are offsets from a fixed virtual epoch T0; the last line uses real time.
redis 7.4.9; T0 = 4000000000 (virtual)
SET hold:e42:s12 fan7 NX EXAT T0+600 -> OK · same for fan9 -> null · holder fan7 · EXPIRETIME T0+600
plain SET over it -> EXPIRETIME -1 (-1 = no TTL: SET replaced the value and its TTL)
SET ... KEEPTTL -> value fan8 · EXPIRETIME T0+600
HSET on a hash with a TTL -> EXPIRETIME T0+900 · INCR on a counter with a TTL -> EXPIRETIME T0+900
HEXPIREAT cart:fan7 T0+600 FIELDS 1 s12 -> [1] · HEXPIRETIME s12 T0+600, s13 -1 · key EXPIRETIME -1
a TTL on one set member: SEXPIRE -> ERR unknown command 'SEXPIRE'
after 400 ms: hold:e42:s14 (PX 50) -> null · cart:fan3 (s14 HPEXPIRE 50) -> {"s15":"hold"}
SET … NX takes a seat and its TTL in one command. A plain SET drops the TTL; KEEPTTL, HSET and INCR keep it (step 7). One hash field can expire alone; a set member cannot (step 6).
4.4 Proof domain, ticketing: a promo code in MULTI and in Lua
Code SPRING allows 3 redemptions, one per fan; 12 fans try at once on 4 connections.
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return 'already' end
if redis.call('SCARD', KEYS[2]) >= tonumber(ARGV[2]) then return 'sold out' end
redis.call('SADD', KEYS[2], ARGV[1]); return 'redeemed'
redis 7.4.9
raw MULTI session: MULTI -> OK · SCARD -> QUEUED · SADD -> QUEUED · EXEC -> [0,1]
Lua, 12 fans at once on 4 connections: redeemed 3 · sold out 9 · SCARD 3 · a winner trying again -> already
Inside MULTI, SCARD answers QUEUED; its count arrives with EXEC, after the SADD ran, so code cannot branch on it (step 8). The script reads, checks and writes as one command (step 9).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A presale shows a live board of the top 10 fans by points. There are 1,000 fans; each purchase adds points to one fan, and every page view reads the board. Two goals: the board is right after every purchase, and a page view reads at most 10 entries from Redis.
Which design meets both?
Question 2 of 5
seats:e9 holds 8 and promo:e9 holds the text SPRING. A checkout sends MULTI, DECRBY seats:e9 2, INCR promo:e9, HSET order:o5 status paid, EXEC.
After EXEC, what do seats:e9 and order o5's status hold?
Question 3 of 5
Fan7 cancels the hold hold:e42:s12. The cancel handler, on its own connection, sends WATCH hold:e42:s12 and GET hold:e42:s12; the reply is fan7, so it sends MULTI, DEL hold:e42:s12 and EXEC. After that GET and before EXEC, fan7's hold lapses and fan9 takes s12 with SET hold:e42:s12 fan9 NX.
What happens to seat s12?
Question 4 of 5
A box office keeps seat holds as fields of one hash, holds:e42 (seat → fan). After each HSET it sends EXPIRE holds:e42 600, meaning each hold should lapse 10 minutes after it was placed. Holds arrive at 19:00, 19:06, 19:12, 19:18 and 19:24. At 19:25 the 19:00 hold is still there; at 19:34 all five vanish at once.
Which is the bug?
Question 5 of 5
Card payments wait in Redis for two ticket-issuing workers, which poll at the same moment. A worker can crash after it takes a payment and before it issues the ticket. Goal: no payment is lost, and no payment is issued twice.
Which structure meets the goal?
Go deeper
Pro tips
- Take a lock or a hold and its TTL in one command:
SET key value NX EX 30, neverSETNXthenEXPIRE. A process that dies between the two leaves a key that never expires. When: seat holds, locks, idempotency keys (steps 2, 7). Verify (RAN,tips.mjs, 50 runs, 1 distinct):SETNXwithout itsEXPIRE→TTL -1;SET NX EX 30→OK · TTL 30. - When Redis "gets slow for everyone", read
SLOWLOG GET. It lists the commands that held the single command thread longest. When: latency spikes across unrelated clients (step 11). Verify (RAN,tips.mjs): withslowlog-log-slower-than 0, aKEYS seat:e42:*over 2,000 keys appears in the log (SLOWLOG, KEYS, SCARD, oldest first). Durations are not printed or graded. In production useSCANinstead ofKEYS(redis.io latency guide).
Source
- redis.io, Redis data types: https://redis.io/docs/latest/develop/data-types/ · fetched 2026-09-27. "Redis is a data structure server"; hashes are "collections of field-value pairs"; lists are "sorted by insertion order"; sets are "unordered collections of unique strings" with O(1) add, remove and membership test; sorted sets "maintain order by each string's associated score"; a stream "acts like an append-only log" (steps 1, 5). Valid as of 2026-09-27.
- redis.io, Diagnosing latency issues, "Single threaded nature of Redis": https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/latency/ · fetched 2026-09-27. Redis "can serve a single request in every given moment, so all the requests are served sequentially"; when a request is slow "all the other clients will wait";
KEYSin production is "a VERY common source of latency" (steps 2, 11; tip 2). Valid as of 2026-09-27. - redis.io, Transactions: https://redis.io/docs/latest/develop/using-commands/transactions/ · fetched 2026-09-27. "A request sent by another client will never be served in the middle of the execution of a Redis Transaction"; queued commands reply
QUEUED; errors afterEXEC"are not handled in a special way: all the other commands will be executed even if some command fails"; "Redis does not support rollbacks";WATCHmakesEXECreturn a null reply if a watched key changed; the GET/+1/SET race example. Valid as of 2026-09-27. - redis.io, Scripting with Lua: https://redis.io/docs/latest/develop/programmability/eval-intro/ · fetched 2026-09-27. "Redis guarantees the script's atomic execution. While executing the script, all server activities are blocked during its entire runtime"; scripts "can perform conditional updates across multiple keys". Valid as of 2026-09-27.
- redis.io, EXPIRE: https://redis.io/docs/latest/commands/expire/ · fetched 2026-09-27. The timeout is cleared only by commands that "delete or overwrite the contents of the key, including DEL, SET, GETSET";
INCR,LPUSHandHSET"will leave the timeout untouched"; callingEXPIREon a key with a TTL updates it ("Refreshing expires");EXPIREATwith a past time deletes the key; keys expire passively on access and actively in a periodic sample. Valid as of 2026-09-27. - redis.io, HEXPIRE: https://redis.io/docs/latest/commands/hexpire/ · fetched 2026-09-27. "since": "7.4.0"; sets a TTL "on one or more fields of a given hash key"; fields are deleted when their TTLs expire. Verified against the container:
redis-server --versionis 7.4.9 andHEXPIREATreturns[1]. Valid as of 2026-09-27. - redis.io, XREADGROUP: https://redis.io/docs/latest/commands/xreadgroup/ · fetched 2026-09-27. A delivered message is stored in the group's Pending Entries List, "a list of message IDs delivered but not yet acknowledged", until
XACK; after a crash "our messages will remain in the pending entries list"; other consumers can claim messages of a failed consumer. Valid as of 2026-09-27. - redis.io, Redis Pub/sub: https://redis.io/docs/latest/develop/pubsub/ · fetched 2026-09-27. "Redis' Pub/Sub exhibits at-most-once message delivery semantics"; a message the subscriber cannot handle "is forever lost"; messages are pushed "to all the subscribed clients"; messages in streams support "at-least-once" delivery. Valid as of 2026-09-27.
Related lessons
Checked against the tools and docs as of 2026-09-27.