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:

  1. Single-threaded event loop — Real Redis uses a single thread with epoll/kqueue. No mutex contention. Our Mutex becomes a bottleneck under high concurrency.
  2. Custom memory allocator — Redis uses jemalloc for efficient memory allocation. We use the system allocator.
  3. Optimized data structures — Redis uses ziplist, quicklist, and other specialized structures. We use a plain HashMap and Vec.
  4. 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:

  1. Network programming — accepting TCP connections, reading and writing bytes
  2. Protocol design — RESP is simple but powerful. Text protocols are easy to debug
  3. Concurrency — sharing state between async tasks with Arc and Mutex
  4. Persistence — atomic file writes prevent corruption
  5. 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.