Building a Redis clone in Rust
· 5 min read
Building a Redis clone in Rust
I use Redis at work, and for a long time it was a black box to me: you send SET, you get OK, things are fast. Last December I decided to open the box by building my own version in Rust. It's called Redis-Rust, it's about 900 lines of Rust, and it taught me more about async Rust than any tutorial did.
This post walks through how it works, the parts I'm happy with, and the parts that are still honestly a bit naive.
What it supports
A small but real slice of Redis:
- Strings:
SET,GET,DEL, plusSET key value EX secondsfor expiry - Lists:
LPUSH,RPUSH,LPOP,RPOP,LRANGE - Sets:
SADD,SREM,SISMEMBER,SMEMBERS - Sorted sets:
ZADD,ZREM,ZRANGE,ZSCORE PING, because every server needs one
It listens on port 6379 like the real thing, and replies in RESP, the Redis wire protocol.
One task per connection
The server is a Tokio TcpListener in a loop. Every accepted socket gets its own task, and every task gets a clone of an Arc pointing at the shared database:
loop {
let (socket, client_addr) = listener.accept().await.unwrap();
let db = db.clone();
spawn(async move {
let (reader, mut writer) = socket.into_split();
let mut reader = BufReader::new(reader);
let mut buffer = String::new();
while reader.read_line(&mut buffer).await.unwrap() > 0 {
let response = command_parser(&db, buffer.trim())
.await
.unwrap_or_else(|e| format!("-ERR {}\r\n", e));
writer.write_all(response.as_bytes()).await.unwrap();
buffer.clear();
}
});
}Tasks are cheap in Tokio, so one per connection is the simplest model that still scales. Splitting the socket into a reader and a writer keeps the ownership story simple: the reader goes into a BufReader, and the writer stays free for responses.
Commands as pattern matching
This is my favorite part of the codebase. Instead of writing a parser with a pile of ifs, I split the line on whitespace and match on the slice:
match parts.as_slice() {
["SET", key, value, "EX", ttl] => { /* set with expiry */ }
["SET", key, value] => { /* plain set */ }
["GET", key] => { /* ... */ }
["LRANGE", key, start, end] => { /* ... */ }
["PING"] => Ok("+PONG\r\n".to_string()),
_ => Ok("-ERR Unknown command\r\n".to_string()),
}Each arm reads almost like the Redis docs. The command name and the number of arguments are checked in one place, and a wrong argument count falls through to the error arm for free. Order matters, though: the SET ... EX arm has to come before plain SET.
One lock per data type
The database is a struct of maps, each behind its own RwLock:
pub struct Database {
db: Arc<RwLock<HashMap<String, String>>>,
expiry: Arc<RwLock<HashMap<String, Instant>>>,
list: Arc<RwLock<HashMap<String, RList>>>,
set: Arc<RwLock<HashMap<String, RSets>>>,
sorted_set: Arc<RwLock<HashMap<String, RSortedSet>>>,
}A RwLock lets many readers in at once, which suits a cache where reads dominate. Separate locks per type mean a burst of LPUSH traffic never blocks a GET.
These are std::sync locks, not Tokio's, and that is fine as long as you never hold a guard across an .await. The compiler helps here: a std lock guard isn't Send, so holding one across an await point inside a spawned task simply doesn't compile. That's why the expiry check scopes its guard in its own block before calling anything async.
Expiry is lazy
SET key value EX 10 stores the value and records an Instant ten seconds in the future. Nothing runs in the background. Instead, GET checks the deadline when you read the key:
// Check expiry - must drop guard before await
let is_expired = {
let exp_map = self.expiry.read().unwrap();
if let Some(exp) = exp_map.get(key) {
now > *exp
} else {
false
}
};
if is_expired {
self.delete(key).await;
return None;
}This is also roughly half of what real Redis does. The other half is an active cycle that samples keys with a TTL and evicts the expired ones, so keys nobody reads don't sit in memory forever. Mine only has the lazy half.
Sorted sets were the fun part
A sorted set needs two things to be fast: look up a member's score by name, and walk members in score order. No single standard collection does both, so RSortedSet keeps two:
- a
HashMap<String, OrderedFloat<f64>>for member to score lookups (ZSCORE) - a
BTreeSet<SortedMembers>that stays ordered by score, forZRANGE
The catch is that f64 doesn't implement Ord, because NaN isn't comparable to anything. The ordered-float crate wraps it in a type that does. Then a custom Ord sorts by score and breaks ties by member name, which is the same rule Redis uses:
impl Ord for SortedMembers {
fn cmp(&self, other: &Self) -> Ordering {
match self.score.cmp(&other.score) {
Ordering::Equal => self.member.cmp(&other.member),
other => other,
}
}
}Updating a score means removing the old entry from the BTreeSet first. The HashMap tells you which entry to remove. Keeping two structures in sync by hand is exactly the kind of thing that's easy to get wrong, and writing it made me appreciate why Redis's own implementation pairs a hash table with a skip list.
What it doesn't do (yet)
Being honest about the gaps is half the value of a project like this:
- It reads commands as plain lines, not RESP arrays. That's why
ncworks, but real clients likeredis-clisend RESP arrays, and those won't parse yet. That's the next thing to fix. Values with spaces in them are also out for now. - Types don't collide. The same key can exist as a string and a list at the same time. Real Redis would answer
WRONGTYPE. - Expiry is lazy only, as described above.
- Nothing is persisted. Restart the server and everything is gone. There's no RDB snapshot and no append-only file.
What I took away
The biggest lesson wasn't about Redis. It was about how much Rust's type system does for concurrent code. Arc makes shared ownership explicit, RwLock makes the locking explicit, and the Send check turns "you're holding a lock across an await" from a production deadlock into a compile error.
The second lesson is that Redis's commands are small, but the data structures underneath them are real computer science. I'd recommend this project to any backend developer who uses Redis and has never looked inside. The code is on GitHub.