Database NotesRohit’s interview study guide
Chapter 25

Redis and Caching

Redis itself — the data structures, expiry and eviction, the caching patterns, rate limits, locks, messaging and persistence — run for real on Redis 8.10, plus where Redis stands after its licence changes.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What Redis is

Added

Redis is an in-memory data structure server. You connect to it over TCP, send it commands like SET, INCR or ZADD, and it answers in microseconds because everything lives in RAM. Every value sits under a key, and the value isn't just a blob: it can be a string, a hash, a list, a set, a sorted set, a stream and more, each with its own commands that run inside the server. Disk is optional — Redis can snapshot or log to disk (see Persistence: RDB and AOF), but the working copy is always in memory.

The mental model: Redis is a counter with one very fast clerk. Clients queue up from many directions, but the clerk handles one request at a time, start to finish, in the order they arrive. Because only one command is ever running, every single command is atomic — INCR can't be interrupted halfway by another client's INCR. That one design decision is why Redis needs no row locks, why it's so predictable, and also why one slow command (a KEYS * over millions of keys, a long Lua script) makes every other client wait.

Why it's fast: data is in memory, commands are simple (most are O(1) or O(log n)), there's no query planner, no locking, and the network layer is a single event loop (epoll on Linux) — the same model as Node.js. Redis 6.0 added optional I/O threads for reading and writing sockets, but commands still execute on the one main thread. In practice the cost you feel is the network round trip, not Redis's work, which is why pipelining matters so much.

Where it's used: caching (the subject of most of this chapter), sessions, rate limiting, counters and leaderboards, queues and event streams, distributed locks, pub/sub fan-out — and, since Redis 8 bundles JSON, search, time series and vector sets, as a small special-purpose database.

redis-cli
> SET ch25:product:1 '{"id":1,"name":"ThinkBook 14","price":899.00}'
OK
> GET ch25:product:1
"{\"id\":1,\"name\":\"ThinkBook 14\",\"price\":899.00}"
> TYPE ch25:product:1
string
> INCR ch25:views:product:1
(integer) 1
> INCR ch25:views:product:1
(integer) 2
> INCRBY ch25:views:product:1 10
(integer) 12
> TYPE ch25:views:product:1
string
> OBJECT ENCODING ch25:views:product:1
"int"
> EXISTS ch25:product:1 ch25:product:2
(integer) 1
> GET ch25:product:2
(nil)
> INCR ch25:product:1
(error) ERR value is not an integer or out of range
What’s happening
  1. SET stores a string under the key ch25:product:1. The value is just bytes to Redis — here a JSON copy of product 1 from the sample schema. Keys are flat strings; the : is only a naming convention that tools display as folders. Every key in this chapter starts with ch25: so it can't collide with anything else on the server.
  2. GET returns the exact bytes back (redis-cli shows them quoted, with the inner quotes escaped). Redis has no idea it's JSON — parsing is the client's job.
  3. INCR on a missing key treats it as 0, adds 1 and returns the new value: 1 → 2, then INCRBY 10 makes it 12. The read, add and write happen inside the server as one step, so 50 clients incrementing at once can't lose an update — the next example proves it.
  4. TYPE still says string for the counter: numbers are strings that happen to parse as integers. OBJECT ENCODING shows the internal trick — Redis stores it as a real 64-bit int, not text.
  5. EXISTS counts how many of the given keys exist (1: only product 1). A missing key reads as (nil), never an error.
  6. INCR on the JSON string fails with ERR value is not an integer or out of range: every command checks the type of the value it's given. That's the "data structure server" idea — the commands understand the value, so you operate on it in place instead of reading it out, changing it in your app and writing it back.
AdvancedPitfall: commands that block every client
AdvancedOne thread means every command is atomic

Fifty app instances each "like" a product 20 times. One version reads the count, adds one in JavaScript and writes it back; the other asks Redis to do the increment.

atomic.mjs
import { createClient } from 'redis';

// 50 separate connections = 50 app instances
const clients = await Promise.all(
  Array.from({ length: 50 }, () => createClient({ url: 'redis://127.0.0.1:6380' }).connect())
);
const [admin] = clients;
await admin.del(['ch25:likes:racy', 'ch25:likes:atomic']);

// Read-modify-write in the app: GET, add 1, SET
async function racyLike(c) {
  const n = Number(await c.get('ch25:likes:racy') ?? 0);
  await c.set('ch25:likes:racy', String(n + 1));
}
// The increment happens inside Redis
async function atomicLike(c) {
  await c.incr('ch25:likes:atomic');
}

// every client likes 20 times; all clients run at the same time
await Promise.all(clients.map(async (c) => { for (let i = 0; i < 20; i++) await racyLike(c); }));
await Promise.all(clients.map(async (c) => { for (let i = 0; i < 20; i++) await atomicLike(c); }));

console.log('expected  ', 50 * 20);
console.log('GET + SET ', await admin.get('ch25:likes:racy'));
console.log('INCR      ', await admin.get('ch25:likes:atomic'));
await Promise.all(clients.map((c) => c.close()));

// expected   1000
// GET + SET  41
// INCR       1000
// (a second run printed 37 and 1000)
What’s happening
  1. Fifty connections stand in for fifty Node processes behind a load balancer. All fifty loops start together, so their commands interleave on the Redis server.
  2. In racyLike, client 1 GETs 0, client 2 GETs 0 before client 1's SET arrives, both compute 1 and both SET 1. Two likes, one increment. With fifty clients racing, almost every update is overwritten: 1,000 likes became 41, and 37 on the next run.
  3. Each Redis command was atomic — the bug is that the sequence GET, compute, SET wasn't. The gap between the read and the write is where other clients slip in. It's the classic lost update, the same one SQL databases prevent with row locks (see Optimistic vs pessimistic locking).
  4. atomicLike sends one INCR. The read-add-write happens inside the single command thread, so no other command can run in the middle. 1,000 likes, exactly 1,000.
  5. The lesson for every Redis design: push the read-modify-write into Redis — a single command (INCR, HINCRBY, SET NX, ZINCRBY), a MULTI block, or a Lua script — instead of doing it in application code.
AdvancedRound trips cost more than the command

Redis can execute a SET in about a microsecond; getting the command to Redis and the reply back takes far longer. Pipelining sends many commands without waiting for each reply:

pipeline.mjs
import { createClient } from 'redis';
const redis = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();
const N = 10_000;

let t = performance.now();
for (let i = 0; i < N; i++) await redis.set(`ch25:seq:${i}`, i);       // one round trip per command
const sequential = performance.now() - t;

t = performance.now();
await Promise.all(Array.from({ length: N }, (_, i) => redis.set(`ch25:pipe:${i}`, i)));  // all written before any reply
const pipelined = performance.now() - t;

console.log(`${N} SETs, awaited one by one: ${sequential.toFixed(0)} ms`);
console.log(`${N} SETs, sent together:      ${pipelined.toFixed(0)} ms`);

for (const prefix of ['ch25:seq:*', 'ch25:pipe:*']) {
  for await (const keys of redis.scanIterator({ MATCH: prefix, COUNT: 1000 })) if (keys.length) await redis.del(keys);
}
await redis.close();

// 10000 SETs, awaited one by one: 809 ms
// 10000 SETs, sent together:      38 ms
// (three runs: 809/38, 763/41, 822/30 ms)
What’s happening
  1. The first loop awaits every SET, so each command waits for the previous reply: 10,000 round trips at roughly 80 µs each (Docker on the same Mac) ≈ 800 ms. Redis was idle most of that time, waiting for the network.
  2. The second loop starts all 10,000 SETs before awaiting any. node-redis writes them onto the one socket back to back (it pipelines commands issued in the same tick automatically), Redis reads them in a batch and writes the replies in a batch: about 20× faster, 30–41 ms.
  3. Order is still guaranteed on one connection: Redis executes the commands in the order they arrived and returns replies in the same order, so the promises resolve correctly.
  4. Pipelining is not atomic — another client's commands can run between yours. When you need "all or nothing, nobody in between", use MULTI/EXEC or a Lua script.
  5. On a real network (0.3–1 ms between availability zones) the gap is bigger still. This is also why a page that makes 40 sequential Redis calls is slow even though Redis is "fast".

The same effect from inside the container, with redis-benchmark (no network hop at all), 200,000 requests from 50 clients:

Terminal
$ docker exec study-db-redis-1 redis-benchmark -t set,get -n 200000 -q
SET: 254129.61 requests per second, p50=0.119 msec
GET: 252525.25 requests per second, p50=0.111 msec
$ docker exec study-db-redis-1 redis-benchmark -t set,get -n 200000 -P 16 -q
SET: 2061855.62 requests per second, p50=0.327 msec
GET: 2325581.25 requests per second, p50=0.255 msec
What’s happening
  1. Without pipelining, one Redis process on a laptop core handled about 250,000 commands a second. The p50 latency (0.11–0.12 ms) is almost all client/socket overhead.
  2. -P 16 sends 16 commands per round trip. Throughput jumps to 2.0–2.3 million commands a second — still one thread, it just spends its time executing instead of waiting.
  3. Per-request latency went up (p50 0.26–0.33 ms) because each reply waits for its batch. Pipelining trades a little latency per command for a lot of throughput.
  4. These are laptop numbers for tiny values. The useful takeaway isn't the absolute figure but the ratio: the network round trip, not Redis, is usually the bottleneck.
AdvancedMULTI/EXEC is not a rollback transaction

MULTI queues commands and EXEC runs them back to back with nothing else in between. What it doesn't do is roll back:

redis-cli
> SET ch25:name "Rohit"
OK
> MULTI
OK
> SET ch25:city Toronto
QUEUED
> INCR ch25:name
QUEUED
> SET ch25:country Canada
QUEUED
> EXEC
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
> MGET ch25:city ch25:name ch25:country
1) "Toronto"
2) "Rohit"
3) "Canada"
What’s happening
  1. After MULTI, every command answers QUEUED — nothing has run yet. The commands are only checked for syntax at this point.
  2. EXEC runs the three commands in order, isolated: no other client's command can run between them.
  3. The second command fails at run time (INCR on a non-number), and Redis carries on: the first and third SETs are applied. MGET shows Toronto and Canada were written.
  4. So MULTI gives isolation (nobody interleaves) but not atomic rollback. A command with a syntax error is caught at queue time and makes EXEC abort the whole block — but type errors like this one only show up when the command runs.
  5. For conditional logic ("only set if the current value is X"), use WATCH + MULTI (optimistic: EXEC returns nil if a watched key changed) or a Lua script, which is what the rest of this chapter does.

#Data structures

Added

The reason to choose Redis over a plain key-value cache is that the values are data structures with their own operations. Instead of reading a JSON array, appending in your app and writing it back, you LPUSH onto a list; instead of sorting scores in your app, you keep them in a sorted set and ask for the top ten. The work happens inside Redis, atomically, without moving the whole value over the network.

Think of it as a toolbox where each drawer has a different container and its own verbs: hash commands start with H, lists with L/R, sets with S, sorted sets with Z, streams with X. Picking the right one is mostly about how you'll read the data back.

