Skip to content
Select theme

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.

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.

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> PING
PONG
127.0.0.1:6379> SET greeting hello
OK
127.0.0.1:6379> GET greeting
"hello"
127.0.0.1:6379> LPUSH queue job1 job2 job3
(integer) 3
127.0.0.1:6379> LRANGE queue 0 -1
1) "job3"
2) "job2"
3) "job1"
127.0.0.1:6379> EXPIRE greeting 100
(integer) 1
127.0.0.1:6379> TTL greeting
(integer) 100
127.0.0.1:6379> DEL queue
(integer) 1
127.0.0.1:6379> TTL queue
(integer) -2
127.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.

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.

# 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
  • 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 2 on an ordinary connection, and the clone answers the same dialect.
  • Not a Redis replacement. No Lua scripting, no streams, no cluster, no AUTH, no SELECT between 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.

Every step is written and verified against rustc 1.99.0 with edition 2024, the compiler and edition the rest of this site uses.

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.