Redis

Redis is an in-memory data structure server, created in 2009 by Salvatore Sanfilippo and stewarded today by Redis Ltd, which after a period under source-available licenses is offered under the AGPLv3 again as of Redis 8. It is the default cache, queue, and leaderboard of the industry, and it is also one of the most readable C codebases you will ever open. This is a full chapter, not a tour: a practical tutorial, a systems-internals walkthrough that follows one SET and one GET from the client socket through the event loop to the reply buffer, worked systems designs for where Redis belongs and where it does not, deep dives into the four load-bearing subsystems, a staged plan for reading the repository, and labs you can run tonight. File names and defaults were checked against the Redis 8.0 source tree.

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 0

Mistake 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-based

A 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.

TypeSmall encodingConverts toThreshold (defaults, Redis 8.0 redis.conf)
stringint / embstrrawembstr up to 44 bytes
hashlistpackhashtable512 entries / 64-byte values
listlistpackquicklistlist-max-listpack-size -2 (8 KB per node)
setintset or listpackhashtable512 ints / 128 entries, 64-byte values
sorted setlistpackskiplist + dict128 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.

Key takeaway: Redis is a demonstration that doing one thing at a time can be a performance strategy: a single-threaded loop over in-memory data structures makes every command atomic and lock-free, adaptive encodings make those structures cheap, fork with copy-on-write lets durability happen off to the side, and sampling makes expiry and eviction affordable, so the loop never stops to do anything slow.