TypeThink of it asTypical useKey commands
Stringa byte blob or a numbercached JSON, counters, flags, locksSET, GET, INCR, SET NX EX
Hasha small object of fieldsa product, a session, per-user settingsHSET, HGET, HINCRBY, HGETALL
Lista dequerecent items, simple queuesLPUSH, RPOP, LRANGE, LTRIM, BLMOVE
Setunique members, no ordertags, "who liked this", dedupSADD, SISMEMBER, SINTER
Sorted setunique members ranked by a scoreleaderboards, priority queues, time windowsZADD, ZINCRBY, ZRANGE … REV, ZRANGEBYSCORE
Streaman append-only log with consumer groupsevent feeds, job queuesXADD, XREADGROUP, XACK
Bitmap, HyperLogLog, Geostrings and sorted sets with special commandsdaily active users, unique counts, "near me"SETBIT, PFADD, GEOSEARCH
JSON, time series, vector set (Redis 8)documents, metrics, embeddingsnested documents, monitoring, similarity searchJSON.SET, TS.ADD, VADD, VSIM
redis-cli
# Hash: one product, field by field
> HSET ch25:product:1 name "ThinkBook 14" price 899.00 stock 12
(integer) 3
> HGET ch25:product:1 price
"899.00"
> HINCRBY ch25:product:1 stock -1
(integer) 11
> HGETALL ch25:product:1
1) "name"
2) "ThinkBook 14"
3) "price"
4) "899.00"
5) "stock"
6) "11"
# List: a customer's recently viewed products, newest first, capped at 3
> LPUSH ch25:recent:customer:1 3 7 1 8
(integer) 4
> LTRIM ch25:recent:customer:1 0 2
OK
> LRANGE ch25:recent:customer:1 0 -1
1) "8"
2) "1"
3) "7"
# Set: unique tags
> SADD ch25:tags:product:1 laptop windows 14-inch laptop
(integer) 3
> SMEMBERS ch25:tags:product:1
1) "laptop"
2) "windows"
3) "14-inch"
> SISMEMBER ch25:tags:product:1 laptop
(integer) 1
# Sorted set: best sellers, scored by units sold (from order_items)
> ZADD ch25:bestsellers 2 "ThinkBook 14" 1 "MacBook Air" 1 "Pixel 10" 4 "SQL Performance Explained" 3 "Designing Data-Intensive Applications" 7 "USB-C Charger" 2 "Noise-cancelling Headphones"
(integer) 7
> ZRANGE ch25:bestsellers 0 2 REV WITHSCORES
1) "USB-C Charger"
2) "7"
3) "SQL Performance Explained"
4) "4"
5) "Designing Data-Intensive Applications"
6) "3"
> ZINCRBY ch25:bestsellers 5 "Pixel 10"
"6"
> ZREVRANK ch25:bestsellers "Pixel 10"
(integer) 1
> TYPE ch25:bestsellers
zset
What’s happening
  1. Hash. HSET writes three fields of product 1 in one command and returns how many fields were new (3). HGET reads one field without transferring the rest, and HINCRBY stock -1 decrements one field atomically — 12 → 11 — which is how you'd reserve stock without a read-modify-write race. HGETALL returns field/value pairs as a flat list.
  2. List. LPUSH pushes each argument onto the head in turn (3, then 7, then 1, then 8), so the list reads 8 1 7 3, newest first. LTRIM 0 2 keeps only indexes 0–2, which caps "recently viewed" at three items forever with no cleanup job.
  3. Set. SADD was given laptop twice but returns 3: members are unique, and the second laptop was ignored. SISMEMBER is an O(1) membership check. A set has no order — never rely on the order SMEMBERS prints.
  4. Sorted set. The scores are the units sold per product from the sample schema's order_items (USB-C Charger 7, SQL Performance Explained 4, …). ZRANGE … 0 2 REV WITHSCORES gives the top three, highest first, in O(log n + 3) — Redis keeps the set ordered as you write.
  5. ZINCRBY 5 bumps Pixel 10 from 1 to 6 as new sales arrive, and the ranking updates immediately: ZREVRANK says it's now at rank 1 (0-based, second place). In SQL this would be a GROUP BY and ORDER BY on every page view; here the ranking is maintained incrementally.
  6. TYPE reports zset. Each key holds exactly one type, and using a command for the wrong type returns WRONGTYPE Operation against a key holding the wrong kind of value.
AdvancedNote: deprecated sorted-set range commands
AdvancedSmall values get compact encodings

Redis quietly stores small hashes, sets, sorted sets and lists in a compact, cache-friendly listpack (a flat byte array) and switches to a real hash table or skip list when they grow:

redis-cli
> OBJECT ENCODING ch25:product:1
"listpack"
> MEMORY USAGE ch25:product:1
(integer) 84
> HSET ch25:product:1 description "A 14-inch Windows laptop with a 2.8K display, 16 GB of RAM and a 1 TB SSD."
(integer) 1
> OBJECT ENCODING ch25:product:1
"hashtable"
> MEMORY USAGE ch25:product:1
(integer) 328
> CONFIG GET hash-max-listpack-value
1) "hash-max-listpack-value"
2) "64"
What’s happening
  1. With three short fields the product hash is a listpack using 84 bytes in total — key, overhead and all.
  2. The description is 76 bytes, more than hash-max-listpack-value (64 on this server), so Redis converts the hash to a real hashtable. Memory jumps to 328 bytes: hash tables need buckets and pointers per field.
  3. The conversion is one-way and automatic; you never choose an encoding. On this Redis 8.10 server the hash limits are 512 entries and 64-byte values; a separate SADD ch25:ids 1 2 3 4 5 came back as intset (a sorted array of integers), and the small sorted set and list were listpack too.
  4. This is why "many small hashes" is a classic Redis memory optimisation: a million products stored as small hashes can take a fraction of the memory of a million separate string keys.
AdvancedCounting uniques, days and distances

Three structures exist mostly to save memory or work:

redis-cli
> EVAL "for i = 1, 100000 do redis.call('PFADD', KEYS[1], 'visitor:' .. i); redis.call('SADD', KEYS[2], 'visitor:' .. i) end return 'done'" 2 ch25:uv:hll ch25:uv:set
"done"
> PFCOUNT ch25:uv:hll
(integer) 100479
> SCARD ch25:uv:set
(integer) 100000
> MEMORY USAGE ch25:uv:hll
(integer) 14366
> MEMORY USAGE ch25:uv:set
(integer) 4661845
> SETBIT ch25:active:2025-03-15 1 1
(integer) 0
> SETBIT ch25:active:2025-03-15 5 1
(integer) 0
> SETBIT ch25:active:2025-03-16 1 1
(integer) 0
> BITCOUNT ch25:active:2025-03-15
(integer) 2
> BITOP AND ch25:active:both ch25:active:2025-03-15 ch25:active:2025-03-16
(integer) 1
> BITCOUNT ch25:active:both
(integer) 1
> GEOADD ch25:cities -79.3832 43.6532 Toronto 72.8777 19.0760 Mumbai -0.1276 51.5072 London 55.2708 25.2048 Dubai
(integer) 4
> GEODIST ch25:cities London Toronto km
"5714.1136"
> GEOSEARCH ch25:cities FROMMEMBER Dubai BYRADIUS 2000 km ASC WITHDIST
1) 1) "Dubai"
   2) "0.0000"
2) 1) "Mumbai"
   2) "1935.6356"
What’s happening
  1. The Lua script adds the same 100,000 visitor ids to a HyperLogLog and to a set. The set is exact: SCARD is 100,000, and it costs 4.66 MB. The HyperLogLog says 100,479 — 0.48% off — in 14 KB. Its standard error is 0.81%, and its size never grows past about 12 KB of registers no matter how many items you add. For "unique visitors per page per day" across millions of pages, that trade is the whole point.
  2. Bitmaps are strings addressed bit by bit. Customer ids 1 and 5 were active on 15 March, customer 1 on 16 March. BITCOUNT counts set bits (2), and BITOP AND intersects days: one customer was active on both. One bit per user means 10 million users fit in 1.25 MB per day.
  3. Geo stores longitude/latitude pairs in a sorted set (the score is a geohash). GEODIST gives London to Toronto as 5,714 km, and GEOSEARCH finds cities within 2,000 km of Dubai, nearest first — Mumbai at 1,936 km.
  4. All three are built on strings or sorted sets, so TYPE reports string or zset; the special commands are what make them useful.
AdvancedDocuments and per-field expiry in Redis 8

Redis 8 ships JSON in the core server (it used to be the separate RedisJSON module in Redis Stack), and Redis 7.4 added expiry on individual hash fields:

redis-cli
> JSON.SET ch25:order:1 $ '{"id":1,"customer":"Rohit","status":"shipped","items":[{"product":"ThinkBook 14","qty":1,"price":899},{"product":"USB-C Charger","qty":2,"price":29}]}'
OK
> JSON.GET ch25:order:1 $.items[*].product
"[\"ThinkBook 14\",\"USB-C Charger\"]"
> JSON.NUMINCRBY ch25:order:1 $.items[1].qty 1
"[3]"
> JSON.GET ch25:order:1 $.items[1]
"[{\"product\":\"USB-C Charger\",\"qty\":3,\"price\":29}]"
> HSET ch25:session:abc user_id 1 csrf x9f2 cart_promo SPRING10
(integer) 3
> HEXPIRE ch25:session:abc 60 FIELDS 1 cart_promo
1) (integer) 1
> HTTL ch25:session:abc FIELDS 2 cart_promo user_id
1) (integer) 60
2) (integer) -1
> HGETEX ch25:session:abc EX 1800 FIELDS 1 user_id
1) "1"
> HTTL ch25:session:abc FIELDS 1 user_id
1) (integer) 1800
> TTL ch25:session:abc
(integer) -1
What’s happening
  1. JSON.SET stores order 1 as a real JSON document, not a string. JSON.GET with the JSONPath $.items[*].product returns just the product names — Redis walks the document so the client doesn't download it all.
  2. JSON.NUMINCRBY changes one number deep inside the document in place: the charger quantity goes 2 → 3. With a plain string you'd have to GET, parse, modify, stringify and SET — the lost-update race from the first topic.
  3. HEXPIRE … FIELDS 1 cart_promo (Redis 7.4) gives one hash field its own 60-second TTL; HTTL shows 60 for the promo and -1 (no expiry) for user_id. When the field expires it disappears from the hash and the rest stays.
  4. HGETEX (Redis 8.0) reads a field and sets that field's expiry in one command — a sliding session timeout on just the fields that need it. The key itself still has no TTL (TTL is -1).
  5. JSON paths in Redis use $-style JSONPath and always return an array (hence "[3]"), because a path can match several places.
Predict the order
redis-cli
> ZADD ch25:q:scores 2 "ThinkBook 14" 2 "Noise-cancelling Headphones" 2 "MacBook Air" 7 "USB-C Charger"
(integer) 4
> ZRANGE ch25:q:scores 0 -1
> ZRANGE ch25:q:scores 0 -1 REV

What do the two ZRANGEs print?

Show answer
Result
> ZRANGE ch25:q:scores 0 -1
1) "MacBook Air"
2) "Noise-cancelling Headphones"
3) "ThinkBook 14"
4) "USB-C Charger"
> ZRANGE ch25:q:scores 0 -1 REV
1) "USB-C Charger"
2) "ThinkBook 14"
3) "Noise-cancelling Headphones"
4) "MacBook Air"
What’s happening
  1. Sorted sets order by score first: the three products with score 2 come before USB-C Charger with 7.
  2. Members with the same score are ordered lexicographically by their bytes: M < N < T, so MacBook Air, Noise-cancelling Headphones, ThinkBook 14.
  3. REV reverses the whole ordering, ties included: highest score first, and among equal scores reverse byte order — ThinkBook 14 before Noise-cancelling Headphones before MacBook Air.
  4. So a leaderboard with ties is deterministic but alphabetical, which is rarely what you want. If ties should go to whoever got there first, fold time into the score (for example score = points × 10¹⁰ + (MAX_TS − ts)) or keep a separate tiebreaker.

#Expiry and eviction

Added

Redis has two different ways for keys to disappear, and mixing them up causes real outages. Expiry is per key and intentional: you say "this key lives 300 seconds" with SET … EX or EXPIRE, and Redis deletes it when the time is up. Eviction is server-wide and a last resort: when memory reaches maxmemory, Redis deletes keys according to the maxmemory-policy to make room, whether or not they had a TTL.

A mental model: expiry is a best-before date on each item in the fridge; eviction is what happens when the fridge is full and you need room for the shopping — you throw something out according to a house rule (oldest-touched first, least-used first, anything with a date on it first, or refuse to buy anything more).

How expiry works under the hood: TTLs are stored as absolute Unix timestamps in milliseconds. A key is removed lazily when someone touches it after its time, and actively by a background cycle that samples keys with TTLs several times a second and deletes the expired ones. So memory from expired keys is reclaimed even if nobody reads them again — usually within a fraction of a second, as measured below.

maxmemory-policyEvictsUse it for
noeviction (default)nothing — writes fail with an OOM errordata you can't lose (queues, sessions, locks)
allkeys-lruleast recently used key, any keya pure cache — the usual choice
allkeys-lfuleast frequently used key (Redis 4.0+)caches with stable hot keys
allkeys-lrmleast recently modified key (Redis 8.6+)read-heavy data where writes signal freshness
allkeys-randoma random keyuniform access patterns
volatile-lru / -lfu / -lrm / -randomsame, but only keys with a TTLone instance holding both cache and durable keys
volatile-ttlthe key with the shortest remaining TTLwhen your TTLs encode priority
redis-cli
> SET ch25:otp:customer:1 482913 EX 3
OK
> TTL ch25:otp:customer:1
(integer) 3
> PTTL ch25:otp:customer:1
(integer) 3000
# (waited 3.2 s)
> GET ch25:otp:customer:1
(nil)
> TTL ch25:otp:customer:1
(integer) -2
> SET ch25:cache:product:1 '{"price":899}'
OK
> TTL ch25:cache:product:1
(integer) -1
> EXPIRE ch25:cache:product:1 300
(integer) 1
> TTL ch25:cache:product:1
(integer) 300
> SET ch25:cache:product:1 '{"price":849}'
OK
> TTL ch25:cache:product:1
(integer) -1
> SET ch25:cache:product:1 '{"price":899}' EX 300
OK
> SET ch25:cache:product:1 '{"price":849}' KEEPTTL
OK
> TTL ch25:cache:product:1
(integer) 300
> PERSIST ch25:cache:product:1
(integer) 1
> TTL ch25:cache:product:1
(integer) -1
What’s happening
  1. SET … EX 3 stores a one-time password and its expiry in one atomic command. TTL answers in seconds (3), PTTL in milliseconds (3000).
  2. After 3.2 seconds the key is gone: GET returns (nil) and TTL returns -2, meaning "no such key". Compare -1, which means "the key exists but has no expiry". Those two sentinel values are a favourite interview detail.
  3. A plain SET makes a key with no TTL (-1); EXPIRE 300 adds one afterwards. Doing it in two commands is fine here, but in a cache fill you'd always use SET … EX so a crash between the two can't leave an immortal key.
  4. The trap: a plain SET on an existing key clears its TTL. The price update turned a 300-second cache entry into one that never expires (-1). Any command that replaces the value (SET, GETSET, the *STORE commands) drops the TTL; commands that modify in place (INCR, LPUSH, HSET, APPEND) keep it.
  5. KEEPTTL (Redis 6.0+) overwrites the value and keeps the remaining TTL — still 300. PERSIST removes the expiry, turning the key back into a permanent one (-1).
