Have you ever wondered how Redis works under the hood? In this mini-series, we build a Redis clone from scratch in Rust. No magic. Just a TCP server, a protocol parser, and a HashMap.
By the end of this series, you will have a working key-value store that speaks the real Redis protocol. You can connect to it with redis-cli and run commands.
This is Part 1. We will build:
- A TCP server using Tokio
- A parser for the RESP protocol (Redis Serialization Protocol)
- SET, GET, and DEL commands
- In-memory storage with HashMap
If you are new to Rust, check out our Rust tutorial series first. You should be comfortable with ownership, error handling, and async/await with Tokio.
Create the Project
Start a new Rust project:
cargo new mini-redis
cd mini-redis
Open Cargo.toml and add these dependencies:
[package]
name = "mini-redis"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
bytes = "1"
- tokio — async runtime for our TCP server
- bytes — efficient byte buffer handling
Understanding the RESP Protocol
Redis uses a simple text-based protocol called RESP (REdis Serialization Protocol). Every Redis command is sent as an array of bulk strings. Let us look at how SET name Alex looks on the wire:
*3\r\n
$3\r\n
SET\r\n
$4\r\n
name\r\n
$4\r\n
Alex\r\n
Here is what each part means:
*3— this is an array with 3 elements$3— the next element is a bulk string of 3 bytesSET— the actual string data\r\n— every line ends with carriage return and newline
The response format is similar. A simple string starts with +, an error starts with -, an integer starts with :, and a null value is $-1\r\n.
Parsing RESP
Let us create a RESP parser. Add a file src/resp.rs:
use std::io;
#[derive(Debug, Clone)]
pub enum RespValue {
SimpleString(String),
Error(String),
Integer(i64),
BulkString(String),
Array(Vec<RespValue>),
Null,
}
impl RespValue {
/// Serialize a RESP value to bytes for sending back to the client.
pub fn serialize(&self) -> String {
match self {
RespValue::SimpleString(s) => format!("+{}\r\n", s),
RespValue::Error(s) => format!("-{}\r\n", s),
RespValue::Integer(i) => format!(":{}\r\n", i),
RespValue::BulkString(s) => format!("${}\r\n{}\r\n", s.len(), s),
RespValue::Null => "$-1\r\n".to_string(),
RespValue::Array(values) => {
let mut result = format!("*{}\r\n", values.len());
for v in values {
result.push_str(&v.serialize());
}
result
}
}
}
}
pub struct RespParser {
data: Vec<u8>,
pos: usize,
}
impl RespParser {
pub fn new(data: Vec<u8>) -> Self {
RespParser { data, pos: 0 }
}
pub fn parse(&mut self) -> io::Result<RespValue> {
if self.pos >= self.data.len() {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "No data"));
}
let type_byte = self.data[self.pos];
self.pos += 1;
match type_byte {
b'+' => self.parse_simple_string(),
b'-' => self.parse_error(),
b':' => self.parse_integer(),
b'$' => self.parse_bulk_string(),
b'*' => self.parse_array(),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Unknown type byte: {}", type_byte as char),
)),
}
}
fn read_line(&mut self) -> io::Result<String> {
let start = self.pos;
while self.pos < self.data.len() - 1 {
if self.data[self.pos] == b'\r' && self.data[self.pos + 1] == b'\n' {
let line = String::from_utf8_lossy(&self.data[start..self.pos]).to_string();
self.pos += 2; // skip \r\n
return Ok(line);
}
self.pos += 1;
}
Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"Expected \\r\\n",
))
}
fn parse_simple_string(&mut self) -> io::Result<RespValue> {
let line = self.read_line()?;
Ok(RespValue::SimpleString(line))
}
fn parse_error(&mut self) -> io::Result<RespValue> {
let line = self.read_line()?;
Ok(RespValue::Error(line))
}
fn parse_integer(&mut self) -> io::Result<RespValue> {
let line = self.read_line()?;
let num: i64 = line.parse().map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, "Invalid integer")
})?;
Ok(RespValue::Integer(num))
}
fn parse_bulk_string(&mut self) -> io::Result<RespValue> {
let line = self.read_line()?;
let len: i64 = line.parse().map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, "Invalid bulk string length")
})?;
if len == -1 {
return Ok(RespValue::Null);
}
let len = len as usize;
if self.pos + len + 2 > self.data.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"Bulk string data incomplete",
));
}
let value = String::from_utf8_lossy(&self.data[self.pos..self.pos + len]).to_string();
self.pos += len + 2; // skip data + \r\n
Ok(RespValue::BulkString(value))
}
fn parse_array(&mut self) -> io::Result<RespValue> {
let line = self.read_line()?;
let count: i64 = line.parse().map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, "Invalid array length")
})?;
if count == -1 {
return Ok(RespValue::Null);
}
let count = count as usize;
let mut values = Vec::with_capacity(count);
for _ in 0..count {
values.push(self.parse()?);
}
Ok(RespValue::Array(values))
}
}
The parser reads one byte to determine the type, then reads the rest based on the RESP rules. This handles all five RESP data types.
Note on TCP framing: For simplicity, we assume each read() call returns exactly one complete RESP command. In production, TCP can split a command across multiple reads or combine multiple commands into one read. A real server would buffer incoming data with bytes::BytesMut and parse from the buffer. We keep it simple here so we can focus on the protocol and commands.
In-Memory Storage
Redis stores data in memory. We use a HashMap wrapped in Arc<Mutex<>> so multiple async tasks can access it safely. Create src/store.rs:
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct Store {
data: Arc<Mutex<HashMap<String, String>>>,
}
impl Store {
pub fn new() -> Self {
Store {
data: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn set(&self, key: String, value: String) {
let mut data = self.data.lock().unwrap();
data.insert(key, value);
}
pub fn get(&self, key: &str) -> Option<String> {
let data = self.data.lock().unwrap();
data.get(key).cloned()
}
pub fn del(&self, key: &str) -> bool {
let mut data = self.data.lock().unwrap();
data.remove(key).is_some()
}
}
We use Arc<Mutex<>> because:
Arclets us share the store across multiple async tasksMutexensures only one task modifies the HashMap at a time
If you want a refresher on these types, see our tutorials on smart pointers and concurrency.
Command Handler
Now we need to turn parsed RESP values into actual commands. Create src/command.rs:
use crate::resp::RespValue;
use crate::store::Store;
pub fn handle_command(value: RespValue, store: &Store) -> RespValue {
let args = match value {
RespValue::Array(args) => args,
_ => return RespValue::Error("ERR expected array".to_string()),
};
if args.is_empty() {
return RespValue::Error("ERR empty command".to_string());
}
let command = match &args[0] {
RespValue::BulkString(s) => s.to_uppercase(),
_ => return RespValue::Error("ERR invalid command format".to_string()),
};
match command.as_str() {
"PING" => handle_ping(&args),
"ECHO" => handle_echo(&args),
"SET" => handle_set(&args, store),
"GET" => handle_get(&args, store),
"DEL" => handle_del(&args, store),
"COMMAND" => RespValue::SimpleString("OK".to_string()),
_ => RespValue::Error(format!("ERR unknown command '{}'", command)),
}
}
fn handle_ping(args: &[RespValue]) -> RespValue {
if args.len() > 1 {
if let RespValue::BulkString(msg) = &args[1] {
return RespValue::BulkString(msg.clone());
}
}
RespValue::SimpleString("PONG".to_string())
}
fn handle_echo(args: &[RespValue]) -> RespValue {
if args.len() < 2 {
return RespValue::Error("ERR wrong number of arguments for 'echo'".to_string());
}
if let RespValue::BulkString(msg) = &args[1] {
RespValue::BulkString(msg.clone())
} else {
RespValue::Error("ERR invalid argument".to_string())
}
}
fn handle_set(args: &[RespValue], store: &Store) -> RespValue {
if args.len() < 3 {
return RespValue::Error(
"ERR wrong number of arguments for 'set'".to_string(),
);
}
let key = match &args[1] {
RespValue::BulkString(s) => s.clone(),
_ => return RespValue::Error("ERR invalid key".to_string()),
};
let value = match &args[2] {
RespValue::BulkString(s) => s.clone(),
_ => return RespValue::Error("ERR invalid value".to_string()),
};
store.set(key, value);
RespValue::SimpleString("OK".to_string())
}
fn handle_get(args: &[RespValue], store: &Store) -> RespValue {
if args.len() < 2 {
return RespValue::Error(
"ERR wrong number of arguments for 'get'".to_string(),
);
}
let key = match &args[1] {
RespValue::BulkString(s) => s.as_str(),
_ => return RespValue::Error("ERR invalid key".to_string()),
};
match store.get(key) {
Some(value) => RespValue::BulkString(value),
None => RespValue::Null,
}
}
fn handle_del(args: &[RespValue], store: &Store) -> RespValue {
if args.len() < 2 {
return RespValue::Error(
"ERR wrong number of arguments for 'del'".to_string(),
);
}
let mut count = 0;
for arg in &args[1..] {
if let RespValue::BulkString(key) = arg {
if store.del(key) {
count += 1;
}
}
}
RespValue::Integer(count)
}
Notice that DEL supports multiple keys, just like real Redis. It returns the number of keys that were actually deleted.
The TCP Server
Now let us wire everything together in src/main.rs:
mod command;
mod resp;
mod store;
use command::handle_command;
use resp::RespParser;
use store::Store;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:6379").await?;
let store = Store::new();
println!("Mini-Redis server listening on 127.0.0.1:6379");
loop {
let (mut socket, addr) = listener.accept().await?;
let store = store.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) => {
println!("Connection closed: {}", addr);
return;
}
Ok(n) => n,
Err(e) => {
eprintln!("Read error from {}: {}", addr, e);
return;
}
};
let data = buf[..n].to_vec();
let mut parser = RespParser::new(data);
let response = match parser.parse() {
Ok(value) => handle_command(value, &store),
Err(e) => resp::RespValue::Error(format!("ERR parse error: {}", e)),
};
let response_bytes = response.serialize();
if let Err(e) = socket.write_all(response_bytes.as_bytes()).await {
eprintln!("Write error to {}: {}", addr, e);
return;
}
}
});
}
}
Here is what happens:
- We bind a TCP listener on port 6379 (the default Redis port)
- For each new connection, we spawn a new Tokio task
- Each task reads data from the socket in a loop
- We parse the RESP data and pass it to our command handler
- We serialize the response and send it back
The store.clone() is cheap because Store uses Arc internally. All tasks share the same underlying HashMap.
Test It
Build and run:
cargo run
Open another terminal and use redis-cli to connect:
redis-cli
127.0.0.1:6379> PING
PONG
127.0.0.1:6379> SET name Alex
OK
127.0.0.1:6379> GET name
"Alex"
127.0.0.1:6379> SET city Berlin
OK
127.0.0.1:6379> GET city
"Berlin"
127.0.0.1:6379> DEL name city
(integer) 2
127.0.0.1:6379> GET name
(nil)
It works. Our mini-Redis speaks the real Redis protocol. Any Redis client can connect to it.
How It All Fits Together
Let us trace a full request. When you type SET name Alex in redis-cli:
- redis-cli converts it to RESP:
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$4\r\nAlex\r\n - Our TCP server reads these bytes from the socket
RespParserturns the bytes intoRespValue::Array([BulkString("SET"), BulkString("name"), BulkString("Alex")])handle_commandmatches on “SET” and callshandle_sethandle_setinserts “name” => “Alex” into the HashMap- We return
RespValue::SimpleString("OK")which serializes to+OK\r\n - redis-cli displays “OK”
The whole flow is simple and direct. No framework magic.
Project Structure So Far
mini-redis/
Cargo.toml
src/
main.rs — TCP server, connection handling
resp.rs — RESP protocol parser and serializer
store.rs — in-memory key-value storage
command.rs — command routing and handlers
What’s Next?
In Part 2, we add real Redis features:
- Key expiry with TTL
- EXPIRE and TTL commands
- Persistence — saving data to disk
- Pub/Sub messaging
Our mini-Redis is functional but limited. Part 2 makes it much more useful.
Related Articles
- Rust Tutorial: Async/Await and Tokio — deep dive into the async runtime we use
- Rust Tutorial: Concurrency — threads, channels, Arc, and Mutex
- Rust Tutorial: Error Handling — the Result type and error patterns
- Rust Tutorial: Structs — how we structure our Store and RespValue types