Building a Redis Clone in Rust: The Key-Value Store
What I dubbed “Phase 1” was mainly about parsing things correctly. Phase 2 is where I try to make the project start to do something useful — storing and retrieving data.
This post is mainly about the GET, SET operations, the concurrency model I picked (why it can be improved a lot), plus some benchmarking. I also realized that I forgot to implement inline commands during benchmarking, so I fixed that.
The Store
The core data structure is straightforward:
pub struct Store {
data: Arc<RwLock<HashMap<String, StoredValue>>>,
}
Arc<RwLock<HashMap>> is the standard Rust pattern for shared mutable state across async tasks. Arc handles the sharing, RwLock handles the mutability. Multiple readers can hold the lock simultaneously, but writers get exclusive access.
I know this differs vastly from the single-threaded Redis event loop - I’ve added my thoughts on this towards the end. I decided to write it out as basic as possible, implement a sufficient set of operations, before dabbling in the more in-depth topic that is the concurrency approach.
Each value also tracks its expiration time:
pub struct StoredValue {
pub data: Vec<u8>,
pub expires_at: Option<Instant>,
}
The server creates one Store and clones it (which just clones the Arc, not the data) for each connection:
let (socket, addr) = self.listener.accept().await?;
let store = self.store.clone();
tokio::spawn(async move {
handle_connection(socket, store).await
});
Lazy Expiration
Redis has two strategies for expiring keys: active expiration (a background task samples random keys) and passive expiration (check on access). I only implemented the passive approach for now, mainly because it’s straightforward, and I don’t really need active expiration for now.
pub async fn get(&self, key: &str) -> Option<Vec<u8>> {
let read_guard = self.data.read().await;
if let Some(value) = read_guard.get(key) {
if value.is_expired() {
drop(read_guard);
self.data.write().await.remove(key);
None
} else {
Some(value.data.clone())
}
} else {
None
}
}
One thing to note: I drop the read lock before acquiring the write lock to delete the expired key. Holding both would deadlock. This does mean another request could slip in between:
Request A Request B
─────────────────────────────────────────────────────────
acquire read lock
check key → expired
drop read lock
acquire read lock
check key → expired
drop read lock
acquire write lock
remove key
release write lock
acquire write lock
remove key (no-op, already gone)
release write lock
Worst case, we spend extra compute to check twice and the second remove is a no-op. That’s fine, I think.
The naturally occurring issue with having only passive expiration is that keys that are never accessed will stick around in memory “forever”. Of course, Redis doesn’t have this issue and it handles this with periodic sampling. I’ll try to add this later.
INCR and Zero-Initialization
Redis has a nice behavior where INCR on a non-existent key treats it as 0:
redis-cli INCR newcounter
# (integer) 1
I’ve taken this approach, as well:
pub async fn incr_by(&self, key: &str, delta: i64) -> Result<i64, String> {
let mut write_guard = self.data.write().await;
let current = if let Some(value) = write_guard.get(key) {
if value.is_expired() {
0
} else {
let s = String::from_utf8(value.data.clone())
.map_err(|_| "ERR value is not an integer or out of range")?;
s.parse::<i64>()
.map_err(|_| "ERR value is not an integer or out of range")?
}
} else {
0
};
let new_value = current
.checked_add(delta)
.ok_or_else(|| "ERR increment or decrement would overflow")?;
write_guard.insert(key.to_string(), StoredValue::new(new_value.to_string().into_bytes()));
Ok(new_value)
}
Things to note:
- Expired keys are treated as non-existent (return 0)
- The value is stored as a string, so we need to do a
convert to i64 -> increment -> convert to str, which is suboptimal checked_addcatches overflow instead of wrapping silently
The parse-increment-stringify cycle isn’t the most efficient, but it allows for values to be stored as bytes, and INCR interprets them as integers on the fly.
MGET and Locks
Batch operations aren’t very inutuitive with RwLock. For MGET, I want to read multiple keys but also clean up any expired ones I find:
pub async fn mget(&self, keys: &[String]) -> Vec<Option<Vec<u8>>> {
let read_guard = self.data.read().await;
let mut results = Vec::with_capacity(keys.len());
let mut expired_keys = Vec::new();
for key in keys {
if let Some(value) = read_guard.get(key) {
if value.is_expired() {
expired_keys.push(key.clone());
results.push(None);
} else {
results.push(Some(value.data.clone()));
}
} else {
results.push(None);
}
}
drop(read_guard);
if !expired_keys.is_empty() {
let mut write_guard = self.data.write().await;
for key in expired_keys {
write_guard.remove(&key);
}
}
results
}
This ends up looking very odd - the logic goes like:
- Acquire a read lock
- Collect expired keys while holding the read lock
- Release the read lock
- Acquire write lock and attempt to cleanup all the expired keys <— This is the part I don’t really like due to possible interactions with simultaneous reads/writes
Overall, the cleanup isn’t atomic, and I’ll think about how to improve it.
The Inline Commands Part That I forgot
When I tried running redis-benchmark against Rudis, I realized that I had missed some implementation details.
Turns out redis-benchmark uses two different protocols. Besides plain RESP arrays:
*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
It also sends plain text:
PING\r\n
This is called an “inline command” — Redis supports it for TCP clients. My parser was treating the P in PING as an unknown RESP type and choking.
It’s not really a fix, but rather implementing what I initially forgot to. Any byte that isn’t a RESP type prefix (+, -, :, $, *) gets routed to an inline command parser:
pub fn parse(buffer: &mut BytesMut) -> Result<Option<(RespValue, usize)>> {
if buffer.is_empty() {
return Ok(None);
}
match buffer[0] {
b'+' => parse_simple_string(buffer),
b'-' => parse_error(buffer),
b':' => parse_integer(buffer),
b'$' => parse_bulk_string(buffer),
b'*' => parse_array(buffer),
_ => parse_inline_command(buffer),
}
}
The inline parser just splits on whitespace and wraps everything in a RESP array:
fn parse_inline_command(buffer: &mut BytesMut) -> Result<Option<(RespValue, usize)>> {
if let Some(pos) = find_crlf(buffer) {
let line = &buffer[..pos];
let parts: Vec<&[u8]> = line
.split(|&b| b == b' ' || b == b'\t')
.filter(|part| !part.is_empty())
.collect();
let elements: Vec<RespValue> = parts
.into_iter()
.map(|part| RespValue::BulkString(Some(part.to_vec())))
.collect();
Ok(Some((RespValue::Array(Some(elements)), pos + 2)))
} else {
Ok(None)
}
}
Not complicated, but I’d have forgotten about it and implemented it way later if it wasn’t for redis-benchmark to remind me.
There’s an important aspect here and that is: user input. Even before deciding to go from Electrical Engineering into Software Engineering, one thing that was hammered into my mind was don’t trust user input and validate/sanitize it. Here’s a HN thread some discussions on that: https://news.ycombinator.com/item?id=3940286
So, that’s what I tried doing, while bouncing ideas off of LLMs - I feel like the most basic attack vectors are covered, but it’s not a guarantee… I haven’t really focused on vulnerabilities throughout my career, so excuse me if I’ve missed something.
Benchmarks
With inline commands working, I could finally run proper benchmarks. I wrote a script that starts each server, runs redis-benchmark with identical parameters, and compares the results side by side.
Single-threaded (default params):
| Command | Redis (req/s) | Rudis (req/s) | Ratio |
|---|---|---|---|
| PING_INLINE | 136,986 | 103,093 | 0.75x |
| PING_MBULK | 217,391 | 138,889 | 0.64x |
| SET | 227,273 | 133,333 | 0.59x |
| GET | 217,391 | 138,889 | 0.64x |
Multi-threaded (100 clients, 8 threads):
| Command | Redis (req/s) | Rudis (req/s) | Ratio |
|---|---|---|---|
| PING_INLINE | 99,800 | 99,800 | 1.00x |
| PING_MBULK | 99,900 | 99,800 | 1.00x |
| SET | 99,800 | 99,800 | 1.00x |
| GET | 99,800 | 99,900 | 1.00x |
Some conclusions
Single-threaded, Rudis runs at about 60-75% of Redis speed. The gap makes sense—Redis has a custom event loop while Rudis uses Tokio’s general-purpose async runtime. Achieving over 50% performance is fine for a first pass.
PING_INLINE is slower than PING_MBULK. The inline parser scans for whitespace and builds an array; the RESP parser just follows length prefixes. Small difference, but visible.
Multi-threaded results are basically identical. Both hit a ceiling around 100K req/s — I suspect redis-benchmark itself is the bottleneck, not the servers. Still, reassuring that Rudis doesn’t fall over under concurrent load.
A few caveats: these benchmarks are informal. Both Redis and Rudis are primarily single-threaded for command execution—Redis by design, Rudis because all writes go through a single RwLock. The 1.00x ratios just mean neither server is the bottleneck at that load level.
Things That Stuck With Me
Lock granularity matters. A single RwLock around the entire HashMap serializes all writes, even for different keys. Redis avoids this with a single-threaded event loop; a multi-threaded approach would want sharding or a concurrent HashMap like dashmap. Reworking this is on my radar.
Lazy expiration is easy but incomplete. Without active expiration, memory fills up with never-accessed expired keys. I think it’s fine for first steps in a learning project, but it’s generally essential, so I’ll try to implement it soon.
What’s Next
What I think should be “Phase 2” is done. The core key-value operations work, benchmarks are in place, and I can compare against Redis as I make changes.
Phase 3 should be a smaller leap, as I plan to just add more commands — EXPIRE, TTL, KEYS, EXISTS. These are mostly straightforward given the current structure, though KEYS (pattern matching) might need some thought.
Code is here: github.com/aleksandar-had/rudis
This blog post was written by me, then optimized and polished by the LLMs.