AdvancedWatching the active expiry cycle

Fifty thousand keys expire at the same moment and nobody ever reads them again. Does the memory come back?

expire-cycle.mjs
import { createClient } from 'redis';
const redis = await createClient({ url: 'redis://127.0.0.1:6393' }).connect();
await redis.configSet('maxmemory', '0');              // no limit for this test
await redis.flushAll();
await redis.configResetStat();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// 50,000 keys that all expire 100 ms from now — and nobody ever reads them again
await Promise.all(Array.from({ length: 50_000 }, (_, i) => redis.set(`ch25:tmp:${i}`, 'x', { expiration: { type: 'PX', value: 100 } })));
const start = Date.now();
for (const wait of [0, 200, 400, 800, 1600]) {
  await sleep(wait - (Date.now() - start) > 0 ? wait - (Date.now() - start) : 0);
  const expired = (await redis.info('stats')).match(/expired_keys:(\d+)/)[1];
  console.log(`t=${String(Date.now() - start).padStart(4)} ms  DBSIZE ${String(await redis.dbSize()).padStart(5)}  expired_keys ${expired}`);
}
await redis.configSet('maxmemory', '4mb');
await redis.close();

// t=   2 ms  DBSIZE 50000  expired_keys 0
// t= 202 ms  DBSIZE   983  expired_keys 49017
// t= 403 ms  DBSIZE     0  expired_keys 50000
// t= 803 ms  DBSIZE     0  expired_keys 50000
// t=1603 ms  DBSIZE     0  expired_keys 50000
What’s happening
  1. This runs on a separate small Redis container (port 6393) started just for this chapter, so FLUSHALL and CONFIG SET don't disturb anything else. node-redis 6's set takes { expiration: { type: 'PX', value: 100 } }; the older { PX: 100 } form still works but is marked deprecated in its type definitions.
  2. At 2 ms all 50,000 keys exist. DBSIZE counts keys that are logically expired but not yet removed, so it's a count of what's physically in memory.
  3. By 200 ms the active cycle has already deleted 49,017 of them — nobody read a single one, so this is entirely the background sampler. By 400 ms they're all gone and expired_keys (from INFO stats) is 50,000.
  4. The cycle is adaptive: when it finds a high share of expired keys in its sample it keeps going, so a mass expiry is cleaned up in a few hundred milliseconds instead of trickling out. A second run gave 736 left at 201 ms and 0 at 402 ms.
  5. On replicas, keys aren't expired independently: the primary sends a DEL when a key expires, so replicas and the AOF stay consistent. A replica read of a logically expired key still returns nil, though.
AdvancedWhat happens when memory is full: noeviction

The same small container has maxmemory 4mb. With the default policy, Redis refuses writes once it's full:

evict-noeviction.mjs
import { createClient } from 'redis';
const redis = await createClient({ url: 'redis://127.0.0.1:6393' }).connect();  // maxmemory 4mb
await redis.configSet('maxmemory-policy', 'noeviction');
await redis.flushAll();

const value = 'x'.repeat(1000);            // ~1 KB per cached product page
let written = 0;
try {
  for (let i = 1; ; i++) {
    await redis.set(`ch25:page:${i}`, value);
    written = i;
  }
} catch (err) {
  console.log(`after ${written} keys:`, err.message);
}
console.log('GET still works:', (await redis.get('ch25:page:1')).length, 'bytes');
console.log('DEL still works:', await redis.del('ch25:page:1'));
const info = await redis.info('memory');
console.log(info.match(/used_memory_human:.*|maxmemory_human:.*/g).join('  '));
await redis.close();

// after 2441 keys: OOM command not allowed when used memory > 'maxmemory'.
// GET still works: 1000 bytes
// DEL still works: 1
// used_memory_human:4.05M  maxmemory_human:4.00M
What’s happening
  1. An empty Redis 8.10 already uses about 1.4 MB (the server, its bundled modules and buffers), so roughly 2.6 MB was left for data.
  2. Each 1,000-byte value plus key and overhead takes about 1.1 KB, so the 2,442nd SET is the one that fails, with OOM command not allowed when used memory > 'maxmemory'. — the exact text to recognise in logs.
  3. Reads (GET) and deletes (DEL) keep working under noeviction; only commands that could add memory are refused. Your app's writes fail, so a cache-aside app that doesn't catch errors on the cache write starts throwing.
  4. Memory sits slightly above the limit (4.05 MB): Redis checks before running a command, so the last accepted write can overshoot. A single big write (a large SUNIONSTORE, say) can overshoot a lot.
  5. noeviction is right for data you must not silently lose; for a cache, pick an eviction policy so old entries make room for new ones.
AdvancedEviction policies compared, and choosing one

Comparing eviction policies with real hot keys

Fill the 4 MB server with 2,000 pages, read pages 1–200 three times (the hot set), then write 2,000 more pages so about 1,500 keys must be evicted. Which survive?

