#What Redis is
AddedRedis 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.
> 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 rangeSETstores a string under the keych25: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 withch25:so it can't collide with anything else on the server.GETreturns 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.INCRon a missing key treats it as0, adds 1 and returns the new value:1 → 2, thenINCRBY 10makes it12. 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.TYPEstill saysstringfor the counter: numbers are strings that happen to parse as integers.OBJECT ENCODINGshows the internal trick — Redis stores it as a real 64-bitint, not text.EXISTScounts how many of the given keys exist (1: only product 1). A missing key reads as(nil), never an error.INCRon the JSON string fails withERR 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.
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.
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)- 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.
- In
racyLike, client 1GETs0, client 2GETs0before client 1'sSETarrives, both compute1and bothSET 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. - 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).
atomicLikesends oneINCR. 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.- The lesson for every Redis design: push the read-modify-write into Redis — a single command (
INCR,HINCRBY,SET NX,ZINCRBY), aMULTIblock, or a Lua script — instead of doing it in application code.
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:
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)- The first loop
awaits everySET, 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. - 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. - 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.
- Pipelining is not atomic — another client's commands can run between yours. When you need "all or nothing, nobody in between", use
MULTI/EXECor a Lua script. - 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:
$ 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- 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.
-P 16sends 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.- 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.
- 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.
MULTI queues commands and EXEC runs them back to back with nothing else in between. What it doesn't do is roll back:
> 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"- After
MULTI, every command answersQUEUED— nothing has run yet. The commands are only checked for syntax at this point. EXECruns the three commands in order, isolated: no other client's command can run between them.- The second command fails at run time (
INCRon a non-number), and Redis carries on: the first and thirdSETs are applied.MGETshowsTorontoandCanadawere written. - So
MULTIgives isolation (nobody interleaves) but not atomic rollback. A command with a syntax error is caught at queue time and makesEXECabort the whole block — but type errors like this one only show up when the command runs. - For conditional logic ("only set if the current value is X"), use
WATCH+MULTI(optimistic:EXECreturns nil if a watched key changed) or a Lua script, which is what the rest of this chapter does.