In Part 1, we built a TCP server with SET, GET, and DEL. In Part 2, we added expiry, persistence, and pub/sub. Now we add more data types, benchmark our implementation, and make it production-ready.
In this final part, we add:
- INCR — atomic integer increment
- LPUSH, LPOP, LRANGE — list operations
- Benchmarks against real Redis
- Graceful shutdown with signal handling
- Better error handling throughout
Adding INCR
INCR atomically increments a number stored at a key. If the key does not exist, it starts at 0. If the value is not a number, it returns an error. This is how real Redis counters work.
Update src/store.rs — add this method:
pub fn incr(&self, key: &str) -> Result<i64, String> {
let mut data = self.data.lock().unwrap();
let (current, existing_expiry): (i64, Option<Instant>) = match data.get(key) {
None => (0, None),
Some(entry) => {
if Self::is_expired(entry) {
(0, None) // treat expired key as non-existent
} else {
match &entry.value {
Value::String(s) => {
let val = s.parse::<i64>().map_err(|_| {
"ERR value is not an integer or out of range".to_string()
})?;
(val, entry.expires_at)
}
Value::List(_) => {
return Err(
"WRONGTYPE Operation against a key holding the wrong kind of value"
.to_string(),
);
}
}
}
}
};
let new_value = current + 1;
data.insert(
key.to_string(),
Entry {
value: Value::String(new_value.to_string()),
expires_at: existing_expiry, // preserve the original TTL
},
);
Ok(new_value)
}
Add the command handler in src/command.rs:
"INCR" => handle_incr(&args, store),
And the handler function:
fn handle_incr(args: &[RespValue], store: &Store) -> RespValue {
if args.len() < 2 {
return RespValue::Error(
"ERR wrong number of arguments for 'incr'".to_string(),
);
}
let key = match &args[1] {
RespValue::BulkString(s) => s.as_str(),
_ => return RespValue::Error("ERR invalid key".to_string()),
};
match store.incr(key) {
Ok(value) => RespValue::Integer(value),
Err(e) => RespValue::Error(e),
}
}
Test it:
127.0.0.1:6379> SET counter 10
OK
127.0.0.1:6379> INCR counter
(integer) 11
127.0.0.1:6379> INCR counter
(integer) 12
127.0.0.1:6379> INCR new_counter
(integer) 1
127.0.0.1:6379> SET name Alex
OK
127.0.0.1:6379> INCR name
(error) ERR value is not an integer or out of range
Adding Lists
Redis lists are one of its most useful data types. They work as a linked list — you push items to the front, pop from the front, and read ranges.
We need to extend our store to hold lists alongside strings. Update the Entry struct in src/store.rs:
#[derive(Clone)]
enum Value {
String(String),
List(Vec<String>),
}
#[derive(Clone)]
struct Entry {
value: Value,
expires_at: Option<Instant>,
}
Now update the existing methods to work with the new Value enum. Here is the updated src/store.rs:
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Clone)]
enum Value {
String(String),
List(Vec<String>),
}
#[derive(Clone)]
struct Entry {
value: Value,
expires_at: Option<Instant>,
}
#[derive(Clone)]
pub struct Store {
data: Arc<Mutex<HashMap<String, Entry>>>,
}
impl Store {
pub fn new() -> Self {
Store {
data: Arc::new(Mutex::new(HashMap::new())),
}
}
fn is_expired(entry: &Entry) -> bool {
entry
.expires_at
.map_or(false, |exp| Instant::now() > exp)
}
pub fn set(&self, key: String, value: String) {
let mut data = self.data.lock().unwrap();
data.insert(
key,
Entry {
value: Value::String(value),
expires_at: None,
},
);
}
pub fn set_with_expiry(&self, key: String, value: String, ttl: Duration) {
let mut data = self.data.lock().unwrap();
data.insert(
key,
Entry {
value: Value::String(value),
expires_at: Some(Instant::now() + ttl),
},
);
}
pub fn get(&self, key: &str) -> Option<String> {
let mut data = self.data.lock().unwrap();
if let Some(entry) = data.get(key) {
if Self::is_expired(entry) {
data.remove(key);
return None;
}
match &entry.value {
Value::String(s) => Some(s.clone()),
Value::List(_) => None, // WRONGTYPE error handled at command level
}
} else {
None
}
}
pub fn del(&self, key: &str) -> bool {
let mut data = self.data.lock().unwrap();
data.remove(key).is_some()
}
pub fn expire(&self, key: &str, ttl: Duration) -> bool {
let mut data = self.data.lock().unwrap();
if let Some(entry) = data.get_mut(key) {
if Self::is_expired(entry) {
data.remove(key);
return false;
}
entry.expires_at = Some(Instant::now() + ttl);
true
} else {
false
}
}
pub fn ttl(&self, key: &str) -> i64 {
let data = self.data.lock().unwrap();
match data.get(key) {
None => -2,
Some(entry) => match entry.expires_at {
None => -1,
Some(expires_at) => {
let now = Instant::now();
if now > expires_at {
-2
} else {
(expires_at - now).as_secs() as i64
}
}
},
}
}
pub fn incr(&self, key: &str) -> Result<i64, String> {
let mut data = self.data.lock().unwrap();
let (current, existing_expiry): (i64, Option<Instant>) = match data.get(key) {
None => (0, None),
Some(entry) => {
if Self::is_expired(entry) {
(0, None)
} else {
match &entry.value {
Value::String(s) => {
let val = s.parse::<i64>().map_err(|_| {
"ERR value is not an integer or out of range".to_string()
})?;
(val, entry.expires_at)
}
Value::List(_) => {
return Err(
"WRONGTYPE Operation against a key holding the wrong kind of value"
.to_string(),
);
}
}
}
}
};
let new_value = current + 1;
data.insert(
key.to_string(),
Entry {
value: Value::String(new_value.to_string()),
expires_at: existing_expiry, // preserve the original TTL
},
);
Ok(new_value)
}
pub fn lpush(&self, key: &str, values: Vec<String>) -> Result<usize, String> {
let mut data = self.data.lock().unwrap();
// Remove expired key
if let Some(entry) = data.get(key) {
if Self::is_expired(entry) {
data.remove(key);
}
}
let entry = data.entry(key.to_string()).or_insert(Entry {
value: Value::List(Vec::new()),
expires_at: None,
});
match &mut entry.value {
Value::List(list) => {
// LPUSH adds each value to the front, one by one (like real Redis)
for v in values.into_iter() {
list.insert(0, v);
}
Ok(list.len())
}
Value::String(_) => Err(
"WRONGTYPE Operation against a key holding the wrong kind of value".to_string(),
),
}
}
pub fn lpop(&self, key: &str) -> Option<String> {
let mut data = self.data.lock().unwrap();
if let Some(entry) = data.get_mut(key) {
if Self::is_expired(entry) {
data.remove(key);
return None;
}
match &mut entry.value {
Value::List(list) => {
if list.is_empty() {
None
} else {
Some(list.remove(0))
}
}
Value::String(_) => None,
}
} else {
None
}
}
pub fn lrange(&self, key: &str, start: i64, stop: i64) -> Result<Vec<String>, String> {
let mut data = self.data.lock().unwrap();
if let Some(entry) = data.get(key) {
if Self::is_expired(entry) {
data.remove(key);
return Ok(Vec::new());
}
match &entry.value {
Value::List(list) => {
if list.is_empty() {
return Ok(Vec::new());
}
let len = list.len() as i64;
// Convert negative indices
let start = if start < 0 {
(len + start).max(0) as usize
} else {
start as usize
};
let stop = if stop < 0 {
(len + stop).max(0) as usize
} else {
stop.min(len - 1) as usize
};
if start > stop || start >= list.len() {
return Ok(Vec::new());
}
Ok(list[start..=stop].to_vec())
}
Value::String(_) => Err(
"WRONGTYPE Operation against a key holding the wrong kind of value".to_string(),
),
}
} else {
Ok(Vec::new())
}
}
pub fn get_all(&self) -> Vec<(String, String)> {
let data = self.data.lock().unwrap();
let now = Instant::now();
data.iter()
.filter(|(_, entry)| entry.expires_at.map_or(true, |exp| now <= exp))
.filter_map(|(k, entry)| match &entry.value {
Value::String(v) => Some((k.clone(), v.clone())),
Value::List(_) => None, // skip lists for simple persistence
})
.collect()
}
pub fn load(&self, pairs: Vec<(String, String)>) {
let mut data = self.data.lock().unwrap();
for (key, value) in pairs {
data.insert(
key,
Entry {
value: Value::String(value),
expires_at: None,
},
);
}
}
}
Add the list command handlers in src/command.rs:
"LPUSH" => handle_lpush(&args, store),
"LPOP" => handle_lpop(&args, store),
"LRANGE" => handle_lrange(&args, store),
And the handler functions:
fn handle_lpush(args: &[RespValue], store: &Store) -> RespValue {
if args.len() < 3 {
return RespValue::Error(
"ERR wrong number of arguments for 'lpush'".to_string(),
);
}
let key = match &args[1] {
RespValue::BulkString(s) => s.as_str(),
_ => return RespValue::Error("ERR invalid key".to_string()),
};
let mut values = Vec::new();
for arg in &args[2..] {
if let RespValue::BulkString(v) = arg {
values.push(v.clone());
} else {
return RespValue::Error("ERR invalid value".to_string());
}
}
match store.lpush(key, values) {
Ok(len) => RespValue::Integer(len as i64),
Err(e) => RespValue::Error(e),
}
}
fn handle_lpop(args: &[RespValue], store: &Store) -> RespValue {
if args.len() < 2 {
return RespValue::Error(
"ERR wrong number of arguments for 'lpop'".to_string(),
);
}
let key = match &args[1] {
RespValue::BulkString(s) => s.as_str(),
_ => return RespValue::Error("ERR invalid key".to_string()),
};
match store.lpop(key) {
Some(value) => RespValue::BulkString(value),
None => RespValue::Null,
}
}
fn handle_lrange(args: &[RespValue], store: &Store) -> RespValue {
if args.len() < 4 {
return RespValue::Error(
"ERR wrong number of arguments for 'lrange'".to_string(),
);
}
let key = match &args[1] {
RespValue::BulkString(s) => s.as_str(),
_ => return RespValue::Error("ERR invalid key".to_string()),
};
let start = match &args[2] {
RespValue::BulkString(s) => match s.parse::<i64>() {
Ok(n) => n,
Err(_) => return RespValue::Error("ERR invalid start index".to_string()),
},
_ => return RespValue::Error("ERR invalid argument".to_string()),
};
let stop = match &args[3] {
RespValue::BulkString(s) => match s.parse::<i64>() {
Ok(n) => n,
Err(_) => return RespValue::Error("ERR invalid stop index".to_string()),
},
_ => return RespValue::Error("ERR invalid argument".to_string()),
};
match store.lrange(key, start, stop) {
Ok(values) => {
let resp_values: Vec<RespValue> = values
.into_iter()
.map(RespValue::BulkString)
.collect();
RespValue::Array(resp_values)
}
Err(e) => RespValue::Error(e),
}
}
Test the list commands:
127.0.0.1:6379> LPUSH tasks "write tests" "fix bug" "deploy"
(integer) 3
127.0.0.1:6379> LRANGE tasks 0 -1
1) "deploy"
2) "fix bug"
3) "write tests"
127.0.0.1:6379> LPOP tasks
"deploy"
127.0.0.1:6379> LRANGE tasks 0 -1
1) "fix bug"
2) "write tests"
Notice that LPUSH adds to the front. The last value pushed is the first in the list. This matches real Redis behavior.
Performance note: We use Vec::insert(0) which is O(n) because it shifts all elements. Real Redis uses a quicklist (a linked list of ziplists) for O(1) head inserts. For a production implementation, you would use std::collections::VecDeque which gives O(1) operations at both ends.
Graceful Shutdown
Our server currently stops abruptly when you press Ctrl+C. Let us add graceful shutdown that saves data before exiting.
Update src/main.rs:
use tokio::signal;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let store = Store::new();
let pubsub = PubSub::new();
// Load data from disk on startup
persistence::load_from_disk(&store)?;
let listener = TcpListener::bind("127.0.0.1:6379").await?;
println!("Mini-Redis server listening on 127.0.0.1:6379");
let shutdown_store = store.clone();
// Spawn the shutdown signal handler
tokio::spawn(async move {
signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
println!("\nShutting down...");
match persistence::save_to_disk(&shutdown_store) {
Ok(_) => println!("Data saved successfully"),
Err(e) => eprintln!("Failed to save data: {}", e),
}
std::process::exit(0);
});
loop {
let (mut socket, addr) = listener.accept().await?;
let store = store.clone();
let pubsub = pubsub.clone();
tokio::spawn(async move {
println!("New connection from {}", addr);
let mut buf = vec![0u8; 4096];
loop {
let n = match socket.read(&mut buf).await {
Ok(0) => return,
Ok(n) => n,
Err(_) => return,
};
let data = buf[..n].to_vec();
let mut parser = RespParser::new(data);
let value = match parser.parse() {
Ok(v) => v,
Err(e) => {
let err = RespValue::Error(format!("ERR parse error: {}", e));
let _ = socket.write_all(err.serialize().as_bytes()).await;
continue;
}
};
if is_subscribe_command(&value) {
handle_subscribe(&mut socket, &value, &pubsub).await;
return;
}
if is_publish_command(&value) {
let response = handle_publish(&value, &pubsub);
let _ = socket.write_all(response.serialize().as_bytes()).await;
continue;
}
let response = handle_command(value, &store);
if socket
.write_all(response.serialize().as_bytes())
.await
.is_err()
{
return;
}
}
});
}
}
Now when you press Ctrl+C, the server saves all data to disk before exiting.
Note: Our simple persistence format only saves string values, not lists. List data is lost on restart. To persist lists, you would need a richer file format like JSON or a binary format similar to Redis RDB files.
Benchmarking Against Real Redis
Let us see how our mini-Redis compares to the real thing. We use redis-benchmark, the official Redis benchmarking tool.
First, benchmark real Redis:
redis-benchmark -t set,get -n 100000 -q
Typical results on a modern machine:
SET: 125,000 requests per second
GET: 135,000 requests per second
Now benchmark our mini-Redis (make sure real Redis is stopped first):
cargo build --release
./target/release/mini-redis &
redis-benchmark -t set,get -n 100000 -q
Our results:
SET: ~45,000 requests per second
GET: ~50,000 requests per second
Our implementation is about 35-40% of real Redis speed. That is actually good for a few hundred lines of Rust. Here is why real Redis is faster:
- Single-threaded event loop — Real Redis uses a single thread with epoll/kqueue. No mutex contention. Our Mutex becomes a bottleneck under high concurrency.
- Custom memory allocator — Redis uses jemalloc for efficient memory allocation. We use the system allocator.
- Optimized data structures — Redis uses ziplist, quicklist, and other specialized structures. We use a plain HashMap and Vec.
- Years of optimization — Redis has been optimized for over 15 years.
How to Improve Performance
If you want to push our mini-Redis further, here are ideas:
Use a sharded HashMap. Instead of one Mutex for the whole HashMap, split it into multiple shards:
use std::collections::HashMap;
use std::sync::Mutex;
const NUM_SHARDS: usize = 16;
struct ShardedMap {
shards: Vec<Mutex<HashMap<String, Entry>>>,
}
impl ShardedMap {
fn new() -> Self {
let mut shards = Vec::with_capacity(NUM_SHARDS);
for _ in 0..NUM_SHARDS {
shards.push(Mutex::new(HashMap::new()));
}
ShardedMap { shards }
}
fn get_shard(&self, key: &str) -> &Mutex<HashMap<String, Entry>> {
let mut hash: usize = 0;
for byte in key.bytes() {
hash = hash.wrapping_mul(31).wrapping_add(byte as usize);
}
&self.shards[hash % NUM_SHARDS]
}
}
This reduces lock contention because different keys usually map to different shards. Real concurrent data structures like dashmap use this approach.
Use tokio::sync::RwLock instead of std::sync::Mutex to allow multiple readers at the same time.
Buffer writes — instead of flushing every response immediately, batch multiple responses together.
Final Project Structure
mini-redis/
Cargo.toml
src/
main.rs — TCP server, pub/sub, graceful shutdown
resp.rs — RESP protocol parser and serializer
store.rs — key-value and list storage with expiry
command.rs — all command handlers
persistence.rs — save/load data to disk
pubsub.rs — publish/subscribe messaging
Full Cargo.toml
[package]
name = "mini-redis"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
bytes = "1"
[profile.release]
opt-level = 3
lto = true
We added release profile optimizations. lto = true enables link-time optimization, which makes the binary faster at the cost of longer compile times.
What We Built
Over three parts, we built a Redis clone that supports:
- Protocol: Full RESP parser and serializer
- String commands: SET (with EX), GET, DEL, INCR
- List commands: LPUSH, LPOP, LRANGE
- Key management: EXPIRE, TTL
- Persistence: SAVE, auto-load on startup, auto-save on shutdown
- Pub/Sub: SUBSCRIBE, PUBLISH
- Utility: PING, ECHO
All of this in about 600 lines of Rust. The code is compatible with redis-cli and any Redis client library.
What We Learned
Building Redis from scratch teaches important concepts:
- Network programming — accepting TCP connections, reading and writing bytes
- Protocol design — RESP is simple but powerful. Text protocols are easy to debug
- Concurrency — sharing state between async tasks with Arc and Mutex
- Persistence — atomic file writes prevent corruption
- Message passing — broadcast channels for pub/sub
These patterns appear in many real-world systems. Database servers, message brokers, and API gateways all use similar techniques.
Ideas to Extend
If you want to keep building, try adding:
- RPUSH, RPOP — push and pop from the right side of lists
- HSET, HGET — hash maps (nested key-value pairs)
- AUTH — password authentication
- Multi/Exec — transactions
- Cluster mode — shard data across multiple instances
- Background expiry — a task that periodically cleans up expired keys
Each feature teaches something new about systems programming.
Related Articles
- Part 1: TCP Server and Commands — TCP server and RESP protocol
- Part 2: Expiry, Persistence, and Pub/Sub — key expiry, disk persistence, messaging
- Rust Tutorial: Async/Await and Tokio — async runtime fundamentals
- Rust Tutorial: Smart Pointers — Arc and other smart pointers
- Rust Tutorial: Concurrency — threads and shared state
- Rust Tutorial: Error Handling — the Result type and error patterns