evict-policies.mjs
import { createClient } from 'redis';
const redis = await createClient({ url: 'redis://127.0.0.1:6393' }).connect();  // maxmemory 4mb
const value = 'x'.repeat(1000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function trial(policy, samples = 5) {
  await redis.flushAll();
  await redis.configSet('maxmemory-policy', policy);
  await redis.configSet('maxmemory-samples', String(samples));
  await redis.configResetStat();
  for (let i = 1; i <= 2000; i++) await redis.set(`ch25:page:${i}`, value);    // fills ~3.4 MB of 4 MB
  await sleep(2000);                                                               // LRU clock ticks once a second
  for (let round = 0; round < 3; round++)                                          // pages 1–200 are "hot"
    for (let i = 1; i <= 200; i++) await redis.get(`ch25:page:${i}`);
  await sleep(2000);
  for (let i = 2001; i <= 4000; i++) await redis.set(`ch25:page:${i}`, value);   // 2,000 new pages force evictions

  const alive = async (from, to) => {
    let n = 0;
    for (let i = from; i <= to; i++) n += await redis.exists(`ch25:page:${i}`);
    return n;
  };
  const evicted = (await redis.info('stats')).match(/evicted_keys:(\d+)/)[1];
  console.log(`${(policy + ' x' + samples).padEnd(17)} evicted ${evicted}  hot ${await alive(1, 200)}/200  cold ${await alive(201, 2000)}/1800  new ${await alive(2001, 4000)}/2000`);
}

for (const policy of ['allkeys-lru', 'allkeys-lfu', 'allkeys-random']) await trial(policy);
await trial('allkeys-lru', 10);   // sample 10 keys per eviction instead of 5
await redis.configSet('maxmemory-samples', '5');

// volatile-* only evicts keys that have a TTL. None here, so it behaves like noeviction.
await redis.flushAll();
await redis.configSet('maxmemory-policy', 'volatile-lru');
try { for (let i = 1; ; i++) await redis.set(`ch25:page:${i}`, value); }
catch (err) { console.log('volatile-lru, no TTLs:', err.message); }
await redis.flushAll();
await redis.close();

// allkeys-lru x5    evicted 1480  hot 143/200  cold 331/1800  new 1999/2000
// allkeys-lfu x5    evicted 1475  hot 200/200  cold 1001/1800  new 1277/2000
// allkeys-random x5 evicted 1477  hot 109/200  cold 981/1800  new 1386/2000
// allkeys-lru x10   evicted 1479  hot 200/200  cold 273/1800  new 2000/2000
// volatile-lru, no TTLs: OOM command not allowed when used memory > 'maxmemory'.
What’s happening
  1. All three policies evicted about 1,480 keys — the same amount of memory had to be freed. What differs is which keys went.
  2. allkeys-random treats every key the same: about half the hot pages were lost (109/200). Fine when access really is uniform, bad for a cache with hot spots.
  3. allkeys-lru with the default 5 samples kept almost all new pages and threw out mostly cold ones, but still lost 57 hot pages (143/200). Redis's LRU is approximate: for each eviction it samples 5 keys and evicts the oldest of those (plus a small pool of good candidates), rather than keeping an exact LRU list. Once most cold keys were gone, samples often held only hot and new keys, and hot ones looked oldest.
  4. maxmemory-samples 10 makes the approximation much closer to true LRU: all 200 hot pages survived and the evictions came almost entirely from cold pages. It costs a little CPU per eviction.
  5. allkeys-lfu kept every hot page (frequency, not recency) but sacrificed 723 new pages — a new key starts with a small frequency counter, so LFU is biased against keys that just arrived. Good for stable popularity, worse when fresh items matter most.
  6. The two sleep(2000) calls matter: the LRU clock has one-second resolution, so without them every key looks equally recent (a first version of this test without them kept only 109–161 hot pages under LRU). And volatile-lru with no TTLs anywhere had nothing it was allowed to evict, so it failed exactly like noeviction.
Does the TTL survive?
redis-cli
> SET ch25:q:session abc EX 100
> APPEND ch25:q:session def
> TTL ch25:q:session
> INCR ch25:q:counter
> EXPIRE ch25:q:counter 100
> SET ch25:q:counter 5
> TTL ch25:q:counter

What do the two TTLs return?

Show answer
Result
> SET ch25:q:session abc EX 100
OK
> APPEND ch25:q:session def
(integer) 6
> TTL ch25:q:session
(integer) 100
> INCR ch25:q:counter
(integer) 1
> EXPIRE ch25:q:counter 100
(integer) 1
> SET ch25:q:counter 5
OK
> TTL ch25:q:counter
(integer) -1
What’s happening
  1. APPEND modifies the existing string in place (abc → abcdef, length 6), so the session keeps its TTL: 100.
  2. The counter gets a 100-second TTL, then SET ch25:q:counter 5 replaces the value. Replacing a value discards the old TTL, so the counter now lives forever: -1.
  3. Rule of thumb: commands that alter a value (APPEND, INCR, HSET, LPUSH, SADD) keep the TTL; commands that overwrite it (SET, GETSET, RENAME onto it, *STORE) reset it. Use SET … KEEPTTL or SET … EX when overwriting.

#Caching patterns: cache-aside, write-through and write-behind

Added

A caching pattern answers two questions: who loads data into the cache, and what happens to the cache when data changes. The Node site covers the application side — cache-aside with a TTL, in-process vs shared caches and the hit-ratio arithmetic (see Cache-aside with a TTL and In-memory vs distributed caches). Here the focus is on what the patterns look like against a real MySQL and a real Redis, and what they cost.

A mental model: the database is the warehouse and Redis is the shop shelf. In cache-aside, the shop assistant checks the shelf, and if it's empty walks to the warehouse and restocks the shelf on the way back. In write-through, every delivery goes to the warehouse and a copy goes straight onto the shelf. In write-behind, deliveries go onto the shelf first and a van takes them to the warehouse in batches later — fast, but if the shop burns down before the van leaves, that stock is gone.

PatternReadsWritesRiskGood for
Cache-aside (lazy loading)app checks cache, on a miss reads DB and fillsapp writes DB, then deletes (or updates) the keystale data for up to the TTL; races on fillthe default for read-heavy data
Read-throughthe cache layer loads from the DB itself—same as cache-aside, logic lives in a library/proxywhen a caching library or proxy owns loading
Write-throughfrom the cache, which is always filledapp writes DB and cache in the same operationslower writes; caching data nobody readsdata that's read right after it's written
Write-behind (write-back)from the cacheapp writes the cache; a background job flushes to the DBdata loss if Redis dies before the flush; DB lagscounters, view counts, metrics — high write rates where losing a little is fine
Refresh-aheadfrom the cachea job reloads hot keys before they expirewasted reloads of keys nobody wantsa few very hot, expensive keys
cache-aside.mjs
import mysql from 'mysql2/promise';
import { createClient } from 'redis';

const db = mysql.createPool({ host: '127.0.0.1', port: 3307, user: 'root', password: 'study', database: 'ch25' });
const redis = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();
const TTL = 300;
const key = (id) => `ch25:cache:product:${id}`;

async function getProduct(id) {
  const cached = await redis.get(key(id));                          // 1. look in the cache
  if (cached !== null) return { source: 'redis', product: JSON.parse(cached) };

  const [rows] = await db.query(                                     // 2. miss: ask the database
    `SELECT p.id, p.name, p.price, p.stock, c.name AS category
       FROM products p JOIN categories c ON c.id = p.category_id
      WHERE p.id = ?`, [id]);
  const product = rows[0] ?? null;
  if (product) await redis.set(key(id), JSON.stringify(product), { expiration: { type: 'EX', value: TTL } });  // 3. fill
  return { source: 'mysql', product };
}

await db.query('SELECT 1');                                          // open the pool's connection first
await redis.del(key(1));
for (let i = 0; i < 3; i++) {
  const t = performance.now();
  const { source, product } = await getProduct(1);
  console.log(`${source.padEnd(5)} ${(performance.now() - t).toFixed(2).padStart(5)} ms  ${JSON.stringify(product)}`);
}
console.log('TTL', await redis.ttl(key(1)));
await redis.close();
await db.end();

// mysql  1.15 ms  {"id":1,"name":"ThinkBook 14","price":"899.00","stock":12,"category":"Laptops"}
// redis  0.19 ms  {"id":1,"name":"ThinkBook 14","price":"899.00","stock":12,"category":"Laptops"}
// redis  0.17 ms  {"id":1,"name":"ThinkBook 14","price":"899.00","stock":12,"category":"Laptops"}
// TTL 300
What’s happening
  1. The first call misses: GET returns null, so the function runs the join against MySQL (ch25 holds the site's sample schema), stores the row as JSON with a 300-second TTL, and returns it. That call took 1.15 ms — two Redis round trips plus the query.
  2. The second and third calls hit: one GET and a JSON.parse, 0.17–0.19 ms. The database never sees them.
  3. price comes back as the string "899.00" because mysql2 returns DECIMAL as strings by default (to avoid floating-point rounding). Whatever shape you cache is what every reader gets, so decide it once.
  4. SET … EX stores the value and its TTL atomically; TTL 300 confirms it. The TTL is the safety net that bounds how stale a cached product can be if an invalidation is ever missed.
  5. Only found rows are cached here. Caching "not found" too (a short-TTL placeholder) protects the database from repeated lookups for ids that don't exist — a common abuse pattern called cache penetration.
AdvancedWhen a cache doesn't pay

The 1.15 ms miss above includes the extra cache round trips. Measured fairly — 2,000 reads each, connections warm — a primary-key lookup in MySQL is barely slower than Redis:

cache-bench.mjs
import mysql from 'mysql2/promise';
import { createClient } from 'redis';
const db = mysql.createPool({ host: '127.0.0.1', port: 3307, user: 'root', password: 'study', database: 'ch25' });
const redis = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();
const sql = `SELECT p.id, p.name, p.price, p.stock, c.name AS category FROM products p JOIN categories c ON c.id = p.category_id WHERE p.id = ?`;
const [rows] = await db.query(sql, [1]);
await redis.set('ch25:cache:product:1', JSON.stringify(rows[0]));
const N = 2000;
let t = performance.now();
for (let i = 0; i < N; i++) await db.query(sql, [1]);
const my = (performance.now() - t) / N;
t = performance.now();
for (let i = 0; i < N; i++) JSON.parse(await redis.get('ch25:cache:product:1'));
const rd = (performance.now() - t) / N;
console.log(`MySQL join by primary key: ${(my * 1000).toFixed(0)} µs per read`);
console.log(`Redis GET + JSON.parse:    ${(rd * 1000).toFixed(0)} µs per read`);
await redis.close(); await db.end();

// MySQL join by primary key: 112 µs per read
// Redis GET + JSON.parse:    84 µs per read
// (second run: 103 µs and 81 µs)
What’s happening
  1. Both loops do one round trip per read on a warm connection, so the comparison is like for like.
  2. MySQL answers an indexed two-table join on primary keys in about 105–112 µs, Redis in about 81–84 µs. The data is tiny and in InnoDB's buffer pool, so MySQL's work is trivial; the round trip dominates both.
  3. So caching a cheap primary-key lookup buys almost nothing on latency — and costs a second system, invalidation logic and stale-data risk. It still pays if the database is the bottleneck under load (every hit is a query MySQL doesn't run), but not because Redis is "10× faster".
  4. Cache what's expensive to compute or popular enough to hurt: aggregations, multi-join pages, rendered fragments, results of calls to slow services.

That is exactly what an aggregate over the 1,000,000-row sales table (generated in ch25 for this chapter from the sample products) looks like:

cache-expensive.mjs
import mysql from 'mysql2/promise';
import { createClient } from 'redis';
const db = mysql.createPool({ host: '127.0.0.1', port: 3307, user: 'root', password: 'study', database: 'ch25' });
const redis = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();

// Revenue per category over 1,000,000 sales rows: the kind of query worth caching
const sql = `SELECT c.name AS category, SUM(s.quantity * s.unit_price) AS revenue
               FROM sales s
               JOIN products p   ON p.id = s.product_id
               JOIN categories c ON c.id = p.category_id
              GROUP BY c.name ORDER BY revenue DESC`;
const KEY = 'ch25:cache:report:revenue-by-category';

async function report() {
  const hit = await redis.get(KEY);
  if (hit) return { from: 'redis', rows: JSON.parse(hit) };
  const [rows] = await db.query(sql);
  await redis.set(KEY, JSON.stringify(rows), { expiration: { type: 'EX', value: 60 } });
  return { from: 'mysql', rows };
}

await db.query('SELECT 1');
await redis.del(KEY);
for (let i = 0; i < 3; i++) {
  const t = performance.now();
  const { from, rows } = await report();
  console.log(`${from.padEnd(5)} ${(performance.now() - t).toFixed(1).padStart(6)} ms`, i === 0 ? rows : '');
}
await redis.close(); await db.end();

// mysql  254.7 ms [
//   { category: 'Laptops', revenue: '824250000.00' },
//   { category: 'Phones', revenue: '711750000.00' },
//   { category: 'Accessories', revenue: '104250000.00' },
//   { category: 'Books', revenue: '33000000.00' }
// ]
// redis    0.3 ms
// redis    0.2 ms
// (second run: 475.2 ms, then 0.4 and 0.3 ms)
What’s happening
  1. The miss scans a million rows and joins them to products and categories: 255–475 ms. A dashboard hit by 50 people a second would keep MySQL busy full-time recomputing the same four numbers.
  2. The hits cost 0.2–0.4 ms — about a thousand times faster — because the four-row answer is already computed.
  3. A 60-second TTL means the report can be up to a minute stale. For a revenue dashboard that's usually fine and should be a product decision, not an accident.
  4. This kind of key is also the classic stampede victim: when it expires under load, every request misses at once. The next topic measures that.
AdvancedWrite-through: update the cache on every write
write-through.mjs
import mysql from 'mysql2/promise';
import { createClient } from 'redis';
const db = mysql.createPool({ host: '127.0.0.1', port: 3307, user: 'root', password: 'study', database: 'ch25' });
const redis = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();
const key = (id) => `ch25:cache:product:${id}`;

// Write-through: every write goes to the database AND the cache, in the same code path
async function updatePrice(id, price) {
  await db.query('UPDATE products SET price = ? WHERE id = ?', [price, id]);          // 1. source of truth first
  const [[row]] = await db.query(
    `SELECT p.id, p.name, p.price, p.stock, c.name AS category
       FROM products p JOIN categories c ON c.id = p.category_id WHERE p.id = ?`, [id]);
  await redis.set(key(id), JSON.stringify(row), { expiration: { type: 'EX', value: 300 } }); // 2. then the cache
  return row;
}

await updatePrice(1, 849.00);
console.log('cache now:', await redis.get(key(1)));
await updatePrice(1, 899.00);                                                         // put the sample data back
await redis.close(); await db.end();

// cache now: {"id":1,"name":"ThinkBook 14","price":"849.00","stock":12,"category":"Laptops"}
What’s happening
  1. The database is written first. If the cache write came first and the database write then failed, the cache would advertise a price that was never saved.
  2. The row is re-read and written to the cache in the same function, so the very next reader gets 849.00 — no miss, no stale window.
  3. The cost: every write now does extra work, and you cache products that may never be read. Write-through is usually combined with a TTL so unread entries age out.
  4. It isn't atomic: if the process dies between the UPDATE and the SET, the cache keeps the old price until the TTL. And two concurrent writers can apply their cache writes in the opposite order from their database writes. The version check in the next topic closes that gap.
AdvancedWrite-behind: absorb writes in Redis, flush in batches

Page-view counts are the textbook case: thousands of increments a second, and losing a few seconds of them in a crash is acceptable. MySQL gets one batched statement instead of one UPDATE per view:

write-behind.mjs
import mysql from 'mysql2/promise';
import { createClient } from 'redis';
const db = mysql.createPool({ host: '127.0.0.1', port: 3307, user: 'root', password: 'study', database: 'ch25' });
const redis = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();
const PENDING = 'ch25:views:pending';

// The hot path: one HINCRBY in Redis per page view, no database write at all
const view = (productId) => redis.hIncrBy(PENDING, String(productId), 1);

// The flusher: every few seconds, move the pending counts to MySQL in one statement
async function flush() {
  const batch = `ch25:views:flushing:${Date.now()}`;
  try {
    await redis.rename(PENDING, batch);              // atomic: new views start a fresh hash
  } catch (err) {
    if (err.message.includes('no such key')) return 0; // nothing viewed since the last flush
    throw err;
  }
  const counts = await redis.hGetAll(batch);          // { '1': '500', '3': '300', ... }
  const rows = Object.entries(counts).map(([id, n]) => [Number(id), Number(n)]);
  await db.query(
    `INSERT INTO product_views (product_id, views) VALUES ? AS new
     ON DUPLICATE KEY UPDATE views = product_views.views + new.views`, [rows]);
  await redis.del(batch);                             // only after MySQL has the numbers
  return rows.length;
}

await db.query('DELETE FROM product_views');
await Promise.all([
  ...Array.from({ length: 500 }, () => view(1)),
  ...Array.from({ length: 300 }, () => view(3)),
  ...Array.from({ length: 200 }, () => view(7)),
]);
console.log('pending in Redis:', await redis.hGetAll(PENDING));
console.log('rows flushed:', await flush());
console.log('second flush:', await flush());
const [rows] = await db.query('SELECT * FROM product_views ORDER BY product_id');
console.log(rows);
await redis.close(); await db.end();

// pending in Redis: { '1': '500', '3': '300', '7': '200' }
// rows flushed: 3
// second flush: 0
// [
//   { product_id: 1, views: 500 },
//   { product_id: 3, views: 300 },
//   { product_id: 7, views: 200 }
// ]
What’s happening
  1. 1,000 page views become 1,000 HINCRBYs on one hash — each atomic, each about as cheap as a GET. MySQL (product_views is a small table created for this example) isn't touched on the hot path.
  2. The flusher's key move is RENAME: in one atomic step the pending hash becomes a batch key, and the next HINCRBY creates a fresh pending hash. No view is counted twice or lost between "read the counts" and "reset them".
  3. The batch is written with one multi-row upsert. VALUES ? AS new … new.views is the MySQL 8.0.19+ row-alias form; the older VALUES(views) function still works but is deprecated.
  4. The batch key is deleted only after MySQL has the rows. The second flush finds nothing to rename (Redis answers ERR no such key) and returns 0.
  5. The failure modes are the price of write-behind: views since the last flush live only in Redis (lose Redis, lose them — persistence decides how many); and a crash after the INSERT but before the DEL re-flushes that batch on restart and double-counts. Real implementations scan for leftover flushing:* keys on startup and make the flush idempotent (store the batch id in MySQL in the same transaction).

#Cache invalidation and stampedes

Added

Two things go wrong with caches in production. Invalidation: the database changed and the cache still serves the old value. Stampedes: a popular key disappears and every request recomputes it at once. The Node site covers the application-side basics — delete-on-write, cache keys, request coalescing inside one process (see Cache invalidation and cache keys and Cache stampedes). This topic reproduces both problems against real MySQL and Redis and fixes them with Redis itself: a version check in Lua, a cross-instance lock, and server-assisted client-side caching.

The mental model for invalidation: the cache is a photocopy of a page in the database. Deleting the copy when the page changes is easy; the hard part is that someone may be walking back from the photocopier with an old copy right now, and will pin it up after you've cleared the board. For stampedes: the cache is a dam. When it opens (the key expires), all the water that was held back hits the database at once.

stale-race.mjs
import mysql from 'mysql2/promise';
import { createClient } from 'redis';
const db = mysql.createPool({ host: '127.0.0.1', port: 3307, user: 'root', password: 'study', database: 'ch25' });
const redis = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();
const KEY = 'ch25:cache:price:1';
let pause;                                   // lets us freeze the reader between its DB read and its cache fill
const gate = new Promise((r) => (pause = r));

async function reader() {                    // request A: a cache-aside read that misses
  const [[row]] = await db.query('SELECT price FROM products WHERE id = 1');
  console.log('A read MySQL      price =', row.price);
  await gate;                                // A is slow here (GC pause, busy event loop, slow network)
  await redis.set(KEY, row.price, { expiration: { type: 'EX', value: 300 } });
  console.log('A filled cache    price =', row.price);
}

async function writer() {                    // request B: a price change that invalidates
  await db.query('UPDATE products SET price = 849.00 WHERE id = 1');
  console.log('B updated MySQL   price = 849.00');
  await redis.del(KEY);
  console.log('B deleted cache key');
}

await redis.del(KEY);
const a = reader();
await new Promise((r) => setTimeout(r, 50)); // let A finish its SELECT
await writer();
pause();                                     // now A wakes up and writes what it read earlier
await a;
console.log('cache now says    price =', await redis.get(KEY), ' TTL', await redis.ttl(KEY));
const [[row]] = await db.query('SELECT price FROM products WHERE id = 1');
console.log('MySQL says        price =', row.price);

await db.query('UPDATE products SET price = 899.00 WHERE id = 1');  // restore the sample data
await redis.del(KEY);
await redis.close(); await db.end();

// A read MySQL      price = 899.00
// B updated MySQL   price = 849.00
// B deleted cache key
// A filled cache    price = 899.00
// cache now says    price = 899.00  TTL 300
// MySQL says        price = 849.00
What’s happening
  1. Request A misses the cache and reads 899.00 from MySQL. Then it stalls before writing the cache — the gate promise makes the stall deterministic; in production it's a GC pause, a slow network or a busy event loop, and it only has to last a few milliseconds.
  2. Meanwhile request B changes the price to 849.00 in MySQL and does the textbook invalidation: DEL the cache key. At this instant the system is correct — the key is gone.
  3. A wakes up and does what cache-aside says: fill the cache with the value it read. But that value is now old. The cache says 899.00 with a fresh 300-second TTL, while MySQL says 849.00.
  4. Nothing will fix it until the TTL runs out — no more writes are coming to trigger another delete. This is why "delete on write" alone isn't enough, and why the TTL is a non-negotiable safety net.
  5. The window is narrow (the read must start before the write and finish after the delete), so it's rare per request — but at thousands of requests a second on hot keys, rare happens daily.
AdvancedPitfall: updating the cache on write
AdvancedFix: refuse to overwrite a newer version

Add a version column to products (ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 1 in ch25), bump it on every write, and only let a cache write succeed if it's newer than what's cached. The check-and-set must be atomic, so it's a Lua script:

stale-fix.mjs
import mysql from 'mysql2/promise';
import { createClient } from 'redis';
const db = mysql.createPool({ host: '127.0.0.1', port: 3307, user: 'root', password: 'study', database: 'ch25' });
const redis = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();
const KEY = 'ch25:cache:price:1';

// Write only if what's cached is older. Runs atomically inside Redis.
const SET_IF_NEWER = `
local cached = redis.call('HGET', KEYS[1], 'version')
if cached and tonumber(cached) >= tonumber(ARGV[1]) then return 0 end
redis.call('HSET', KEYS[1], 'version', ARGV[1], 'price', ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1`;
const fill = (row) => redis.eval(SET_IF_NEWER, { keys: [KEY], arguments: [String(row.version), row.price, '300'] });

let pause; const gate = new Promise((r) => (pause = r));
async function reader() {
  const [[row]] = await db.query('SELECT price, version FROM products WHERE id = 1');
  console.log(`A read MySQL      price = ${row.price} (v${row.version})`);
  await gate;
  console.log(`A tries to fill   v${row.version} → ${await fill(row) ? 'stored' : 'rejected, cache already newer'}`);
}
async function writer() {
  await db.query('UPDATE products SET price = 849.00, version = version + 1 WHERE id = 1');
  const [[row]] = await db.query('SELECT price, version FROM products WHERE id = 1');
  console.log(`B updated MySQL   price = ${row.price} (v${row.version}), cache fill → ${await fill(row) ? 'stored' : 'rejected'}`);
}

await redis.del(KEY);
const a = reader();
await new Promise((r) => setTimeout(r, 50));
await writer();
pause();
await a;
console.log('cache now says   ', await redis.hGetAll(KEY));

await db.query('UPDATE products SET price = 899.00, version = 1 WHERE id = 1');
await redis.del(KEY);
await redis.close(); await db.end();

// A read MySQL      price = 899.00 (v1)
// B updated MySQL   price = 849.00 (v2), cache fill → stored
// A tries to fill   v1 → rejected, cache already newer
// cache now says    { version: '2', price: '849.00' }
What’s happening
  1. The same interleaving as before: A reads version 1 and stalls; B updates MySQL, which bumps the row to version 2.
  2. B writes the new value through to the cache instead of deleting the key. The script finds no cached version, stores v2 and returns 1.
  3. A wakes up and tries to store v1. The script reads the cached version (2), sees 2 >= 1 and returns 0 without writing. The stale fill is refused.
  4. The whole script runs as one command — no other client can change the key between the HGET and the HSET, which is exactly the gap that made the race possible. Doing the same check from Node with two commands would reintroduce the race.
  5. Writing through (rather than deleting) is what makes the version check work: a deleted key has no version to compare against, so the stale fill would win again. Any monotonically increasing value works as the version — a counter column, an updated_at with enough precision, or the binlog position if you invalidate from change data capture.
AdvancedStampede across instances, and more defences

Stampede across instances, measured

Ten app instances, twenty concurrent requests each, all arriving just after the 250 ms revenue report from the previous topic expired. First with plain cache-aside, then with a short lock so only one instance recomputes:

stampede.mjs
import mysql from 'mysql2/promise';
import { createClient } from 'redis';
import { randomUUID } from 'node:crypto';

const db = mysql.createPool({ host: '127.0.0.1', port: 3307, user: 'root', password: 'study', database: 'ch25', connectionLimit: 20 });
const instances = await Promise.all(Array.from({ length: 10 }, () => createClient({ url: 'redis://127.0.0.1:6380' }).connect()));
const KEY = 'ch25:cache:report:revenue-by-category';
const LOCK = `${KEY}:lock`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const SQL = `SELECT c.name AS category, SUM(s.quantity * s.unit_price) AS revenue
               FROM sales s JOIN products p ON p.id = s.product_id JOIN categories c ON c.id = p.category_id
              GROUP BY c.name ORDER BY revenue DESC`;
let dbQueries = 0;
async function compute(redis) {
  dbQueries++;
  const [rows] = await db.query(SQL);
  const json = JSON.stringify(rows);
  await redis.set(KEY, json, { expiration: { type: 'EX', value: 60 } });
  return json;
}

// Plain cache-aside: every miss recomputes
async function naive(redis) {
  return (await redis.get(KEY)) ?? compute(redis);
}

// Miss → try to take a short lock; the winner recomputes, everyone else waits for the value
const RELEASE = `if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end`;
async function withLock(redis) {
  for (;;) {
    const cached = await redis.get(KEY);
    if (cached) return cached;
    const token = randomUUID();
    if (await redis.set(LOCK, token, { condition: 'NX', expiration: { type: 'PX', value: 5000 } })) {
      try { return await compute(redis); }
      finally { await redis.eval(RELEASE, { keys: [LOCK], arguments: [token] }); }
    }
    await sleep(25);                                   // someone else is computing: poll again shortly
  }
}

for (const [name, fn] of [['no protection', naive], ['SET NX lock', withLock]]) {
  await instances[0].del([KEY, LOCK]);
  dbQueries = 0;
  const t = performance.now();
  // 10 instances × 20 concurrent requests arrive just after the key expired
  await Promise.all(instances.flatMap((r) => Array.from({ length: 20 }, () => fn(r))));
  console.log(`${name.padEnd(14)} 200 requests → ${String(dbQueries).padStart(3)} MySQL queries, ${(performance.now() - t).toFixed(0)} ms`);
}
await Promise.all(instances.map((r) => r.close())); await db.end();

// no protection  200 requests → 200 MySQL queries, 55544 ms
// SET NX lock    200 requests →   1 MySQL queries, 562 ms
// (second run: 200 queries in 55983 ms, 1 query in 587 ms)
What’s happening
  1. No protection: all 200 requests see an empty key before any of them has finished computing, so all 200 run the million-row aggregate. MySQL had 20 pool connections running them in parallel, each slowed by the others, and the last request waited 55.5 seconds. A query that takes 250 ms alone took a minute under a stampede — the database the cache was protecting got the full load, slower.
  2. With the lock, each request that misses tries SET lock <random token> NX PX 5000. Redis runs that atomically, so exactly one request across all ten instances gets OK; everyone else gets null.
  3. The winner computes once and fills the cache. The losers sleep 25 ms and look again; within about half a second they find the value. 1 query, 562 ms for all 200 requests.
  4. The lock has an expiry (PX 5000) so a crashed winner can't block the refresh forever, and it's released with a compare-and-delete script so a slow winner can't delete a lock that already expired and was taken by someone else. That's the distributed-lock topic below in miniature.
  5. Polling is the simplest way to wait. Real systems often do better: serve the stale value while one instance refreshes (store a logical "refresh after" time inside the value, with the real TTL longer), so nobody waits at all.
AdvancedClient-side caching with server-assisted invalidation

Redis 6.0 added client tracking: a client can keep its own in-process copy of keys it has read, and Redis sends it an invalidation message when any of those keys change. node-redis 5+ implements it over RESP3:

client-cache.mjs
import { createClient } from 'redis';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Instance A keeps a local copy of what it reads; Redis tells it when a key it read changes
const a = await createClient({ url: 'redis://127.0.0.1:6380', RESP: 3, clientSideCache: { ttl: 0, maxEntries: 1000 } }).connect();
const b = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();   // instance B, a normal client
const KEY = 'ch25:cache:product:1:price';

await b.set(KEY, '899.00');
console.log('A GET →', await a.get(KEY));
console.log('A GET →', await a.get(KEY));
console.log('stats', JSON.stringify(a.clientSideCache.stats()));

await b.set(KEY, '849.00');                 // another instance changes it
await sleep(20);                            // the invalidation push message arrives on A's connection
console.log('A GET →', await a.get(KEY));
console.log('stats', JSON.stringify(a.clientSideCache.stats()));
console.log((await b.sendCommand(['CLIENT', 'LIST'])).split('\n').filter((l) => l.includes('flags=t')).map((l) => l.match(/id=\d+|flags=\S+|resp=\d/g).join(' ')));
await b.del(KEY);
await a.close(); await b.close();

// A GET → 899.00
// A GET → 899.00
// stats {"hitCount":1,"missCount":1,"loadSuccessCount":1,"loadFailureCount":0,"totalLoadTime":0.3896249999999952,"evictionCount":0}
// A GET → 849.00
// stats {"hitCount":1,"missCount":2,"loadSuccessCount":2,"loadFailureCount":0,"totalLoadTime":1.4159170000000074,"evictionCount":0}
// [ 'id=397 flags=t resp=3' ]
What’s happening
  1. Client A connects with RESP: 3 and clientSideCache. node-redis turns on CLIENT TRACKING for the connection; CLIENT LIST shows it with flags=t and resp=3.
  2. A's first GET goes to Redis (a miss in the local cache) and Redis remembers that this connection read this key. The second GET is answered from A's own memory — hitCount: 1 — with no network round trip at all.
  3. Client B, an ordinary connection, sets the price to 849.00. Redis looks up who has read that key and pushes an invalidation message to A over A's connection.
  4. A's library drops its local copy, so A's next GET misses locally, goes to Redis and returns the new price. missCount went 1 → 2.
  5. This gives you the speed of an in-process cache without the stale-copy-per-instance problem — but the invalidation is asynchronous (a few microseconds to milliseconds of staleness), Redis spends memory tracking keys per client, and if the connection drops the client must flush its whole local cache. Use it for hot, read-mostly keys.

#Rate limiting with Redis

Added

Rate limiting in a fleet needs one shared counter per client, updated atomically, that cleans itself up. Redis fits perfectly: INCR is atomic, TTLs remove old windows for free, sorted sets hold timestamps, and Lua scripts make check-and-update one indivisible step. The Node site compares the algorithms in plain JavaScript and wires express-rate-limit to a Redis store (Rate-limiting algorithms); this topic is about the Redis commands underneath and the bugs that hide in them.

The mental model: each client gets a turnstile counter in Redis. A fixed window is a counter that a cleaner resets every minute (the TTL); a sliding log is a guest book of the last minute's arrivals; a token bucket is a jar of tokens that refills at a steady drip and each request takes one.

redis-cli
> MULTI
OK
> INCR ch25:rl:login:customer:1
QUEUED
> EXPIRE ch25:rl:login:customer:1 60 NX
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1
> MULTI
OK
> INCR ch25:rl:login:customer:1
QUEUED
> EXPIRE ch25:rl:login:customer:1 60 NX
QUEUED
> EXEC
1) (integer) 2
2) (integer) 0
# (waited 2 s)
> MULTI
OK
> INCR ch25:rl:login:customer:1
QUEUED
> EXPIRE ch25:rl:login:customer:1 60 NX
QUEUED
> EXEC
1) (integer) 3
2) (integer) 0
> TTL ch25:rl:login:customer:1
(integer) 58
What’s happening
  1. Each login attempt runs INCR and EXPIRE … NX together in a MULTI/EXEC block. The app compares the INCR result with the limit (say 5 per minute) and rejects with HTTP 429 when it's over.
  2. The first attempt creates the counter at 1, and EXPIRE 60 NX sets a 60-second TTL (1 = TTL was set). NX (Redis 7.0+) means "only if the key has no expiry yet".
  3. The second and third attempts increment to 2 and 3; EXPIRE … NX returns 0 because the TTL is already set, so the window doesn't slide forward on every request. TTL 58 shows two seconds of the window have passed.
  4. When the TTL runs out Redis deletes the counter and the next attempt starts a new window at 1. No cron job, no cleanup, and every app instance sees the same count.
  5. This is a fixed window: a client can do 5 attempts at 0:59 and 5 more at 1:01 — 10 in two seconds. If that burst matters, use the sliding log or token bucket below.
