What I Learned Building a Redis Clone in C++
A few weeks ago I was studying backend development from first principles. Not frameworks, not tutorials that skip the hard parts — actual fundamentals. How does data get stored? How does a server talk to a client? What even is a database at its core?
That's when I came across Redis for the first time.
I was reading about caching and kept seeing Redis mentioned everywhere. I looked it up and found out it's just a key-value store — you give it a key, it gives you back a value. Fast. Simple. Used by Twitter, GitHub, Snapchat.
And I thought: how hard can it be to build something like that?
Spoiler: harder than I expected. But I learned more from this one project than from weeks of reading.
Here's everything I built, everything I got wrong, and everything I learned.
What Is a Key-Value Store?
Before I get into the code, let me explain what I was even building.
A key-value store is the simplest possible database. Instead of tables and rows like SQL, it just stores pairs:
"name" → "Drishti"
"age" → "20"
"city" → "Delhi"
You store something with a key. You retrieve it with the same key. That's it. Redis is the most popular key-value store in the world and it's used everywhere — caching, session storage, rate limiting, pub/sub messaging.
My goal was to build a simplified version from scratch in C++ and compare it to the real thing.
The Plan
I gave myself a clear roadmap:
- TCP server on port 6380
- GET / SET / DELETE commands
- LRU eviction (automatically remove old keys when store is full)
- Disk persistence (data survives server restarts)
- Benchmark against real Redis
Let me walk through each one.
Step 1: The TCP Server
The first thing I had to learn was how servers actually work at the socket level. I'd used HTTP APIs before but never thought about what happens underneath.
A socket is just an endpoint for communication. When you connect to a server, both sides get a socket and they use it to send and receive data over TCP.
Here's the core of my server:
SOCKET server_fd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(6380);
bind(server_fd, (sockaddr*)&address, sizeof(address));
listen(server_fd, 5);
while (true) {
SOCKET client_fd = accept(server_fd, nullptr, nullptr);
// handle client...
closesocket(client_fd);
}
In plain English:
- Create a socket
- Bind it to port 6380
- Listen for connections
- In a loop — accept a client, handle them, close the connection
Why port 6380? Redis uses 6379. I wanted to run both simultaneously for benchmarking, so I picked the next port.
One thing that caught me off guard: on Windows you have to explicitly initialize the socket library with WSAStartup before using any socket functions. On Linux you don't need this. Small difference, but it tripped me up at first.
Step 2: Parsing Commands
Once the server could accept connections, I needed it to actually understand commands.
The client sends a raw string like "set name Drishti". My server receives it as a buffer of bytes. I needed to split it into tokens:
"set name Drishti" → ["set", "name", "Drishti"]
I wrote a parser that splits the string by spaces:
std::vector<std::string> Server::parse_command(const std::string& command) {
std::vector<std::string> tokens;
size_t start = 0, end = 0;
while ((end = command.find(' ', start)) != std::string::npos) {
tokens.push_back(command.substr(start, end - start));
start = end + 1;
}
tokens.push_back(command.substr(start));
return tokens;
}
Then handle each command based on tokens[0]:
if (cmd == "set") → store[tokens[1]] = tokens[2]
if (cmd == "get") → return store[tokens[1]]
if (cmd == "delete") → store.erase(tokens[1])
One bug I hit early: telnet sends \r\n at the end of each command. So tokens[2] would be "Drishti\r\n" instead of "Drishti". The fix was trimming trailing whitespace and newlines before parsing.
Step 3: LRU Eviction
This was the most interesting part to implement.
The problem: memory is finite. If clients keep adding keys forever, the store will eventually run out of memory. I needed a way to automatically remove old keys when the store gets full.
LRU — Least Recently Used — removes the key that hasn't been accessed in the longest time. The logic is simple: if you haven't used something recently, you probably don't need it.
Example with capacity 5:
set a 1 → [a]
set b 2 → [a, b]
set c 3 → [a, b, c]
set d 4 → [a, b, c, d]
set e 5 → [a, b, c, d, e] ← full!
get a → [b, c, d, e, a] ← 'a' just used, moves to front
set f 6 → evict 'b' (least recently used)
[c, d, e, a, f]
The implementation uses two data structures together:
A doubly linked list tracks order of use. Most recently used is at the front, least recently used is at the back.
A hash map maps each key directly to its position in the list for O(1) lookup.
List: [f] <-> [a] <-> [e] <-> [d] <-> [c]
Map: "a" → node, "f" → node, ...
Neither structure alone is enough. The list gives you order but lookup is O(n). The map gives you fast lookup but has no concept of order. Together they give you both in O(1).
Every GET or SET moves that key to the front of the list. When you need to evict, just remove from the back. That's it.
Step 4: Disk Persistence
Without persistence, all data disappears when the server restarts. Not great.
I implemented an Append-Only File (AOF) — the same strategy Redis uses. Every SET and DELETE gets logged to a file:
set a 1
set b 2
set c 3
delete b
set d 4
The file only ever gets appended to — never edited or rewritten. On startup, the server reads the file top to bottom and replays every command:
replay: set a 1 → {a=1}
replay: set b 2 → {a=1, b=2}
replay: set c 3 → {a=1, b=2, c=3}
replay: delete b → {a=1, c=3}
replay: set d 4 → {a=1, c=3, d=4}
Testing this felt satisfying. Set some keys, kill the server, restart it, get the same keys back. It works.
Step 5: Benchmarks vs Redis
The moment of truth. I installed Redis 7.0.15 on WSL and wrote a Python benchmark that sends 1000 SET and 1000 GET commands to both servers:
| Operation | My KV Store | Redis 7.0.15 |
|---|---|---|
| SET 1000 keys | 3.55s (281 req/s) | 3.19s (313 req/s) |
| GET 1000 keys | 3.55s (281 req/s) | 1.12s (892 req/s) |
SET is almost equal — only 11% slower. Honestly didn't expect that.
GET has a 3x gap — Redis is significantly faster here.
But here's the thing: this comparison isn't entirely fair. And understanding why it isn't fair taught me more than the numbers themselves.
What the Numbers Don't Tell You
My benchmark script opens a new TCP connection for every single command:
connect → send "set a 1" → receive "OK" → disconnect
connect → send "get a" → receive "1" → disconnect
Every TCP connection requires a 3-way handshake (SYN, SYN-ACK, ACK). That's network overhead on every single request. Real Redis clients use persistent connections — one connection stays open and thousands of commands flow through it.
My store isn't slow because C++ is slow. It's slow because of connection overhead that has nothing to do with the actual KV logic.
This was one of the most valuable lessons from the project: benchmarks are only meaningful in context. The numbers matter less than understanding what the numbers are actually measuring.
Other Limitations (Being Honest)
Single-threaded: My server handles one request at a time. Redis uses event-driven I/O (epoll) to handle thousands of concurrent clients on a single thread. Production servers use async I/O or thread pools.
AOF grows forever: My file never gets compacted. Redis periodically rewrites the AOF to remove redundant entries — if a key was set 1000 times, only the last value matters.
No RESP protocol: Real Redis uses RESP (Redis Serialization Protocol), a structured binary format. My store uses plain text, so it's not compatible with redis-cli or any Redis SDK.
LRU capacity is hardcoded to 5: Fine for a demo, not for production.
What I Actually Learned
Going in, I thought this project would teach me about databases. It did — but it taught me much more than that.
TCP sockets from scratch. I now understand what actually happens when two programs communicate over a network. Before this, HTTP was magic to me.
Why data structures matter in practice. LRU eviction is a textbook problem, but implementing it made the O(1) requirement real. You can't just use a list. You can't just use a map. You need both.
The gap between "working" and "production-grade". My store works. Redis works at 1 million req/s under production load. Understanding that gap — connection pooling, async I/O, protocol design, crash recovery — is what separates a learning project from real infrastructure.
Benchmarking is a skill. Writing a benchmark that actually measures what you think it's measuring is harder than it sounds.
First principles learning works. I could have used a Redis client library and moved on. Instead I built the thing. Now when I use Redis in a real project, I'll understand what it's doing.
The Code
Full source code on GitHub: https://github.com/DrishtiTripathi2230/kv-store
The README has a detailed breakdown of every component with diagrams and examples — worth a read if you want to understand any part in more depth.
Tags: #cpp #systems #redis #showdev #beginners
Top comments (0)