Redis handles a huge number of requests per second on one core, and it does it without locks, threads or anything clever in the hot path. It reads bytes off sockets, parses a very simple protocol, touches a hash table in memory, and writes bytes back. The speed comes from never waiting.
You'll build a server that the real redis-cli and redis-benchmark can talk
to. It starts as a loop that answers PING, and grows into a store with several
data types, key expiry, two kinds of persistence and a replica that follows the
primary. Along the way you'll write an event loop by hand. Most engineers have
probably used one for years without ever seeing it.
01Why build this
Redis sits in front of a large share of production databases, and it fails in ways that make much more sense once you've built one:
- Event loops stop being abstract. Node, nginx, Netty and Redis all run on
the same idea: one thread, non-blocking sockets and
epollorkqueue. You'll have written one. - Latency spikes get an explanation. A single slow
KEYS *blocks every client, because there's only one thread. You'll see it happen in your own server. - "Is Redis durable?" gets a precise answer. You'll know what
appendfsync everyseccan lose and why aBGSAVEdoubles memory on a busy primary. - Replication lag becomes concrete. You'll know what the replica has, what it's missing, and how it catches up after a disconnect.
It's also a pretty friendly first network server. Every milestone produces something
you can poke at with redis-cli.
02What you're building
One command's trip through the finished server:
*5\r\n$3\r\nSET\r\n... to its TCP connection. The bytes sit in the kernel's receive buffer.?Why can one thread be fast enough?
Because each command does very little work, all of it in memory. The expensive part of a request is waiting for the network, and an event loop never waits: it asks the kernel which sockets are ready and handles only those. Adding threads would add locks around the keyspace and buy almost nothing until the network itself is saturated.
03Before you start
| You need | Why | Where to get it |
|---|---|---|
The real redis-cli and redis-benchmark | Your test client and load generator | Install Redis from your package manager; don't run its server |
| The RESP specification | Every byte on the wire | redis.io, under "Redis serialization protocol specification" |
Sockets: socket, bind, listen, accept | You'll manage connections yourself | Chapter 10 |
Non-blocking I/O and epoll or kqueue | The core of milestone 2 | man 7 epoll, or man 2 kqueue on macOS |
| One language with access to raw sockets | Go and Rust are easiest; C matches Redis's own source | Any |
04The roadmap
Eight milestones. Each one works with the real client, so you can test with a tool you didn't write.
Answer PING
1 eveningListen on port 6379, accept a connection, and read what the client sends.
redis-cli sends every command as a RESP array of bulk strings: an
asterisk and a count, then each argument as a length-prefixed string.
Write a parser for that shape and reply with +PONG\r\n. Keep the parser
incremental from day one: it should be able to say "not enough bytes yet"
without throwing away what it has.
redis-cli PING prints PONG and redis-cli ECHO hi prints hi.Many clients, one thread
1 weekendMake the listening socket and every client socket non-blocking. Register them
with epoll (or kqueue), and loop: wait for events, accept new clients, read
from readable ones, and write pending output to writable ones.
In Go it's tempting to start a goroutine per connection instead. That works, but then you need a lock around the keyspace. Build the single-threaded loop at least once. It's the design decision the rest of Redis is built around.
redis-cli sessions stay connected at once, and redis-benchmark -t ping finishes without errors.Strings and a keyspace
1 eveningAdd a hash table from key to value and implement GET, SET, DEL, EXISTS
and INCR. Return the right reply types: bulk strings, integers, nil, and
-ERR messages that match what real Redis says closely enough for clients to
parse.
Run redis-benchmark with pipelining. If you handled partial reads properly in
milestone 2, it works. If you didn't, this is where it breaks.
redis-benchmark -t set,get -P 16 runs clean, and INCR on a non-integer returns an error reply.Expiry
1 weekendKeep a second table from key to expiry time. Check it on every access and delete the key if it's past due. That's lazy expiry, and it's enough for correctness.
It isn't enough for memory: keys nobody reads again never get deleted. Add a periodic task, run from the event loop, that samples a few keys with a TTL and deletes the expired ones, repeating while a large share of the sample was expired. Redis does roughly this, trading perfect cleanup for bounded work per tick.
EX 1 is gone after a second, and a million expired keys are cleaned up without a client touching them.Lists, hashes, sets and sorted sets
1–2 weekendsMake values tagged by type and implement LPUSH/LRANGE, HSET/HGET,
SADD/SMEMBERS, and the sorted set commands. Using a key with the wrong
command returns WRONGTYPE.
Sorted sets are the interesting one. Redis pairs a hash table (member to score) with a skip list ordered by score, so it can both look up a member and walk a range quickly. Write the skip list yourself. It's short, a bit fiddly at first, and it's a data structure you'll recognise in other systems.
ZADD, ZRANGE ... WITHSCORES and ZRANK agree with real Redis on the same inputs.Snapshots: RDB
1 weekendSerialise the keyspace to a file with your own compact format. SAVE does it
in the loop and blocks everyone. BGSAVE calls fork(): the child writes the
snapshot while the parent keeps serving, and the kernel's copy-on-write
pages give the child a frozen view for free.
Write to a temp file and rename it into place. Then watch memory while you
write heavily during a BGSAVE. Every page the parent touches gets copied.
A busy primary can need a lot more RAM during a snapshot for that reason.
SAVE and BGSAVE write a file, a restart loads it, and clients keep getting replies while BGSAVE runs.The append-only file
1 weekendAppend every write command, in RESP, to a log file. On startup, replay it.
Offer the same three policies Redis does: fsync after every write, once per
second, or never and let the OS decide. Measure the throughput of each.
The log grows forever, so add a rewrite: fork, write the smallest set of commands that rebuilds the current keyspace, and append whatever changed meanwhile before swapping files. Handle a half-written last command on replay.
appendfsync always, kill -9 loses no acknowledged write, and BGREWRITEAOF shrinks the file without losing data.A replica
1–2 weekendsWhen a replica connects, the primary sends a full snapshot and then streams every write command after it. Track a replication offset: the number of bytes of command stream sent. The replica reports how far it got.
Keep a fixed-size backlog of recent commands on the primary. A replica that reconnects with an offset still inside the backlog gets only the missing part, not a new snapshot. Then kill the primary right after a write and notice that the replica may never have seen it. That's asynchronous replication.
REPLICAOF receives every write, and after a brief disconnect it catches up without a full resync.05Traps that catch everyone
| Symptom | Cause | Fix |
|---|---|---|
Works in redis-cli, fails under redis-benchmark | You assumed one read is one command | Buffer per client; parse in a loop until the buffer holds no complete command |
| Large replies get cut off | A non-blocking write sent only part of the buffer | Keep the rest in the output buffer and wait for writability |
| The server stalls for everyone | A slow command, or a blocking disk write, inside the event loop | Keep O(n) work bounded; move disk flushing to a background thread or child |
| Memory keeps growing with expired keys | Only lazy expiry | Add the sampling expiry cycle |
| TTLs jump after an NTP adjustment | Wall-clock time used for relative expiries | Use a monotonic clock for timers; store absolute Unix times only for persistence |
| RDB file is corrupt after a crash | Snapshot written in place | Write to a temp file, fsync, rename |
| Replica diverges after reconnecting | Offsets counted in commands on one side and bytes on the other | Count bytes of the RESP stream everywhere |
06Stretch goals
- Pub/sub.
SUBSCRIBEandPUBLISH, where a subscribed connection changes mode and receives pushed messages. - Transactions.
MULTI/EXECandWATCH: optimistic concurrency in about a page of code. - Blocking commands.
BLPOPparks a client until a list gets an element, without blocking the loop. - Eviction. A
maxmemorylimit with approximated LRU or LFU, the way Redis samples instead of keeping an exact order. - Cluster mode. Hash slots,
MOVEDredirects, and a client that follows them. Chapter 29 covers partitioning.
07References worth your time
The RESP spec on redis.io. Short, precise, and the only document you need for milestone 1.
A free online book that builds a Redis-like server from sockets up, including an event loop, a hash table and a sorted set.
A staged challenge with automated tests for each step, including RDB loading and replication. Useful as a test suite even if you follow this roadmap.
The event loop, the sorted set and replication, in readable C. Look at each one after the matching milestone.
The official explanation of RDB, AOF, fsync policies, PSYNC and the backlog, with the trade-offs stated plainly.
The 1990 paper that introduced the structure behind sorted sets. A few pages long and easy to implement from.