AdvancedPitfall: evicted or lost rate-limit counters
AdvancedThe bug: INCR without an atomic EXPIRE

The common hand-written version is "INCR, and if the result is 1, EXPIRE" — two round trips:

redis-cli
> INCR ch25:rl:api:customer:2
(integer) 1
# ...the app process is killed here, before it sends EXPIRE...
> TTL ch25:rl:api:customer:2
(integer) -1
> INCR ch25:rl:api:customer:2
(integer) 2
> EXPIRE ch25:rl:api:customer:2 60 NX
(integer) 1
> TTL ch25:rl:api:customer:2
(integer) 60
What’s happening
  1. INCR creates the counter at 1. The app would now send EXPIRE — but if it crashes, is deployed or loses its connection first, the expiry never arrives.
  2. TTL -1: the counter exists with no expiry. With the if count == 1 then EXPIRE version, nothing will ever set it, because the count is never 1 again. That customer's counter grows forever and they're locked out permanently once they reach the limit.
  3. Sending EXPIRE … NX on every request (not just the first) heals it: the next request sets the missing TTL (1) and the window works again. The MULTI version or a Lua script removes the gap completely.
AdvancedSliding log and token bucket in Lua

Both algorithms need several reads and writes per check, so they run as Lua scripts — atomic, one round trip, and using Redis's clock (TIME) so app servers with slightly different clocks agree:

