What we're building: a Redis clone
Step 1 of 10, and it runs nothing. This is the map: what the finished server does, the commands it answers, and the nine steps between here and there. Every step starts from the state the previous one left behind, and each ends tagged in the examples repository, so the tags form one project growing a piece at a time.
A Redis clone is the standard exercise in network programming, and it earns the reputation. The protocol is small enough to hold in your head. Everything hard sits underneath it: many clients at once, all touching the same keys; keys that expire on their own; state that has to survive a restart. Build one and you have written a threaded server, a parser that copes with a half-delivered packet, and a storage engine, in that order.
The finished server
Section titled “The finished server”One binary, mini-redis, started with no arguments. It listens on 127.0.0.1:6379, the port real
Redis uses, so the real redis-cli connects without a flag, and it answers RESP until it’s stopped.
By the end of step 8 it speaks sixteen commands; steps 9 and 10 prove it works and measure how fast
it is.
The value model is deliberately small. A key holds either a string or a list of strings, and optionally a time to live. That is enough to exercise every interesting part of a server (parsing, dispatch, shared mutable state, expiry, fan-out, queueing) without the type zoo that real Redis grew over fifteen years.
The command set
Section titled “The command set”| Command | What it does | Step |
|---|---|---|
PING |
liveness probe; also queues inside MULTI |
2 |
ECHO message |
round-trips one bulk string, the codec’s test command | 3 |
SET key value |
stores a string | 4 |
GET key |
reads it back, nil when the key is missing |
4 |
DEL key [key …] |
deletes, and answers how many keys existed | 4 |
LPUSH key value [value …] |
prepends to a list, answers the new length | 4 |
LRANGE key start stop |
reads a slice of that list | 4 |
EXPIRE key seconds |
attaches a time to live | 5 |
TTL key |
seconds left: -1 for no expiry, -2 for no key |
5 |
SAVE |
writes the snapshot file | 6 |
SUBSCRIBE channel [channel …] |
joins channels, and stops answering ordinary commands | 7 |
UNSUBSCRIBE [channel …] |
leaves them | 7 |
PUBLISH channel message |
fans a message out, answers the subscriber count | 7 |
MULTI |
starts queueing commands instead of running them | 8 |
EXEC |
runs the queue, atomically | 8 |
DISCARD |
drops the queue | 8 |
Here is the real redis-server 7.2.4 answering the real redis-cli, captured while this article was written. It is the reference the clone matches, command for command:
127.0.0.1:6379> PINGPONG127.0.0.1:6379> SET greeting helloOK127.0.0.1:6379> GET greeting"hello"127.0.0.1:6379> LPUSH queue job1 job2 job3(integer) 3127.0.0.1:6379> LRANGE queue 0 -11) "job3"2) "job2"3) "job1"127.0.0.1:6379> EXPIRE greeting 100(integer) 1127.0.0.1:6379> TTL greeting(integer) 100127.0.0.1:6379> DEL queue(integer) 1127.0.0.1:6379> TTL queue(integer) -2127.0.0.1:6379>Two replies carry information beyond the value itself. TTL answers -1 for a key with no expiry
attached and -2 for a key that isn’t there at all, because a missing key is not an error. And
LRANGE returns the list head-last, since LPUSH prepends: the last value pushed is the first one
read back.
The one idea: two problems, in order
Section titled “The one idea: two problems, in order”Everything in the track falls out of two problems, and the step order is the order they have to be solved in.
Framing. A RESP frame is length-prefixed. The first byte says what kind of value follows, and for
the two kinds that carry data (bulk strings and arrays), a line of ASCII digits says how many bytes,
or how many elements, are coming. Encoding a complete frame is an afternoon’s work. Decoding half of
one is the actual job: read returns what has arrived, not what was asked for, so a frame split
across two packets has to wait in a buffer for the rest of itself. Step 2 encodes and decodes
complete frames by hand, with no crate and no runtime. Step 3 is the decoder that survives the
split, and it is where the protocol stops being a toy.
Dispatch. A server accepts connections in a loop, and each connection needs its own task, because
a client that sends half a command and then thinks for a minute must not stop the others. Every one
of those tasks touches the same store, so the store needs a lock, and the lock is where a naive
server falls over. Step 4 puts Tokio in: an accept loop, a task per connection, and one Arc<Db>
shared between them.
one task per connection one shared store ──────────────────────────────────── ───────────────────────accept loop ──► read frame ──► parse ──► dispatch ──► read / write ──► Arc<Db> (step 4) ▲ ▲ │ │ the next frame the sweeper from that client (step 5)Two mechanisms land on top of that shape. The sweeper (step 5) walks the keys on a schedule and
reclaims the expired ones without blocking anyone. Pub/sub (step 7) adds a broadcast bus beside the
store, so PUBLISH reaches tasks that are parked in SUBSCRIBE instead of reading commands.
The route
Section titled “The route”| # | Article | What lands |
|---|---|---|
| 2 | Speaking the RESP protocol | frames encoded and decoded by hand; a server that answers PING |
| 3 | Parsing and encoding RESP | a decoder that survives a split packet; ECHO |
| 4 | Handling concurrent connections | Tokio, one task per connection, the shared store, and the first real commands |
| 5 | Storing values with expiry | EXPIRE, TTL, and the sweeper |
| 6 | Persistence with a snapshot | SAVE, and the load on startup |
| 7 | Pub/sub messaging | SUBSCRIBE, PUBLISH, and fan-out |
| 8 | Transactions with MULTI and EXEC |
queueing per connection, atomic execution |
| 9 | Testing a network server | integration tests over a real socket |
| 10 | Benchmarks, and what real Redis does | throughput, a sharded store, and the gaps that remain |
What this clone is not
Section titled “What this clone is not”- Not a replica, and not append-only. The landing page for this track promises replication and an append-only file. The ten steps that exist build neither, and this article keeps the promises the steps can keep; step 10 names what is missing. Real Redis persists both ways (the RDB snapshot and the AOF journal), and step 6 writes a snapshot of the clone’s own, because a format you can read in an afternoon is the point of the exercise.
- Not RESP3. RESP2 is what clients speak unless they negotiate something newer; the redis-cli on
this machine reports
proto 2on an ordinary connection, and the clone answers the same dialect. - Not a Redis replacement. No Lua scripting, no streams, no cluster, no AUTH, no
SELECTbetween numbered databases, no blocking pops. One database, sixteen commands, one process. - Not async from the first line. Steps 2 and 3 use nothing but the standard library (a blocking
TcpStream, read and written by hand), because the protocol is easier to get right with no runtime in the way. Tokio arrives at step 4, when there is something worth scheduling.
What you need first
Section titled “What you need first”- Async, comfortably. One task per connection is the entire concurrency model, and step 4
assumes
asyncand.awaitneed no explaining. Async fundamentals and Async in practice cover what the track uses. - Shared state. The store sits behind
Arcand a lock, and the lock is where the interesting bugs live. Sharing state between threads, Interior mutability and Smart pointers and sharing are the prerequisites. Resultand?. A client that disconnects mid-command is an error to handle, not a crash. Handling errors covers the pattern.
Every step is written and verified against rustc 1.99.0 with edition 2024, the compiler and edition the rest of this site uses.
Where the code lives
Section titled “Where the code lives”Step 2 starts the project at mini-redis/ in the root of
the examples repository and tags it mini-ch02. Every
step after that commits its end state and tags it (mini-ch03, mini-ch04, and so on), so
git checkout mini-ch06 is a server that runs. This article commits nothing; the first tag lands
with step 2.
Step 2 speaks the first bytes of RESP: frames encoded and decoded by hand, and a server that answers
PING. The track overview keeps the full list current as articles land.