Part I: The mental model
client ──RESP bytes──▶ socket ──▶ ae.c event loop (epoll/kqueue)
│ readable event
▼
networking.c readQueryFromClient()
│ parse RESP into argv
▼
server.c processCommand() ─▶ command table
│ dispatch (src/commands/*.json)
▼
t_string.c setCommand() / getCommand()
│
▼
dict.c keyspace hash table ──▶ robj value
│
▼
networking.c reply buffer ──▶ write on next tick ──▶ client
Hold this picture and the whole codebase falls into place. Redis is one thread running one loop. The loop waits for sockets to become readable, reads command bytes off them, parses those bytes into an argument vector, looks the command up in a table, and calls a C function that touches an in-memory hash table. The reply is appended to a per-client buffer and written back out on a later turn of the same loop. Everything else in the repository, the persistence machinery, replication, cluster mode, the background thread pool, exists to serve that loop without ever blocking it.
The one-sentence identity: Redis is a dictionary of typed,
adaptively encoded data structures, served over a text protocol by
a single-threaded event loop, with durability done off to the side
by forked children and background threads. The dictionary is
dict.c, the types and encodings are
object.c plus the t_*.c files, the loop
is ae.c, the protocol is networking.c,
and the off-to-the-side work is rdb.c,
aof.c, and bio.c. Six ideas, six sets of
files, and the source is flat enough that all of them live in one
directory.
What makes Redis worth studying rather than merely using is that each of those six ideas is a deliberate answer to a question you will face designing any server: how do I get concurrency without locks, how do I keep small data small, how do I persist without pausing, how do I expire millions of keys without scanning them. The rest of this chapter takes each answer apart.
Part II: Using it
Installing on Linux and macOS
# Debian/Ubuntu
sudo apt install redis-server
# macOS
brew install redis
brew services start redis # or just: redis-server
# anywhere, via container
docker run -p 6379:6379 redis
Distro packages can lag by a major version; if you want current
Redis 8 on Ubuntu, the official APT repository at
packages.redis.io or the Docker image is the reliable
route. Verify what you got with redis-server --version
and check liveness with redis-cli ping, which should
answer PONG.
A first real session
$ redis-cli
127.0.0.1:6379> SET user:1000:name "Ada"
OK
127.0.0.1:6379> GET user:1000:name
"Ada"
127.0.0.1:6379> EXPIRE user:1000:name 60
(integer) 1
127.0.0.1:6379> TTL user:1000:name
(integer) 57
127.0.0.1:6379> INCR pageviews
(integer) 1
127.0.0.1:6379> ZADD leaderboard 3172 ada 2891 lin
(integer) 2
127.0.0.1:6379> ZRANGE leaderboard 0 -1 WITHSCORES
1) "lin"
2) "2891"
3) "ada"
4) "3172"
That session already shows the shape of the product: strings with
expiry give you a cache, atomic counters give you rate limiting,
and sorted sets give you a leaderboard, each a data structure with
commands rather than tables with queries. The protocol is plain
text over TCP, so every language has a client, and
redis-cli MONITOR in a second terminal is the best
free tour of what an application actually asks its cache to do.
From code
The natural client language is Python with
redis-py (pip install redis):
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
r.set("user:1000:name", "Ada", ex=3600)
print(r.get("user:1000:name")) # 'Ada'
r.hset("user:1000", mapping={"name": "Ada", "city": "London"})
print(r.hgetall("user:1000")) # {'name': 'Ada', 'city': 'London'}Mistake one: KEYS in production
Every command runs on the one thread, so an O(n) scan of the whole
keyspace stalls every other client until it finishes. The cursor
based SCAN does the same job in bounded slices:
# wrong: blocks the server for the duration of the scan
KEYS user:*
# right: iterate in small batches, other clients interleave
SCAN 0 MATCH user:* COUNT 100
# ...repeat with the returned cursor until it comes back as 0Mistake two: check-then-set races
Two clients can interleave between a GET and a
SET. Redis's answer is to push the atomicity into one
command; the lock idiom is the canonical example:
# wrong: two commands, another client can win between them
exists = r.get("lock:job42")
if not exists:
r.set("lock:job42", my_id)
# right: one atomic command, with an expiry so a crash cannot
# leave the lock held forever
acquired = r.set("lock:job42", my_id, nx=True, ex=30)Mistake three: one round trip per operation
Redis usually answers in well under a millisecond, so the dominant cost of a chatty loop is your own network round trips. Pipelining sends a batch and reads the replies together:
# wrong: 10,000 network round trips
for i in range(10000):
r.set(f"k:{i}", i)
# right: a few round trips, typically an order of magnitude faster
pipe = r.pipeline()
for i in range(10000):
pipe.set(f"k:{i}", i)
pipe.execute()Mistake four: JSON blobs where a hash belongs
Serializing an object into one string means every field update is a read-modify-write of the whole blob, done in your application with a race window. A hash lets Redis update one field atomically, and small hashes are stored in a compact encoding you will meet in Part VII:
# wrong: fetch, mutate, write back the whole object
u = json.loads(r.get("user:1000"))
u["city"] = "Paris"
r.set("user:1000", json.dumps(u))
# right: one atomic field write
r.hset("user:1000", "city", "Paris")Mistake five: forgetting that memory is the budget
Out of the box maxmemory is unset and
maxmemory-policy is noeviction, so a
"cache" that never sets TTLs simply grows until the box swaps or
the OOM killer arrives, and once a limit is set, writes start
failing with an error instead of evicting. A cache deployment
wants an explicit limit and an eviction policy, for example
maxmemory 2gb with
maxmemory-policy allkeys-lru. Part VII explains what
those policies actually do.
Part III: When it is the right tool
Redis is the right tool when the working set fits in memory and you want latency measured in microseconds with data structure semantics: caching, session storage, rate limiting, leaderboards, distributed locks, pub/sub fan-out, and queues via lists or streams. It is a strong fit precisely when the operations you need map onto its commands, because then every operation is one atomic, already-implemented, already-fast primitive.
The alternatives frame the boundaries. Memcached is the purer cache: multithreaded, only strings, no persistence, and still a fine choice when you want nothing but a flat LRU cache saturating big NICs. Valkey is the Linux Foundation fork created in 2024 when Redis left open source licensing; it is command-compatible and the default choice in some distros, and everything in this chapter about the architecture applies to it too. And when the data is relational, larger than memory, or must be the authoritative copy, a disk-first database such as Postgres is the right substrate, with Redis in front of it rather than instead of it; my databases note covers that decision in general form.
The architecture-shaped warning: do not make Redis the only copy of data you cannot afford to lose. Persistence is real but bounded: the default AOF policy fsyncs once per second, RDB snapshots are minutes apart, and failover to an asynchronously-updated replica can discard acknowledged writes. The safe shape keeps a durable system of record behind it:
safe: app ──▶ Redis (cache, TTLs, eviction)
│ miss / write-through
▶ Postgres (source of truth, real durability)
dangerous: app ──▶ Redis (only copy, everysec fsync, async replica)
│ crash or failover
▶ up to seconds of acknowledged writes gone
When one box is not enough you shard across many, which is Redis Cluster's job and the subject of my distributed cache design write-up, which derives the same architecture from requirements instead of from code.
Part IV: Systems designs where Redis belongs
Part III gave the rule, Redis wins when the operations you need map onto its commands. This part works the rule as concrete designs, each with the keys, the commands, the TTLs, and the failure behavior spelled out, because the difference between a checklist and a system is the wiring. Everything here runs on a single instance, survives the move to Cluster, and leans on the two properties the rest of the chapter establishes, every command is atomic and almost every command is fast.
A cache-aside layer in front of Postgres
The most common deployment is also the most instructive. The
application reads Redis first, and on a miss it reads
Postgres, then writes the row
back with a TTL so the entry retires itself. Read-through is
the same design with the fetch moved into a cache library, the
wiring below is cache-aside, where the application owns the
miss path. Writes go to Postgres and then delete the cache key
rather than updating it, because a delete cannot plant a stale
value during a race while an update can, and every TTL gets
jitter so keys populated together do not expire together. The
remaining failure mode is the stampede, a hot key expires and a
thousand concurrent requests all miss at once and arrive at the
database as a synchronized herd. One defense is a rebuild lock,
the first missing reader claims a sentinel key with
SET NX EX and recomputes while the rest wait
briefly or serve slightly stale data. The other is
probabilistic early refresh, where each reader recomputes
before expiry with a probability that rises as the deadline
approaches, scaled by how long the recompute takes, so the
refresh has usually happened before any miss can occur. The
lock version is the easier one to reason about.
import json, random, time
TTL = 300
def get_user(uid):
key = f"user:{uid}"
hit = r.get(key)
if hit is not None:
return json.loads(hit)
# one worker rebuilds a hot key, the rest wait briefly and retry
if r.set(f"rebuild:{key}", "1", nx=True, ex=10):
row = fetch_user_from_postgres(uid)
r.set(key, json.dumps(row), ex=TTL + random.randint(0, 60))
r.delete(f"rebuild:{key}")
return row
time.sleep(0.05)
return get_user(uid)
Size the cache for hit rate rather than for the whole table,
set maxmemory with allkeys-lru, and
let eviction do the working-set selection. The full derivation
of this design, including how it shards, is my
distributed cache
write-up.
A session store
Sessions are the cleanest case of data Redis should own
outright. The browser holds an opaque random id in a cookie,
and the server holds a hash at session:{id} with
the user id, roles, and a CSRF secret, created with a TTL and
refreshed with EXPIRE on each authenticated
request so the window slides. Every app server sees the same
store, so there is no sticky routing and any instance can
serve any request, which is the property that lets the web
tier scale horizontally and deploy without draining
connections. Logout is a DEL, and revoking
everything a user holds is a small per-user set of their
session ids, deleted in one pipeline. The loss story is what
makes the fit honest, if the store dies users log in again, an
annoyance rather than an outage, which is exactly the
durability contract Redis offers. A session hash with a
handful of short fields stays in the listpack encoding from
Part VII and costs a few hundred bytes, so a million live
sessions fit comfortably under a gigabyte.
A rate limiter, three ways
The fixed window is INCR on
rl:{user}:{minute} with EXPIRE set
on the first increment, two commands and almost no memory, and
one known flaw, a client can spend a full budget at the end of
one window and another at the start of the next, so a limit of
100 per minute briefly admits nearly 200 across the boundary.
The sliding window log fixes that with a sorted set per
client, each request adds a member scored by its timestamp,
ZREMRANGEBYSCORE drops entries older than the
window, and ZCARD counts exactly what happened in
the last minute, at the cost of one stored entry per request.
The token bucket allows controlled bursts, and it belongs in a
Lua script so the read-modify-write of the bucket state is one
atomic step. A script runs alone on the one execution thread,
which turns the classic concurrent-counter race into
straight-line code.
BUCKET = """
local rate = tonumber(ARGV[1]) -- tokens added per second
local burst = tonumber(ARGV[2]) -- bucket capacity
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or burst
local ts = tonumber(b[2]) or now
tokens = math.min(burst, tokens + (now - ts) * rate)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], ttl)
return allowed
"""
allow = r.register_script(BUCKET)
ok = allow(keys=["rl:user:1000"], args=[5, 10, time.time(), 60])Pick by what the limit protects. The fixed window suits coarse abuse caps where a boundary burst is tolerable, the sliding log gives exact enforcement for expensive endpoints, and the token bucket is the shape traffic policing actually wants, steady rate with bounded burst. The service built around these decisions is my rate limiter design write-up.
A leaderboard on sorted sets
The leaderboard is the design where Redis is not merely a good
choice but the reference answer. One sorted set per season,
ZINCRBY applies a score event,
ZRANGE ... REV reads the top ten,
ZREVRANK answers the question every player
actually asks, where am I, and a range around a player's rank
produces the neighbors view. Each of those is O(log n)
through the skiplist plus dict pairing described in Part VII,
so ten million members answer rank queries in microseconds,
which relational engines handle badly because a global rank is
a count of everyone with a better score, recomputed per
request. The wiring detail that matters is seasons as separate
keys, lb:2026w30 rather than one eternal set, so
a reset is starting a new key and old seasons retire by TTL
instead of by deletion storm. My
gaming leaderboard
design builds the surrounding service.
redis-cli ZINCRBY lb:2026w30 31 player:ada
redis-cli ZRANGE lb:2026w30 0 9 REV WITHSCORES # top ten
redis-cli ZREVRANK lb:2026w30 player:ada # ada's rank, 0-basedA work queue, Streams versus lists
The list version came first and is still everywhere. Producers
LPUSH jobs, and each worker takes one with
BRPOPLPUSH, a blocking pop from the pending list
and an atomic push onto that worker's processing list in a
single step, spelled BLMOVE in newer code. The
processing list is the safety net, a job leaves it only after
completion, so a crashed worker leaves evidence behind. The
weakness is that the evidence does not act on itself, you must
run a janitor that scans processing lists for entries older
than a deadline and pushes them back, and there is no delivery
counter, so a poison job can circulate forever. Streams with
consumer groups build all of that in. XADD
appends, XREADGROUP delivers each entry to
exactly one consumer in the group, every delivered but
unacknowledged entry sits in the pending entries list with an
idle time and a delivery count, XAUTOCLAIM
reassigns entries idle too long to a live worker, and
XACK retires them. A delivery count that climbs
past a threshold flags the poison job for a dead letter
stream. The contract is at least once, a worker that dies
after doing the work but before the ack causes a redelivery,
so handlers must be idempotent, which is what the idempotency
key design at the end of this part provides.
producer ──XADD──▶ jobs stream
│ XREADGROUP GROUP workers ... each entry to one consumer
▼
worker w1 crashes mid-job ▶ entry stays in the pending entries list
│ XAUTOCLAIM after the idle threshold
▼
worker w2 finishes ──XACK──▶ entry retired, delivery count remembers
My distributed message queue design derives the same contract from requirements, and the next part covers where Streams stop being the right log.
Pub/sub fan-out, and what it promises
PUBLISH and SUBSCRIBE are the
simplest primitives in the building, and the delivery
guarantee is the entire design decision. A message goes to the
subscribers connected at that instant and is stored nowhere, a
slow subscriber is disconnected when its output buffer fills,
and one that reconnects has missed everything in between. That
is at most once, and it is precisely right when the message is
a hint rather than a fact. Cache invalidation broadcasts
qualify because the next read repairs any loss, so do presence
and typing indicators, config reload pushes, and the fan-out
tier of a chat system where each websocket server subscribes
to channels for the users it holds and a reconnecting client
resyncs from durable history anyway, the shape my
chat system design uses.
The rule is short, publish things whose loss is repaired by
the next read, queue things whose loss is a bug, and reach for
Streams as the in-house upgrade when you need replay.
Idempotency keys with SET NX EX
Retries are how distributed systems cope with failure, and
idempotency keys are how they stay honest while doing it. The
client sends a unique request id, and the server claims it
with SET key value NX EX ttl, one atomic command
that succeeds for exactly one attempt. The winner processes
the request and overwrites the key with the stored result,
retries find the key and return that result without
re-executing, and a retry that arrives mid-flight sees the
in-progress marker and backs off. The TTL is the retry
horizon, a day covers clients that retry after long outages
without accumulating keys forever. The honest caveat comes
from the durability story in Part VII, a failover can forget a
freshly claimed key, so for money movement the ledger itself
must also enforce uniqueness, with the Redis key as the fast
path that absorbs the retry traffic. My
payment system design
spells out that layering.
claimed = r.set(f"idem:{req_id}", "in-progress", nx=True, ex=86400)
if claimed:
result = process(request)
r.set(f"idem:{req_id}", json.dumps(result), xx=True, ex=86400)
else:
prior = r.get(f"idem:{req_id}")
if prior == "in-progress":
raise Conflict("original attempt still running")
result = json.loads(prior)Part V: Where Redis does not belong
The negative space matters as much as the designs. Most production misuses of Redis trace back to one of a few boundaries, and each boundary follows from architecture this chapter has already built, the memory budget, the bounded durability, the single execution thread, and the absence of a query planner. This part walks them in order of how expensive the lesson tends to be.
As the primary system of record
The durability numbers from Part VII decide this one. An RDB
snapshot preserves the past at the price of the present,
everything after the last save is gone on a crash, and saves
are minutes apart. AOF at the default everysec acknowledges
writes that can still die with the process, about a second of
them, and even appendfsync always cannot help
with failover, promoting an asynchronous replica can discard
acknowledged writes regardless of the fsync dial. A system of
record also grows for years, and Redis charges RAM prices for
every byte, which turns cold history into the most expensive
storage in the fleet. Keep the authoritative copy in a
disk-first engine with a real write-ahead log, which is
Postgres in most stacks, and let
Redis be the fast copy in front, the shape Part III drew. The
tell that a team has crossed this line is a backup strategy
that consists of hoping the replica stays up.
When the working set cannot fit in RAM
Redis has one storage tier and it is the expensive one. If the
data is genuinely evictable cache, oversubscription is the
design, set maxmemory, choose an eviction policy,
size for hit rate, and let the cold tail live in the database
behind you. If the data is not evictable, the arithmetic stops
working, because every cold byte occupies DRAM that a
disk-first engine would keep on SSD at a fraction of the cost.
LSM-tree storage in the
RocksDB family exists exactly for
the shape where the working set is smaller than the dataset,
hot pages in memory and cold levels on disk. Some
Redis-compatible servers offer flash tiers, and the fair way
to assess them is as different systems with different latency
contracts rather than as a bigger Redis.
For complex queries and joins
Redis answers exactly the questions you prepared it to answer. Access by key is O(1), and access by anything else is a secondary index you build by hand, a set per attribute value, updated by your application, on every write, forever. There is no planner, no constraint enforcement, no ad hoc query, and a join is a Lua script you now maintain. Two or three hand-built indexes are a normal and healthy pattern, the leaderboard above is one. A keyspace that has grown into a shadow relational schema, sets mirroring foreign keys and scripts replaying joins, is the sign to hand the workload to Postgres, where declarative indexes and a planner do that bookkeeping with decades of correctness behind them, and to keep Redis for the hot paths it still owns.
For large blobs
Every value travels through the single thread and the
per-client output buffer. A huge value technically fits, the
protocol allows bulk strings up to
proto-max-bulk-len, half a gigabyte by default,
but limits are not the problem, latency is. Serving a 100 MB
value occupies the loop while the bytes are copied, inflates
the client output buffer toward its disconnect thresholds, and
gets rewritten wholesale into the AOF and the replication
stream on every update. Blobs also convert RAM directly into
cost while using none of the data structure semantics that
justify the price. The standard wiring stores the bytes in
object storage and keeps the pointer plus the hot metadata in
Redis, the design my
object storage write-up
covers from the other side.
For exactly-once messaging
Pub/sub is at most once, and Streams are at least once, delivery repeats until acknowledged, so duplicates are a normal event and consumer idempotency is what turns the contract into effectively exactly once. That is genuinely fine for most work queues. It stops being fine when the log itself is the product, days or weeks of retention for replay and backfill, many independent consumer groups reading at their own pace, ordered partitions rebalanced across a consumer fleet, and throughput planned at disk prices for storage. Kafka-class replicated logs are built around exactly those requirements, retention on disk, offsets as plain consumer-owned integers, ordering within each partition, and an in-memory stream cannot follow them there without paying RAM prices for history. My distributed message queue design works through where that line falls.
The distributed lock caveat
The single-instance lock from Part II, SET NX EX
with a unique token and a Lua release that checks the token
before deleting, is a fine efficiency lock. It deduplicates
work, one cron runner instead of five, and its failure mode, a
rare double execution when the lock expires under a stalled
holder, wastes computation without corrupting anything. The
debate begins when the lock guards correctness. Redlock, the
multi-instance algorithm, acquires the lock on a majority of
independent Redis nodes so that one node's crash cannot hand
the lock out twice. Kleppmann's critique makes two calm
points. A client can be paused after acquiring, by garbage
collection or by the network, and act on a lock that has
already expired, so safety requires a fencing token, a
monotonically increasing number that the protected resource
itself checks, and Redlock does not produce one. And the
algorithm's safety rests on timing assumptions, bounded pauses
and well-behaved clocks, that distributed systems theory
prefers not to rest on. Antirez's reply argues that those
timing assumptions are the same ones most practical systems
already accept, and that many uses need mutual exclusion in
the common case rather than proof against adversarial
schedules. Both essays reward a full read, and they agree on
more than the framing suggests. The operational line is the
cost of a stale lock. If it wastes work, single-instance
SET NX EX is enough and Redlock adds machinery
without adding a fencing token. If it can corrupt state, the
fix is fencing enforced by the storage being protected, or
leases from a linearizable coordinator, not a bigger lock
service.
The ecosystem, briefly
One boundary is organizational rather than technical. The 2024 license change, when Redis moved off the BSD license to source-available terms, produced Valkey, the Linux Foundation fork that major distros and cloud providers adopted, and 2025 brought a partial reversal when Redis 8 added the AGPLv3 option, returning the project to an OSI-approved license. For everything in this chapter the two are interchangeable, Valkey forked from Redis 7.2 and kept the architecture, and every design above runs unchanged on either. KeyDB is an earlier fork that made the server multithreaded, and Dragonfly is a from-scratch multithreaded implementation of the same protocol on a different internal architecture under a source-available license, both worth knowing as evidence that the protocol has outgrown any single steward. This chapter follows the Redis source because it remains the reference implementation and the most readable of the family.
Part VI: The full life of one command
This is the core of the chapter. We follow
SET user:1000:name "Ada" and then
GET user:1000:name from the client's socket to the
reply, naming every file the bytes pass through. Once you can
narrate this path from memory, the repository reads like
annotations on a diagram you already own.
Stage 0: the bytes on the wire
The client speaks RESP, the REdis Serialization Protocol. A command is an array of bulk strings, length-prefixed so the parser never scans for delimiters inside values:
*3\r\n$3\r\nSET\r\n$14\r\nuser:1000:name\r\n$3\r\nAda\r\n
*3 announces three items, each $n
announces a bulk string of n bytes. The replies we expect back are
+OK\r\n for the SET and
$3\r\nAda\r\n for the GET. The protocol is simple
enough to speak by hand with nc localhost 6379, and
that simplicity is a design decision: parsing is cheap, and every
language gets a client for free.
Stage 1: accept, and a client is born
At startup, main() in server.c loads the
config, creates the event loop, and listens on port 6379 using the
small socket library in anet.c. When our client
connects, the accept handler creates a client struct
(defined in server.h: the query buffer, the argument
vector, the reply buffer, flags) and registers the new socket with
the event loop, setting readQueryFromClient as its
read handler. From this point the client is just another file
descriptor the loop watches.
Stage 2: ae.c wakes up
The heart of the server is aeMain() in
ae.c, a few hundred lines implementing an event loop
over whichever multiplexing syscall the platform offers:
ae_epoll.c on Linux, ae_kqueue.c on
macOS and BSD, with select as the fallback. The loop blocks in
epoll_wait until a registered descriptor is ready,
then fires the handler attached to it. Our SET arrives, the kernel
marks the socket readable, and the loop calls
readQueryFromClient. Before each blocking wait the
loop also calls beforeSleep(), a hook that matters at
the end of this story, and it schedules time events, chiefly
serverCron, the housekeeping tick that runs 10 times
a second by default.
Stage 3: networking.c reads and parses
readQueryFromClient in networking.c
reads from the socket into the client's query buffer, then calls
processInputBuffer, which hands our array-of-bulk
bytes to processMultibulkBuffer. The parser walks the
length prefixes and builds c->argv, an array of
three string objects: SET,
user:1000:name, Ada. Nothing has been
interpreted yet; Redis so far has only turned bytes into an argv,
exactly like a shell.
Stage 4: server.c looks up and vets the command
processCommand in server.c looks up
argv[0] in the command table. Since Redis 7 that
table is generated from JSON specifications under
src/commands/, one file per command
(set.json declares arity, flags, key positions), so
metadata that used to live in a giant C array is now data. With
the command found, the function runs the gauntlet: is the client
authenticated, do its ACLs permit this command, are we out of
memory and is this a write (if maxmemory is set,
this is where eviction is attempted before a denial), should the
write be refused because persistence is failing. Only then does
call() invoke the command's function pointer. This
one function is also where MULTI queuing, replication
propagation, and slow-log timing hook in, which is why it repays
slow reading.
Stage 5: t_string.c executes SET
setCommand in t_string.c parses options
(NX, EX, and friends) and delegates to
setGenericCommand, which stores the value into the
database. Two things happen on the way in. First, the value was
already wrapped as an robj, and
tryObjectEncoding in object.c tries to
shrink it: a string that parses as an integer becomes an
int encoding, anything of 44 bytes or fewer becomes
embstr, allocated in one block with its header, and
only longer strings get the general raw encoding over
an sds string from sds.c. Second, the
key goes into the keyspace.
Stage 6: dict.c stores the pair
Each database is fundamentally a hash table from
dict.c (Redis 8 wraps it in a kvstore layer, but the
dict is still the engine): chained buckets, powers-of-two sizing,
and the famous trick, incremental rehashing. When the table needs
to grow, Redis allocates the new table beside the old one and
migrates a few buckets per operation instead of stopping the
world to rehash millions of keys, because a single-threaded
server cannot afford any O(n) pause, so every O(n) job inside
Redis is either incremental, forked, or pushed to a background
thread. Our key hashes to a bucket, the entry is stored,
the keyspace notification and dirty counter for persistence tick
over, and the command is done in a few hundred nanoseconds of
actual data structure work.
Stage 7: the reply buffer
setGenericCommand ends with
addReply(c, shared.ok), appending the five bytes
+OK\r\n to the client's output. Replies go into a
fixed per-client buffer first, spilling into a linked list of
chunks only when they outgrow it (the logic is
_addReplyToBufferOrList in
networking.c; tiny replies like ours never leave the
fixed buffer). Crucially, no write happens yet. The client is
added to a pending-writes list, and when the event loop next
passes through beforeSleep(), Redis writes all
pending outputs directly, installing a write event handler only
for the sockets that could not take the whole reply at once. Most
replies therefore complete with zero extra trips through epoll. A
client that reads too slowly while its buffer grows past the
configured limits is disconnected
(closeClientOnOutputBufferLimitReached), which is
how Redis protects its memory from a stuck consumer.
Stage 8: GET, and the read path's one twist
The GET travels the same road: readable event, parse,
processCommand, then getCommand in
t_string.c, which calls
lookupKeyReadOrReply. The twist is inside the
lookup: before returning a value, Redis checks whether the key
has an expiry that has already passed, and if so deletes it and
reports a miss. Expiration in Redis is lazy first: a dead
key is reclaimed when someone touches it, and a background cycle
exists only to catch the keys nobody ever touches again.
Our key is alive, the value's bytes are appended as
$3\r\nAda\r\n, and the same beforeSleep pass flushes
it to the socket. Total path length for a GET: one epoll wakeup,
one read, a hash lookup, one write. That is the entire secret of
sub-millisecond Redis.
Part VII: Internals deep dives
Deep dive: the event loop, and where the threads actually are
"Redis is single-threaded" is the most repeated and most oversimplified fact about it. The precise claim: command execution is single-threaded, so every command is atomic by construction, and the data structures need no locks; but the process is not single-threaded, and knowing where the other threads are is the real understanding. The design works because the workload cooperates: typical commands are O(1) or O(log n) touches of RAM taking microseconds, so the bottleneck is the network, not the CPU. One thread that never waits beats many threads that contend, and the code gets to be simple: no data races, no lock ordering, no lost wakeups, anywhere in the command path.
The threads that do exist form three groups. I/O threads, off by
default and enabled with io-threads N, parallelize
socket reading, writing, and protocol parsing when a single core
saturates on network work; commands still execute serially on the
main thread, so atomicity survives. The background thread pool in
bio.c takes jobs that would otherwise stall the
loop: closing files (which can block in the kernel), AOF fsync,
and lazy freeing, where UNLINK or eviction hands a
huge object to a background thread to deallocate instead of
freeing millions of allocations inline. And forked children
handle RDB saves and AOF rewrites, as the persistence dive
explains. The corollary of one execution thread is the famous
trap: one slow command stalls everyone, which is why
KEYS, SMEMBERS on giant sets,
unbounded LRANGE, and O(n) Lua scripts are the
classic production incidents, and why SCAN and its
cousins exist.
main thread: ae loop ▶ parse ▶ execute ▶ reply (the only place commands run) io threads: read sockets / write sockets / parse (optional, io-threads N) bio threads: close(fd) │ aof fsync │ lazy free (bio.c, always present) fork children: RDB save │ AOF rewrite (whole-process snapshots)
Deep dive: the object system and adaptive encodings
Every value is an robj (defined in
server.h, managed in object.c): a type,
an encoding, a 24-bit clock field used by LRU/LFU, a refcount,
and a pointer. The type is the command interface, string, list,
set, hash, sorted set, stream; the encoding is the concrete
representation currently in use, and the trick of the
object system is that a type keeps one interface while swapping
its data structure underneath as the value grows. Small
collections are stored as a listpack, a compact serialized buffer
scanned linearly, because for a handful of elements a linear scan
of contiguous bytes beats a hash table in both space and real
time. Past configured thresholds, the collection converts, once
and irreversibly, to the pointer-based structure that scales.
| Type | Small encoding | Converts to | Threshold (defaults, Redis 8.0 redis.conf) |
|---|---|---|---|
| string | int / embstr | raw | embstr up to 44 bytes |
| hash | listpack | hashtable | 512 entries / 64-byte values |
| list | listpack | quicklist | list-max-listpack-size -2 (8 KB per node) |
| set | intset or listpack | hashtable | 512 ints / 128 entries, 64-byte values |
| sorted set | listpack | skiplist + dict | 128 entries / 64-byte values |
The scaled-up structures are worth knowing by name. A large list
is a quicklist (quicklist.c), a doubly linked list
whose nodes are themselves listpacks, a two-level design that
keeps memory compact while making push and pop at both ends O(1).
A large sorted set (t_zset.c) is the classic
pairing: a skiplist for order and range queries plus a dict for
O(1) member-to-score lookup, both pointing at shared elements.
The trap to correct: encodings never convert back down when a
collection shrinks, and a single oversized value flips the whole
collection, so a hash of 511 tiny fields plus one 100-byte value
is a hashtable. Watch it happen yourself with
OBJECT ENCODING key, which is Lab 2, and the
cheapest systems lesson available anywhere.
Deep dive: persistence from a process that never wants to block
Redis offers two persistence forms with one shared trick. RDB
(rdb.c) is a point-in-time binary snapshot of the
whole dataset written to dump.rdb; the default
config asks for one after 3600 seconds if at least 1 key changed,
300 seconds if 100 changed, or 60 seconds if 10000 changed. AOF
(aof.c, off by default) logs every write command and
replays the log at startup; since Redis 7 it is multi-part,
living in an appendonlydir/ directory as a base
snapshot plus incremental append files and a manifest, which made
the rewrite path simpler and safer than the old single-file
dance.
The shared trick is fork(). For a background save
the process forks; the child sees a frozen copy of all memory,
courtesy of the kernel's copy-on-write page tables, and serializes
at leisure while the parent keeps serving. The cost is physical:
as the parent mutates pages, the kernel copies them, so a
write-heavy workload during a save can grow resident memory
toward two copies of the hot pages, and the fork itself must copy
page tables, a pause proportional to dataset size that
INFO reports as latest_fork_usec. AOF
rewrite uses the same fork: the child writes a compact base from
the frozen state while the parent accumulates new increments.
The durability dial is appendfsync:
always (fsync every write, the slow-but-safest
setting), everysec (the default: fsync once per
second from a bio.c background thread, so a crash
loses at most about a second), and no (let the OS
decide). The misconception to correct: AOF-everysec Redis
is not a durable database in the Postgres sense; an acknowledged
write can still die with the process, and replication is
asynchronous, so failover can lose acknowledged writes too.
The posture most deployments land on is AOF everysec plus RDB
snapshots for fast restarts and backups, and the same RDB
machinery doubles as the transfer format when a replica first
synchronizes (replication.c).
Deep dive: expiration and eviction
Expiration answers "this key has a TTL"; eviction answers "we are
out of memory, sacrifice something." They are different machines.
Expiries live in a separate per-database dict mapping key to
expiry time. Reclamation is lazy first, as the GET path showed:
expireIfNeeded deletes a dead key at touch time.
The active side (expire.c) runs from
serverCron: each cycle samples 20 keys per database
from the expires dict, deletes the dead ones, and if more than
10% of the sample was dead, loops again, all under a CPU budget
of 25% of cron time. The effect is a probabilistic guarantee: the
fraction of already-dead keys still holding memory stays around
one in ten, without ever scanning the keyspace.
Eviction (evict.c) triggers on the write path when
maxmemory is exceeded. The policy grid is two
axes: which keys are candidates (allkeys-* or
volatile-*, the latter only keys with TTLs) and how
victims are chosen (lru, lfu,
random, or volatile-ttl for
shortest-TTL-first), plus the default noeviction,
which refuses writes with an error instead. The LRU is
deliberately approximate: instead of maintaining a global
recency list (pointer overhead on every object, cache-hostile
updates on every touch), each robj carries a 24-bit last-access
clock, and eviction samples maxmemory-samples keys
(default 5), keeping a small pool of the best candidates seen and
evicting the oldest. The redis.io docs show that at 10 samples
the approximation is close to true LRU at a fraction of the
cost. LFU reuses the same 24 bits as a logarithmic access
counter plus decay timestamp, tuned by
lfu-log-factor (default 10) and
lfu-decay-time (default 1 minute), so a burst
yesterday does not outrank steady traffic today. The trap:
volatile-* policies can only evict keys that have TTLs, so
a workload that never sets expiries under volatile-lru behaves
exactly like noeviction and fails writes at the limit.
Part VIII: Reading the repository
Everything lives flat in src/. Read it in stages,
each with questions you should be able to answer before moving
on.
Stage 0, the loop. Read ae.c in
full, with a glance at ae_epoll.c; it is short and
self-contained. Then in server.c read
main(), initServer(), and
beforeSleep(). Questions: what are the two kinds of
events ae supports? Where does the loop block? What work does
Redis deliberately do right before blocking?
Stage 1, one command end to end. In
networking.c: readQueryFromClient,
processMultibulkBuffer, addReply. In
server.c: processCommand and
call(). In t_string.c:
setCommand and getCommand. Glance at
src/commands/set.json to see the generated command
table's source of truth. Questions: where exactly could a
command be rejected before executing? Why do replies not write
to the socket immediately? What makes every command atomic?
Stage 2, the data structures.
dict.c (incremental rehashing is a gem worth reading
twice), sds.c, listpack.c,
quicklist.c, then object.c for
tryObjectEncoding and the conversion logic, then one
type file, ideally t_zset.c for the
skiplist-plus-dict pairing. Questions: how does a dict rehash
without pausing? At what moments does a listpack become a
hashtable? Why does a sorted set need two structures?
Stage 3, durability. rdb.c
(rdbSaveBackground and the fork),
aof.c (the multi-part manifest and rewrite), and
bio.c. Questions: what precisely does the child
process see after fork? What can be lost under everysec? Which
three job types does bio run and why those three?
Stage 4, memory pressure. expire.c
and evict.c. Questions: why sample instead of
scanning? Why is the LRU clock only 24 bits? What does
volatile-lru do with no TTLs set?
Where not to start: cluster.c and
its siblings (a distributed system grafted onto the server, best
read only after the standalone story is solid),
replication.c's partial-resync edge cases, and the
scripting layer. None of them make sense until the single-node
loop is second nature.
Part IX: Hands-on labs
Lab 1: watch the traffic, measure the latency
redis-cli MONITOR # terminal 1: every command, live
redis-benchmark -t set,get -n 100000 -q # terminal 2
redis-cli --latency # then: sustained latency probe
MONITOR prints every command the server executes
(never leave it on in production; it costs real throughput).
--latency shows the round-trip floor, typically a
fraction of a millisecond on localhost; numbers vary by machine.
Concept taught: the request path from Part VI, observed from
outside.
Lab 2: watch an encoding transition
redis-cli DEL h
redis-cli HSET h f1 v1
redis-cli OBJECT ENCODING h # "listpack"
for i in $(seq 1 600); do redis-cli HSET h f$i v$i > /dev/null; done
redis-cli OBJECT ENCODING h # "hashtable" (past 512 entries)
redis-cli DEL h
redis-cli HSET h big $(python3 -c "print('x'*100)")
redis-cli OBJECT ENCODING h # "hashtable" (one 100-byte value > 64)Concept taught: adaptive encodings and their thresholds, and that one oversized value flips the whole collection.
Lab 3: force a snapshot, observe the fork
redis-benchmark -t set -n 1000000 -r 1000000 -q # fill some memory
redis-cli BGSAVE
redis-cli INFO persistence | grep -E "rdb_bgsave_in_progress|latest_fork_usec|rdb_last_bgsave_status"
latest_fork_usec is the pause the fork itself cost;
watch it grow if you load more data and save again. On Linux,
ps aux | grep redis during the save shows the child
process, and repeating the INFO persistence call
during a write-heavy save shows copy-on-write growth in the
current_cow_size field. Concept taught: fork-based
snapshotting and its memory cost.
Lab 4: eviction policies in miniature
redis-cli CONFIG SET maxmemory 20mb
redis-cli CONFIG SET maxmemory-policy noeviction
redis-benchmark -t set -n 200000 -r 200000 -d 512 -q # writes start erroring
redis-cli CONFIG SET maxmemory-policy allkeys-lru
redis-benchmark -t set -n 200000 -r 200000 -d 512 -q # writes succeed, old keys go
redis-cli INFO stats | grep evicted_keys
redis-cli CONFIG SET maxmemory 0 # cleanup
Concept taught: the noeviction default versus LRU sampling, and
evicted_keys as the counter that proves it.
Lab 5: one slow command stalls everyone
redis-cli --latency # terminal 1: leave running, note the baseline
redis-cli DEBUG SLEEP 2 # terminal 2: block the loop for 2 seconds
The latency probe spikes to roughly two thousand milliseconds for
one sample and recovers. Concept taught: single-threaded
execution means every client shares the fate of the slowest
command; this is exactly what a stray KEYS does in
production.
Part X: Understanding checks
What is Redis in one sentence? An in-memory data structure server: a dictionary of typed, adaptively encoded values, executed one command at a time by a single-threaded event loop, with durability handled by forked children and background threads.
Why is single-threaded execution fast rather than a bottleneck? Because commands are microsecond-scale memory operations, the real constraint is network I/O, not CPU. One thread that never blocks avoids all locking, contention, and context-switch costs, and I/O threads can be added for the network half without touching execution atomicity.
What actually runs on other threads or processes? Optional I/O threads for socket read/write and parsing; the bio.c pool for file closes, AOF fsync, and lazy frees; and forked children for RDB saves and AOF rewrites. Command execution itself is always the main thread.
Why is every Redis command atomic? Because only one command executes at a time on the one execution thread. There is no interleaving to protect against, so atomicity is a structural property, not a locking achievement.
Walk the path of a SET. Readable event in ae.c fires readQueryFromClient in networking.c; processMultibulkBuffer parses RESP into argv; processCommand in server.c looks up the command table and runs ACL and memory checks; setCommand in t_string.c stores an encoded robj into the dict.c keyspace; addReply queues +OK, flushed to the socket in the next beforeSleep pass.
What problem does incremental rehashing solve? Growing a hash table normally requires an O(n) stop-the-world rehash, which a single-threaded server cannot afford. Redis keeps old and new tables side by side and migrates a few buckets per operation, bounding every pause while the table converges.
Why do small hashes use a listpack instead of a hash table? A listpack is one contiguous allocation scanned linearly, so for a few dozen elements it beats a hash table on memory (no pointers, no buckets) and often on time (cache locality). The encoding converts to a real hashtable at 512 entries or a 64-byte value by default, and never converts back.
What does fork-based persistence cost? The fork itself pauses the server proportionally to page-table size (visible as latest_fork_usec), and copy-on-write means a write-heavy workload during a save duplicates dirtied pages, so memory headroom must budget for the divergence.
Can AOF with everysec lose data? Yes, by design: writes are acknowledged before the fsync that happens once per second on a background thread, so a crash can lose about the last second. appendfsync always closes that window at a large throughput cost, and failover to an async replica can lose acknowledged writes regardless.
How does Redis expire millions of keys without scanning them? Lazily on access (a touched dead key is deleted and reported missing), plus an active sampling cycle: 20 keys sampled per database per iteration, looping while more than 10% of the sample is expired, capped at 25% of cron CPU. The result is a bounded fraction of stale keys, found without any full scan.
How approximate is approximated LRU? Each object stores a 24-bit access clock; eviction samples five keys by default, pools the best candidates, and evicts the oldest seen. It is not true LRU, but with 10 samples the redis.io docs show behavior close to exact LRU, and it costs no global list and no per-access pointer maintenance.
When is Memcached the better choice? When the need is exactly a flat, volatile string cache at very high connection counts: Memcached is multithreaded and can saturate more cores per instance for that one job. The moment you want data structures, persistence, replication, or atomic server-side operations, Redis wins.
Why did a write suddenly return an OOM error under memory pressure? maxmemory was reached under the default noeviction policy, or under a volatile-* policy with no TTL-bearing keys to evict. Either configure allkeys-lru (or lfu) for cache workloads, or treat the error as backpressure for store workloads.
Your p99 latency spikes every few minutes; where do you look first? Suspect loop stalls: a periodic O(n) command (SLOWLOG GET), expiry storms of large keys, fork pauses at BGSAVE/rewrite (latest_fork_usec), or a swap-thrashing host. The single-threaded model means anything slow shows up as everyone's latency, which paradoxically makes diagnosis easy.
Why is the reply buffered instead of written inside the command? Writing inside execution would let one slow client block the loop mid-command. Buffering decouples execution from socket readiness: the loop writes when the socket can take it, and output-buffer limits disconnect consumers that fall too far behind.
Part XI: Design lessons
One writer, no locks, as an architecture. Redis gets atomicity and simplicity by serializing all mutation through one thread and moving everything slow off that thread. The same shape appears in Node.js, in LMAX-style trading engines, and in every actor system: if the critical section is everything, make it fast instead of concurrent.
Never pay O(n) in the foreground. Every unavoidable O(n) job in Redis is amortized (dict rehashing, SCAN cursors), sampled (expiry, LRU), forked (RDB, AOF rewrite), or backgrounded (lazy free, fsync). That inventory of four techniques is reusable verbatim in any latency-sensitive service.
Interface stability over representation stability. The type/encoding split lets Redis change data structures under a fixed command surface, per key, at runtime, by size. The same idea is adaptive representations in V8 (small-integer to heap number, shape transitions) and adaptive indexes in databases.
Approximation as a budget decision. Approximate LRU with 5 samples and probabilistic expiry with a 10% stale target trade a little precision for enormous constant-factor wins, and both expose the dial (maxmemory-samples, active-expire-effort) instead of hiding the trade. Sampling instead of scanning generalizes to hot-key detection, quantile estimation, and load shedding.
Borrow the kernel. Copy-on-write fork turns "get a consistent snapshot of gigabytes without stopping" from a hard concurrent-programming problem into one syscall. Recognizing which hard problems the OS has already solved (mmap, sendfile, io_uring elsewhere) is a recurring senior move.
Part XII: Memorization framework
The one-sentence summary: Redis is a single-threaded event loop executing atomic commands against an in-memory dict of adaptively encoded objects, with all slow work forked, backgrounded, sampled, or amortized.
Socket → ae → RESP → dispatch → t_*.c → dict/robj → reply buffer → socket ae.c → networking.c → server.c → t_string.c → dict.c/object.c → networking.c
Memorize these blocks:
The loop: ae.c over epoll/kqueue; commands execute only on the main thread; io-threads (optional) for socket I/O; bio.c for close/fsync/lazyfree; fork for RDB and AOF rewrite.
The encodings: embstr up to 44 bytes; hash listpack up to 512 entries/64-byte values; zset and set listpack up to 128; intset up to 512; conversions are one-way.
Durability: RDB via fork + copy-on-write, default save points 3600/1, 300/100, 60/10000; AOF off by default, appendfsync everysec loses up to about a second; Redis 7+ AOF is multi-part in appendonlydir/.
Memory pressure: expiry is lazy plus active sampling (20 keys, 10% stale target, 25% CPU cap); eviction is policy grid (allkeys/volatile × lru/lfu/random/ttl), default noeviction, LRU approximated by 5-key sampling over a 24-bit clock.