rate-limits.mjs
import { createClient } from 'redis';
import { randomUUID } from 'node:crypto';
const redis = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Sliding log: one sorted-set member per accepted request, scored by its time in ms
const SLIDING_LOG = `
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
local window, limit = tonumber(ARGV[1]), tonumber(ARGV[2])
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)
local used = redis.call('ZCARD', KEYS[1])
if used < limit then
  redis.call('ZADD', KEYS[1], now, ARGV[3])
  redis.call('PEXPIRE', KEYS[1], window)
  return {1, limit - used - 1}
end
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
return {0, tonumber(oldest[2]) + window - now}`;

// Token bucket: two numbers per client, refilled lazily from the elapsed time
const TOKEN_BUCKET = `
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
local capacity, perMs = tonumber(ARGV[1]), tonumber(ARGV[2])
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens, ts = tonumber(b[1]) or capacity, tonumber(b[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * perMs)
local ok = 0
if tokens >= 1 then tokens = tokens - 1; ok = 1 end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / perMs))
if ok == 1 then return {1, math.floor(tokens)} end
return {0, math.ceil((1 - tokens) / perMs)}`;

const slidingLog = (key) => redis.eval(SLIDING_LOG, { keys: [key], arguments: ['1000', '5', randomUUID()] });   // 5 per 1 s
const tokenBucket = (key) => redis.eval(TOKEN_BUCKET, { keys: [key], arguments: ['5', '0.005'] });             // 5 burst, 5 per s

for (const [name, hit, key] of [['sliding log', slidingLog, 'ch25:rl:log:customer:1'], ['token bucket', tokenBucket, 'ch25:rl:tb:customer:1']]) {
  await redis.del(key);
  const start = Date.now();
  const line = [];
  for (const [at, count] of [[0, 7], [500, 2], [1050, 3]]) {   // a burst of 7, then 2 at 0.5 s, then 3 at 1.05 s
    await sleep(Math.max(0, at - (Date.now() - start)));
    const results = [];
    for (let i = 0; i < count; i++) {
      const [ok, n] = await hit(key);
      results.push(ok ? '✓' : `✗(${n}ms)`);
    }
    line.push(`t=${at}: ${results.join(' ')}`);
  }
  console.log(`${name.padEnd(12)} ${line.join(' | ')}`);
  await redis.del(key);
}
await redis.close();

// sliding log  t=0: ✓ ✓ ✓ ✓ ✓ ✗(998ms) ✗(997ms) | t=500: ✗(499ms) ✗(497ms) | t=1050: ✓ ✓ ✓
// token bucket t=0: ✓ ✓ ✓ ✓ ✓ ✗(198ms) ✗(198ms) | t=500: ✓ ✓ | t=1050: ✓ ✓ ✓
// (a second run printed the same pattern, ±2 ms)
What’s happening
  1. Both limiters allow 5 requests per second; the test sends a burst of 7 at 0 s, 2 more at 0.5 s and 3 at 1.05 s. The ✗(n ms) numbers are the retry-after values each script returns.
  2. Sliding log: the script first drops sorted-set members older than one second (ZREMRANGEBYSCORE), counts what's left (ZCARD) and, if under 5, adds this request with its timestamp as the score. Requests 6 and 7 are rejected; the oldest entry is 2 ms old, so the retry-after is about 998 ms. At 0.5 s all five are still inside the window, so both are rejected; at 1.05 s they've aged out and three are accepted. Exact, but it stores one member per accepted request.
  3. Each log member gets a random UUID because sorted-set members must be unique — two requests in the same millisecond with the member now would collapse into one and under-count.
  4. Token bucket: the bucket starts full (5 tokens), the burst takes all five, and requests 6–7 are told to retry in 198 ms (one token refills every 200 ms). By 0.5 s about 2.5 tokens have dripped back, so both requests pass; by 1.05 s another 2.75 have, so all three pass. It allows bursts up to the capacity and then a steady rate, with only two numbers stored per client.
  5. Both scripts call PEXPIRE so an idle client's key disappears by itself. Scripts that read TIME and then write are fine on Redis 5+ because replicas receive the script's effects (the ZADD/HSET commands), not the script, so they never re-run TIME and disagree.

#Distributed locks

Added

A distributed lock makes sure only one process across many machines does something at a time: send the nightly invoice email once, rebuild one cache key, process one order. Within one process you'd use a mutex; across machines you need a shared place to record "I hold it" — and Redis's SET key value NX PX ttl does that in one atomic command: set it only if nobody else has, and expire it automatically so a crashed holder can't keep it forever.

The mental model is a meeting-room booking with an end time. You write your name on the board if the room is free (NX), the booking expires on its own (PX), and when you leave you only rub out the board if it still has your name on it. The awkward part, and the one interviewers dig into, is that bookings expire on the clock — if your meeting overruns, someone else gets the room while you're still talking.

redis-cli
> SET ch25:lock:order:6 worker-a:7f3c NX PX 10000
OK
> SET ch25:lock:order:6 worker-b:91d2 NX PX 10000
(nil)
> GET ch25:lock:order:6
"worker-a:7f3c"
> PTTL ch25:lock:order:6
(integer) 10000
> DELEX ch25:lock:order:6 IFEQ worker-b:91d2
(integer) 0
> DELEX ch25:lock:order:6 IFEQ worker-a:7f3c
(integer) 1
What’s happening
  1. Worker A takes the lock on order 6 with a unique token as the value and a 10-second lease: OK. NX makes it "set if not exists", atomically — there's no window between checking and setting.
  2. Worker B tries the same and gets (nil): the lock is held. B either retries later or gives up.
  3. The lock's value is A's token, and it will expire on its own in 10,000 ms if A crashes. Without an expiry, a crashed holder would leave the lock held forever.
  4. Releasing must check ownership: delete only if the value is still my token. DELEX key IFEQ value (Redis 8.4+) does that compare-and-delete in one command. B's token doesn't match, so nothing is deleted (0); A's matches (1).
  5. Before Redis 8.4 (and on Valkey) the same release is a small Lua script, used in the stampede example above: if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end. A plain DEL is a bug, as the next example shows.
AdvancedWhy a plain DEL is wrong

Worker A's lease is 200 ms but its work takes 300 ms. The lock expires under it, B acquires it, and then A finishes and "releases":

lock-bug.mjs
import { createClient } from 'redis';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const [a, b, c] = await Promise.all([1, 2, 3].map(() => createClient({ url: 'redis://127.0.0.1:6380' }).connect()));
const LOCK = 'ch25:lock:report';
const holders = new Set();
const log = (msg) => console.log(`${String(Date.now() - t0).padStart(4)} ms  ${msg}  holders=[${[...holders]}]`);
let t0;

async function worker(redis, name, token, workMs, release) {
  const ok = await redis.set(LOCK, token, { condition: 'NX', expiration: { type: 'PX', value: 200 } });
  if (!ok) return log(`${name} could not get the lock`);
  holders.add(name); log(`${name} acquired`);
  await sleep(workMs);                                           // the critical section
  holders.delete(name);
  log(`${name} releases → ${await release(redis, token)}`);
}

const plainDel = (redis) => redis.del(LOCK);                                       // ❌ deletes whoever holds it
const delIfMine = (redis, token) => redis.sendCommand(['DELEX', LOCK, 'IFEQ', token]); // ✓ Redis 8.4+

for (const [label, release] of [['DEL', plainDel], ['DELEX IFEQ', delIfMine]]) {
  console.log(`--- release with ${label}`);
  await a.del(LOCK); t0 = Date.now();
  const A = worker(a, 'A', 'token-a', 300, release);             // A's work (300 ms) outlives its 200 ms lock
  await sleep(250); const B = worker(b, 'B', 'token-b', 150, release);  // the lock expired at 200 ms: B gets it
  await sleep(100); const C = worker(c, 'C', 'token-c', 150, release);  // 350 ms: A has released — whose lock?
  await Promise.all([A, B, C]);
}
await Promise.all([a, b, c].map((r) => r.close()));

// --- release with DEL
//    0 ms  A acquired  holders=[A]
//  252 ms  B acquired  holders=[A,B]
//  302 ms  A releases → 1  holders=[B]
//  353 ms  C acquired  holders=[B,C]
//  404 ms  B releases → 1  holders=[C]
//  504 ms  C releases → 0  holders=[]
// --- release with DELEX IFEQ
//    0 ms  A acquired  holders=[A]
//  252 ms  B acquired  holders=[A,B]
//  302 ms  A releases → 0  holders=[B]
//  354 ms  C could not get the lock  holders=[B]
//  404 ms  B releases → 1  holders=[]
What’s happening
  1. A acquires at 0 ms with a 200 ms lease. At 200 ms Redis expires the key; at 252 ms B acquires a fresh lock. Already two workers are in the critical section (holders=[A,B]) — the lease ran out while A was still working.
  2. With plain DEL, A's release at 302 ms deletes B's lock (→ 1). At 353 ms C acquires, so B and C now overlap. B's DEL at 404 ms deletes C's lock. One bug cascades into a chain of wrong releases.
  3. With DELEX … IFEQ, A's release at 302 ms sees the value is token-b, not token-a, and does nothing (→ 0). B's lock survives, C correctly can't acquire, and B releases its own lock at 404 ms (→ 1).
  4. Notice that the safe release fixed the cascade but not the first overlap at 252–302 ms: A and B both held the lock. No release logic can fix that, because A doesn't know its lease ran out. That needs either a lease long enough (and renewed while working) or a fencing token.
AdvancedFencing tokens, Redlock and lease renewal

Fencing tokens: make the storage reject old holders

The robust answer to "my lease expired while I was paused" is to have the thing you're protecting check a monotonically increasing token that comes with each lock. Here the token comes from INCR, and MySQL refuses writes with an older token than the last one it saw (daily_report is a one-row table created for this example):

fencing.mjs
import mysql from 'mysql2/promise';
import { createClient } from 'redis';
const db = mysql.createPool({ host: '127.0.0.1', port: 3307, user: 'root', password: 'study', database: 'ch25' });
const redis = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await db.query("UPDATE daily_report SET body = 'empty', fence = 0 WHERE id = 1");
await redis.del(['ch25:lock:report', 'ch25:fence:report']);

async function acquire(name) {
  const ok = await redis.set('ch25:lock:report', name, { condition: 'NX', expiration: { type: 'PX', value: 200 } });
  if (!ok) return null;
  return redis.incr('ch25:fence:report');            // a number that only ever goes up: the fencing token
}
async function save(name, fence) {
  // the storage checks the token: a write from an older lock holder is refused
  const [r] = await db.query('UPDATE daily_report SET body = ?, fence = ? WHERE id = 1 AND fence < ?', [`written by ${name}`, fence, fence]);
  console.log(`${name} (token ${fence}) writes → ${r.affectedRows ? 'accepted' : 'rejected: a newer holder already wrote'}`);
}

const fenceA = await acquire('A');
console.log('A got token', fenceA, '— then pauses for 300 ms (GC, swap, a slow call)');
await sleep(250);                                     // A's lock expired at 200 ms
const fenceB = await acquire('B');
console.log('B got token', fenceB);
await save('B', fenceB);
await sleep(50);
await save('A', fenceA);                              // A wakes up and writes late
const [[row]] = await db.query('SELECT body, fence FROM daily_report WHERE id = 1');
console.log('row:', row);
await redis.del(['ch25:lock:report', 'ch25:fence:report']);
await redis.close(); await db.end();

// A got token 1 — then pauses for 300 ms (GC, swap, a slow call)
// B got token 2
// B (token 2) writes → accepted
// A (token 1) writes → rejected: a newer holder already wrote
// row: { body: 'written by B', fence: 2 }
What’s happening
  1. Every successful acquire also INCRs a counter, so A gets token 1. A then stalls for 300 ms total — longer than its 200 ms lease.
  2. B acquires after the lease expired and gets token 2. B writes with WHERE fence < 2; the row's fence was 0, so it's accepted and the row now records fence 2.
  3. A wakes up believing it still holds the lock and writes with token 1. WHERE fence < 1 doesn't match a row whose fence is 2, so affectedRows is 0 and the late write is rejected by the database itself.
  4. The lock still gives efficiency (usually only one worker does the job), and the fencing check gives correctness (an expired holder can't corrupt the result). This is Martin Kleppmann's argument from his 2016 critique of Redlock: a lock based on leases and clocks needs the resource to enforce fencing if correctness matters.
  5. The check works with any storage that supports a conditional write — a SQL WHERE, a MongoDB filter on the update, a SET … IFEQ in Redis 8.4+ — but not with an external API that has no idea what a token is.

#Pub/Sub and Streams

Added

Redis has two messaging features that look similar and behave completely differently. Pub/Sub is a radio broadcast: PUBLISH sends a message to whoever is subscribed to the channel right now, and it's gone — no storage, no replay, no acknowledgement. Streams (Redis 5.0+) are an append-only log, like a tiny Kafka topic: every XADD entry gets an id and stays until you trim it, consumers read from any point, and consumer groups share work with acknowledgements and redelivery.

The mental model: Pub/Sub is a live radio show — if your radio was off, you missed it. A stream is a podcast feed — episodes stay up, each listener remembers where they got to, and a household (a consumer group) can split the episodes between its members.

Use Pub/Sub for ephemeral fan-out where a missed message is harmless: "invalidate this key in your local cache", live notifications to connected websockets, chat presence. Use Streams when the message matters: order events, background jobs, an audit feed.

pubsub-vs-stream.mjs
import { createClient } from 'redis';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const url = 'redis://127.0.0.1:6380';
const pub = await createClient({ url }).connect();
await pub.del('ch25:stream:orders');

// --- Pub/Sub: deliver to whoever is listening right now
const early = await createClient({ url }).connect();
await early.subscribe('ch25:orders', (msg) => console.log('  early subscriber got', msg));
console.log('PUBLISH order 9 → receivers:', await pub.publish('ch25:orders', 'order 9 paid'));
await sleep(20);
const late = await createClient({ url }).connect();          // starts after the message was sent
await late.subscribe('ch25:orders', (msg) => console.log('  late subscriber got', msg));
await sleep(50);
console.log('  late subscriber got nothing: the message was never stored');
await Promise.all([early.close(), late.close()]);
console.log('PUBLISH order 10 → receivers:', await pub.publish('ch25:orders', 'order 10 paid'));

// --- Stream: an append-only log that keeps entries until you trim them
const id9 = await pub.xAdd('ch25:stream:orders', '*', { order: '9', status: 'paid' });
const id10 = await pub.xAdd('ch25:stream:orders', '*', { order: '10', status: 'paid' });
console.log('XADD →', id9, id10);
const reader = await createClient({ url }).connect();        // a consumer that starts later still reads both
const res = await reader.xRead({ key: 'ch25:stream:orders', id: '0' });
console.log('late XREAD from 0 →', JSON.stringify(res[0].messages));
await pub.del('ch25:stream:orders');
await Promise.all([pub.close(), reader.close()]);

// PUBLISH order 9 → receivers: 1
//   early subscriber got order 9 paid
//   late subscriber got nothing: the message was never stored
// PUBLISH order 10 → receivers: 0
// XADD → 1792164899694-0 1792164899694-1
// late XREAD from 0 → [{"id":"1792164899694-0","message":{"order":"9","status":"paid"}},{"id":"1792164899694-1","message":{"order":"10","status":"paid"}}]
What’s happening
  1. A subscribing connection is dedicated to Pub/Sub — in RESP2 it can't run normal commands while subscribed — so each subscriber here is its own client.
  2. PUBLISH returns how many subscribers received the message: 1, the early subscriber, which prints it. The late subscriber joined 20 ms later and never sees order 9: Redis didn't store it anywhere.
  3. With both subscribers closed, PUBLISH order 10 returns 0 receivers. The message vanished, and the publisher's only clue is that number. Pub/Sub is at-most-once.
  4. XADD … * appends to the stream and Redis generates the id: <milliseconds>-<sequence>. Both entries were added in the same millisecond, so they're …694-0 and …694-1. Ids always increase, which is what lets readers resume.
  5. A reader that connects later asks for everything after id 0 and gets both orders, with their fields. The stream is storage; it keeps entries until you trim them (XTRIM, or MAXLEN on XADD).
AdvancedScaling Pub/Sub in Redis Cluster
AdvancedConsumer groups: sharing work, with acknowledgements

A consumer group lets several workers split a stream between them. Each entry is delivered to one consumer and stays in the group's pending entries list (PEL) until that consumer acknowledges it:

redis-cli
> XADD ch25:orders * order_id 6 status paid
"1792164930366-0"
> XADD ch25:orders * order_id 7 status paid
"1792164930366-1"
> XADD ch25:orders * order_id 8 status paid
"1792164930366-2"
> XGROUP CREATE ch25:orders shipping 0
OK
> XREADGROUP GROUP shipping worker-1 COUNT 2 STREAMS ch25:orders >
1) 1) "ch25:orders"
   2) 1) 1) "1792164930366-0"
         2) 1) "order_id"
            2) "6"
            3) "status"
            4) "paid"
      2) 1) "1792164930366-1"
         2) 1) "order_id"
            2) "7"
            3) "status"
            4) "paid"
> XREADGROUP GROUP shipping worker-2 COUNT 2 STREAMS ch25:orders >
1) 1) "ch25:orders"
   2) 1) 1) "1792164930366-2"
         2) 1) "order_id"
            2) "8"
            3) "status"
            4) "paid"
> XACK ch25:orders shipping 1792164930366-2
(integer) 1
> XPENDING ch25:orders shipping
1) (integer) 2
2) "1792164930366-0"
3) "1792164930366-1"
4) 1) 1) "worker-1"
      2) "2"
What’s happening
  1. Three paid orders (6, 7, 8 from the sample schema's ids) are appended. XGROUP CREATE … shipping 0 creates a group that starts from the beginning of the stream; $ instead of 0 would mean "only new entries".
  2. XREADGROUP … > means "give me entries never delivered to anyone in this group". worker-1 asks for two and gets orders 6 and 7; worker-2 then gets the only one left, order 8. Each entry goes to exactly one consumer in the group — that's how you scale processing.
  3. worker-2 ships order 8 and XACKs it (1 entry acknowledged). Acknowledged entries leave the PEL.
  4. XPENDING shows the group still has 2 unacknowledged entries, both owned by worker-1. If worker-1 crashes now, those orders aren't lost — they're recorded as delivered-but-not-done.
  5. A different group (say email) reading the same stream would get all three orders independently. Groups are like separate subscriptions; consumers within a group share the work.

When worker-1 crashed holding orders 6 and 7, another worker can claim anything that's been pending too long:

redis-cli
# worker-1 crashed holding orders 6 and 7. Two seconds later worker-2 takes over anything idle for 1 s:
# (waited 2 s)
> XAUTOCLAIM ch25:orders shipping worker-2 1000 0
1) "0-0"
2) 1) 1) "1792164930366-0"
      2) 1) "order_id"
         2) "6"
         3) "status"
         4) "paid"
   2) 1) "1792164930366-1"
      2) 1) "order_id"
         2) "7"
         3) "status"
         4) "paid"
3) (empty array)
> XACK ch25:orders shipping 1792164930366-0 1792164930366-1
(integer) 2
> XPENDING ch25:orders shipping
1) (integer) 0
2) (nil)
3) (nil)
4) (nil)
> XINFO GROUPS ch25:orders
1)  1) "name"
    2) "shipping"
    3) "consumers"
    4) (integer) 2
    5) "pending"
    6) (integer) 0
    7) "last-delivered-id"
    8) "1792164930366-2"
    9) "entries-read"
   10) (integer) 3
   11) "lag"
   12) (integer) 0
> XLEN ch25:orders
(integer) 3
> XTRIM ch25:orders MAXLEN 1
(integer) 2
> XLEN ch25:orders
(integer) 1
What’s happening
  1. XAUTOCLAIM ch25:orders shipping worker-2 1000 0 transfers to worker-2 every pending entry idle for at least 1,000 ms, scanning from id 0. It returns the cursor to continue from (0-0 = done), the claimed entries (orders 6 and 7) and an empty list of ids that were deleted from the stream meanwhile.
  2. worker-2 processes and acknowledges both; XPENDING is now empty. Every order was handled exactly once by the group, despite a crash — the delivery is at-least-once, so the handler must be idempotent (worker-1 might have shipped order 6 just before it died).
  3. XINFO GROUPS shows the group's position (last-delivered-id), how many entries it has read and its lag — 0, nothing waiting. Lag is the number to alert on.
  4. Acknowledging doesn't delete: the stream still has all 3 entries (XLEN 3). Streams grow until you trim them; XTRIM … MAXLEN 1 removed 2. In production use XADD … MAXLEN ~ 100000 so each append trims cheaply (the ~ lets Redis trim whole internal nodes).
  5. Redis 8.2 added XACKDEL (acknowledge and delete in one command) and XDELEX, which can also clean up consumer-group references when deleting.

#Persistence: RDB and AOF

Added

Redis keeps the dataset in memory, so persistence is about what survives a restart. There are two mechanisms, and you can use both. RDB snapshots: Redis forks, and the child writes the whole dataset to a compact dump.rdb file at intervals (save 3600 1 300 100 60 10000 by default). AOF (append-only file): every write command is appended to a log and, depending on appendfsync, flushed to disk every second (everysec, the default), on every write (always) or whenever the OS decides (no). On restart Redis loads the AOF if it's enabled (it's the most complete), otherwise the RDB.

A mental model: RDB is taking a photo of the whiteboard every few minutes — compact, fast to restore, but you lose whatever was written since the last photo. AOF is a secretary writing down every change as it happens — at most a second of loss with everysec, but the notebook grows, so Redis periodically rewrites it into the shortest list that rebuilds the current board.

RDBAOF (everysec)Neither
What's on diskpoint-in-time snapshotlog of writes (plus a base snapshot, Redis 7+)nothing
Data lost on a crasheverything since the last snapshot (minutes)about 1 secondeverything
Restart speedfast (load a binary file)slower (replay), helped by the RDB baseinstant, empty
Costa fork per snapshota write per command, background fsync, rewritesnone
Use forbackups, caches you'd like warm after restartdata you'd rather not losepure caches
Terminal
# Server 1: RDB snapshots only.  Server 2: AOF with appendfsync everysec.
$ rdb-cli SET ch25:order:6:status paid
OK
$ rdb-cli BGSAVE
Background saving started
# (waited 1 s)
$ rdb-cli SET ch25:order:7:status paid
OK
$ aof-cli SET ch25:order:6:status paid
OK
$ aof-cli SET ch25:order:7:status paid
OK
# (waited 1.5 s)
# kill -9 both servers: no shutdown, no final save
$ docker kill study-db-ch25-rdb-1 study-db-ch25-aof-1
study-db-ch25-rdb-1
study-db-ch25-aof-1
$ docker start study-db-ch25-rdb-1 study-db-ch25-aof-1
study-db-ch25-rdb-1
study-db-ch25-aof-1
# (waited 2 s)
$ rdb-cli MGET ch25:order:6:status ch25:order:7:status
1) "paid"
2) (nil)
$ aof-cli MGET ch25:order:6:status ch25:order:7:status
1) "paid"
2) "paid"
What’s happening
  1. Two throwaway Redis 8.10 containers were started for this test: one with --save "3600 1" --appendonly no (RDB only) and one with --appendonly yes --appendfsync everysec. rdb-cli and aof-cli are shorthands for docker exec <container> redis-cli.
  2. On the RDB server, BGSAVE forks a child that writes a snapshot containing order 6. Order 7 is written after the snapshot. On the AOF server both writes are appended to the log, and the 1.5-second wait lets the once-a-second fsync run.
  3. docker kill sends SIGKILL — the equivalent of a power cut or the OOM killer. Redis gets no chance to save on shutdown (a clean SHUTDOWN would have saved, because a save point is configured).
  4. After restart, the RDB server loaded the snapshot: order 6 is back, order 7 is gone — everything since the last snapshot is lost. With the default save points that window is minutes.
  5. The AOF server replayed its log: both orders survived. Had the kill come within a second of the write, the last second could still have been lost; appendfsync always closes that window at a large cost in write throughput.
AdvancedPitfall: fork cost of snapshots and rewrites
AdvancedWhat the AOF looks like on disk

Since Redis 7.0 the AOF is a directory: a base file (an RDB-format snapshot by default), one or more incremental files, and a manifest tying them together:

Terminal
$ docker exec study-db-ch25-aof-1 ls -l /data /data/appendonlydir
/data:
total 8
drwx------ 1 redis redis 146 Oct 16 15:37 appendonlydir
-rw------- 1 redis redis  89 Oct 16 15:37 dump.rdb

/data/appendonlydir:
total 12
-rw------- 1 redis redis  89 Oct 16 15:37 appendonly.aof.1.base.rdb
-rw------- 1 redis redis 260 Oct 16 15:37 appendonly.aof.1.incr.aof
-rw------- 1 redis redis 102 Oct 16 15:37 appendonly.aof.manifest
$ docker exec study-db-ch25-aof-1 cat /data/appendonlydir/appendonly.aof.manifest
file appendonly.aof.1.base.rdb seq 1 type b
file appendonly.aof.1.incr.aof seq 1 type i startoffset 0
$ aof-cli INFO persistence | grep -E "^(aof_enabled|aof_rewrites|aof_last_write_status|rdb_last_save_time|rdb_changes_since_last_save|aof_current_size|aof_base_size)"
rdb_changes_since_last_save:2
rdb_last_save_time:1792165059
aof_enabled:1
aof_rewrites:0
aof_rewrites_consecutive_failures:0
aof_last_write_status:ok
aof_current_size:349
aof_base_size:89
$ aof-cli BGREWRITEAOF
Background append only file rewriting started
# (waited 1 s)
$ docker exec study-db-ch25-aof-1 cat /data/appendonlydir/appendonly.aof.manifest
file appendonly.aof.2.base.rdb seq 2 type b
file appendonly.aof.2.incr.aof seq 2 type i startoffset 260
What’s happening
  1. appendonlydir holds the base file (.base.rdb, a snapshot taken when the AOF was last rewritten), the incremental file (.incr.aof, every write since) and the manifest listing which files make up the current AOF. The dump.rdb next to it is this server's separate RDB snapshot.
  2. INFO persistence confirms AOF is on and healthy (aof_last_write_status:ok), and that the log is 349 bytes on top of an 89-byte base.
  3. BGREWRITEAOF (also triggered automatically, by default when the AOF has doubled since the last rewrite and is at least 64 MB) forks a child that writes a new base snapshot, while the parent opens a new incremental file for writes arriving meanwhile. When the child finishes, Redis swaps the manifest atomically: now seq 2.
  4. Before Redis 7.0 the AOF was a single file, and writes during a rewrite were buffered in memory and written twice; the multi-part design removed that cost. Old guides that say "copy appendonly.aof" refer to that layout.

The incremental file is just the Redis protocol (RESP), one command after another. After an INCR ch25:views:product:1, the new incremental file contained:

appendonly.aof.2.incr.aof
*2
$6
SELECT
$1
0
*2
$4
INCR
$20
ch25:views:product:1
What’s happening
  1. *2 starts an array of two elements; $6 says the next bulk string is 6 bytes (SELECT), then $1 and 0: the log first records which database the following commands apply to.
  2. Then the INCR itself with its 20-byte key. The AOF stores the command, not the resulting value — replaying it re-executes the increment.
  3. That's why an AOF can be inspected, truncated by hand (to undo an accidental FLUSHALL before a rewrite has happened) and repaired with redis-check-aof if the last command was half-written.
AdvancedNo persistence plus auto-restart can wipe your replicas

A primary with persistence turned off, a replica, and a container orchestrator that restarts crashed processes. The primary restarts empty — and the replica faithfully copies it:

Terminal
$ redis-cli CONFIG GET save
1) "save"
2) ""
$ redis-cli CONFIG GET appendonly
1) "appendonly"
2) "no"
$ redis-cli SET ch25:cart:customer:1 "ThinkBook 14, USB-C Charger x2"
OK
$ redis-cli -h replica GET ch25:cart:customer:1
"ThinkBook 14, USB-C Charger x2"
$ docker restart study-db-ch25-primary-1
study-db-ch25-primary-1
# (waited 9 s)
$ redis-cli DBSIZE
(integer) 0
$ redis-cli -h replica DBSIZE
(integer) 0
$ docker logs study-db-ch25-replica-1 2>&1 | grep -E "Flushing old data|keys loaded" | tail -2
1:S 16 Oct 2026 15:47:56.693 * MASTER <-> REPLICA sync: Flushing old data
1:S 16 Oct 2026 15:47:56.693 * Done loading RDB, keys loaded: 0, keys expired: 0.
What’s happening
  1. The primary has no save points (save "") and no AOF — a common setting for "it's only a cache". The replica, started with --replicaof primary 6379, has the cart.
  2. The primary restarts (a crash plus auto-restart behaves the same). With nothing on disk it comes back empty.
  3. The replica reconnects, sees a primary with a new replication id, and does a full resync. Its log says it all: Flushing old data, then keys loaded: 0. The one copy that survived the crash was erased by replication.
  4. The Redis replication docs warn about exactly this: if the primary has persistence off, don't let it restart automatically — or keep persistence on so it reloads before replicas sync. Sentinel or Cluster failover (promoting the replica instead of restarting the primary) avoids it too.
AdvancedOnline backups with BACKUP in Redis 8.10

Online backups with BACKUP (Redis 8.10)

Redis 8.10 added a BACKUP command family that produces a restorable base-plus-increment set without pausing writes or juggling AOF rewrites:

Terminal
$ aof-cli BACKUP START
OK
$ aof-cli SET ch25:order:8:status shipped
OK
$ aof-cli BACKUP STATUS
1) "state"
2) "incrementing"
3) "error"
4) ""
5) "start_time"
6) (integer) 1792166448
7) "end_time"
8) (integer) 0
$ aof-cli BACKUP SEAL
OK
$ aof-cli BACKUP STATUS
1) "state"
2) "sealed"
3) "error"
4) ""
5) "start_time"
6) (integer) 1792166448
7) "end_time"
8) (integer) 1792166448
$ aof-cli BACKUP LIST
1) "/data/backupdir/appendonly.aof.5.base.rdb"
2) "/data/backupdir/appendonly.aof.6.incr.aof"
3) "/data/backupdir/appendonly.aof.manifest"
$ docker cp study-db-ch25-aof-1:/data/backupdir ./restore
$ aof-cli BACKUP CLEANUP
OK
$ docker run -d --name study-db-ch25-restore -v "$PWD/restore:/restore" redis:8.10 redis-server --preload-file aof:/restore/appendonly.aof.manifest --save "" >/dev/null
# (waited 2 s)
$ docker exec study-db-ch25-restore redis-cli --no-raw MGET ch25:order:6:status ch25:order:7:status ch25:order:8:status
1) "paid"
2) "paid"
3) "shipped"
$ docker logs study-db-ch25-restore 2>&1 | grep -E "DB loaded|pre-loaded"
1:M 16 Oct 2026 16:00:49.232 * DB loaded from base file appendonly.aof.5.base.rdb: 0.000 seconds
1:M 16 Oct 2026 16:00:49.232 * DB loaded from incr file appendonly.aof.6.incr.aof: 0.000 seconds
1:M 16 Oct 2026 16:00:49.232 * DB pre-loaded from append only file: /restore/appendonly.aof.manifest: 0.000 seconds
What’s happening
  1. BACKUP START forks and writes a fresh base snapshot, then keeps appending new writes to an incremental file — so the SET of order 8 after the start is still captured. BACKUP STATUS says incrementing: the base is done and writes are being collected.
  2. BACKUP SEAL freezes the backup at that point and writes a manifest; the status becomes sealed, and BACKUP LIST gives the absolute paths of the three files to copy.
  3. The files are copied out (docker cp) and BACKUP CLEANUP releases them on the server.
  4. A brand-new container started with --preload-file aof:<manifest> loads the base, then the increment. All three orders are there, including the one written after BACKUP START — the restore reflects the seal point, not just the snapshot.
  5. Separating start and seal lets a control plane stagger the forks across cluster nodes. On older versions the usual backup is copying dump.rdb (safe at any time — it's written to a temp file and renamed atomically) or the AOF directory with rewrites paused.

#Redis, Valkey and licensing

Added

For fifteen years Redis was simply BSD-licensed open source. Between 2024 and 2025 that changed twice, and a fork appeared, so "Redis" in an interview or a job description can now mean one of several things. The short version: Redis 7.2 and earlier are BSD; Redis 7.4.x is source-available (RSALv2 or SSPLv1); Redis 8.0 and later add AGPLv3 as a third option, which is OSI open source again; and Valkey is the BSD-licensed fork of Redis 7.2.4 run by the Linux Foundation.

The mental model: think of a popular recipe book. The publisher stopped letting restaurants copy it freely (the 2024 change, aimed at cloud providers selling Redis as a service); some of the cooks took the last free edition and started their own book (Valkey); a year later the publisher released new editions under a licence open-source cooks accept again, as long as anyone who serves dishes from a modified recipe publishes the changes (AGPL).

DateWhat happenedLicence
2009–2024Redis up to 7.2.xBSD-3-Clause
20 March 2024Redis Inc announces that, starting with Redis 7.4, Redis is dual-licensed and no longer BSDRSALv2 or SSPLv1 (source-available, not OSI open source)
28 March 2024The Linux Foundation launches Valkey, continuing from Redis 7.2.4, backed by AWS, Google Cloud, Oracle, Ericsson and SnapBSD-3-Clause
October 2024AWS ElastiCache and MemoryDB add Valkey, priced below their Redis OSS engines—
1 May 2025Redis 8.0: AGPLv3 added as an option; JSON, search, time series, probabilistic types and vector sets move into the coreRSALv2, SSPLv1 or AGPLv3 (your choice)
October 2025Valkey 9.0 GA (hash-field expiry, atomic slot migration, multiple databases in cluster mode)BSD-3-Clause
2026Redis 8.x continues (8.10 used in this chapter); Valkey 9.1.2 released on 1 September 2026as above

What the licences mean in practice:

  • BSD-3-Clause (Redis ≤ 7.2, Valkey): do anything, including selling it as a hosted service, as long as you keep the copyright notice.
  • RSALv2: free to use, modify and redistribute, but you may not offer Redis itself to others as a managed service or a competing product. Using Redis inside your own application — even a commercial SaaS — is allowed.
  • SSPLv1 (the licence MongoDB wrote in 2018): if you offer the software as a service, you must publish the source of your whole service stack. Not OSI-approved.
  • AGPLv3 (Redis 8+): OSI-approved open source; if you modify Redis and let users interact with it over a network, you must share your modifications. Running unmodified Redis behind your app has no obligations beyond the usual notices.

For most teams the licence change changes nothing day to day: an app that uses Redis as its cache or queue was never the target. It matters to companies hosting Redis for others (cloud providers, platform vendors) and to legal teams with policies about SSPL/AGPL. The client libraries (node-redis, ioredis, redis-py, Jedis) stayed permissively licensed throughout.

Compatibility: Valkey 7.2 was a drop-in replacement for Redis 7.2 — same protocol, same commands, same RDB/AOF formats — and Valkey still reports a redis_version field for clients that check it. Since then the two have diverged: Redis 8 bundles JSON, the query engine, time series, probabilistic structures and vector sets in the core server, and adds commands such as HGETEX, DELEX and BACKUP used in this chapter; Valkey ships its own modules and features separately. Core strings, hashes, lists, sets, sorted sets, streams, Lua, Pub/Sub and replication work the same in both, so most application code (and everything in this chapter that doesn't name a Redis 8 version) runs on either. A Valkey server wasn't run for this chapter; check a specific command against the Valkey docs before relying on it there.

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.