A concurrent, in-memory key-value store built in Go featuring lock striping (sharded concurrency), exact redis-cli, and Append-Only File (AOF) persistence.
To evaluate the impact of lock striping, we conducted empirical benchmarks measuring throughput and latency percentiles as the shard count scaled from 1 (global lock) up to 128 independent shards under high write/read contention.
Methodology Note: To isolate internal lock contention and LRU list mutation from OS kernel TCP socket overhead, the shard scaling benchmark tests the in-memory engine across concurrent goroutines. End-to-end TCP loopback benchmarks (including network stack traversal and RESP serialization) achieve ~37kβ50k ops/sec with sub-millisecond p50 latency.
- Workload: 300,000 requests (50% GET / 50% SET)
- Concurrency: 30 concurrent worker goroutines
- Key Range: 20,000 keys (generating realistic hash collisions)
| Shard Count | Throughput (ops/sec) | p50 Latency | p90 Latency | p99 Latency | Speedup vs Global Lock |
|---|---|---|---|---|---|
| 1 (Global Lock) | 1,898,558 op/s | 347 ns | 480 ns | 156.42 Β΅s | 1.00x |
| 2 | 2,198,121 op/s | 417 ns | 542 ns | 209.23 Β΅s | 1.16x |
| 4 | 2,363,148 op/s | 358 ns | 511 ns | 155.31 Β΅s | 1.24x |
| 8 | 2,273,747 op/s | 335 ns | 490 ns | 29.99 Β΅s | 1.20x |
| 16 | 3,395,021 op/s | 326 ns | 480 ns | 2.37 Β΅s | 1.79x |
| 32 | 3,133,491 op/s | 310 ns | 460 ns | 1.50 Β΅s | 1.65x |
| 64 | 3,350,987 op/s | 302 ns | 445 ns | 766 ns | 1.77x |
| 128 | 3,236,095 op/s | 301 ns | 440 ns | 652 ns | 1.70x |
Key Finding: Increasing shards from 1 (single global lock) to 64/128 shards:
- Boosted throughput by +79% (from 1.89M to 3.39M ops/sec).
- Slashed p99 tail latency from 156.4 Β΅s down to 652 ns β an over 240x reduction in tail contention.
+---------------------------------------+
| TCP Server (:6379) - RESP Protocol |
| (Goroutine per client connection) |
+---------------------------------------+
|
FNV-1a Hash(key) % numShards
|
+-------------------+-------------------+-------------------+
| | | |
+----+----+ +----+----+ +----+----+ +----+----+
| Shard 0 | | Shard 1 | | Shard 2 | ... |Shard N-1|
| Mutex | | Mutex | | Mutex | | Mutex |
| Hashmap | | Hashmap | | Hashmap | | Hashmap |
| LRU List| | LRU List| | LRU List| | LRU List|
+----+----+ +----+----+ +----+----+ +----+----+
| | | |
+-------------------+-------------------+-------------------+
|
+--------------------------------+
| AOF Persistence Engine (fsync) |
+--------------------------------+
Instead of a single global lock or fragile per-key locks, the key space is partitioned into sync.Mutex, hashmap, and LRU list. Keys in different shards execute concurrently with zero lock contention.
-
$O(1)$ Lookup: Hashmapmap[string]*Nodemaps keys directly to list nodes. -
$O(1)$ Reordering: Accessed or updated keys are promoted to the MRU position (head) in-place without memory reallocation. -
$O(1)$ Eviction: When a shard reaches capacity, the least-recently-used node at the tail is unlinked and removed from the map. - Sentinel Head & Tail: Implemented with sentinel nodes to eliminate nil checks and branch mispredictions.
Implements RESP2 data types (Simple Strings, Bulk Strings, Integers, Arrays, Errors) and inline plain-text commands. You can connect with standard redis-cli, redis-benchmark, nc, or any Redis client library.
All write operations (SET, DEL, FLUSHDB) are persisted to an append-only log file in standard RESP format, with configurable fsync policies (always, everysec, no). On server restart, the AOF is replayed to reconstruct the in-memory state.
| Dimension | Real Redis | This Go Key-Value Store |
|---|---|---|
| Concurrency Model | Single-threaded event loop | Multi-threaded with Sharded Locking |
| Multi-Core Utilization | Requires multi-process clustering | Scales across all CPU cores natively in 1 process |
| LRU Eviction | Approximated (samples 5 random keys) |
Exact |
| Memory / GC | Manual C (jemalloc, sds strings) |
Go Managed Runtime + GC |
| Persistence | RDB snapshots + AOF background rewrite |
Sequential AOF with configurable fsync & replay |
| Protocol | RESP2 & RESP3 |
RESP2 + Inline Commands (redis-cli compatible) |
Real Redis does not maintain an exact LRU order using a linked list. Instead, it uses approximated LRU:
- Every key stores a small timestamp (roughly, "when was this last accessed") as part of its object metadata β this piggybacks on data Redis already keeps, so it is nearly free.
- When Redis needs to evict something, it does not walk an ordered list β instead, it randomly samples a small number of keys (default: 5), checks their timestamps, and evicts whichever key in that sample is oldest.
- No global structure tracks exact order across the entire keyspace.
This is called "approximated" because it does not guarantee evicting the actual least-recently-used key across the whole keyspace β just the oldest within a random sample. In practice, this provides an effective approximation, especially as sample size increases, but it is provably non-exact.
There are two primary costs of maintaining an exact LRU linked list at global scale:
- Memory Overhead: A full doubly-linked list requires two extra pointers (
prev/next) per key, which accumulates significantly across millions of keys. - Read-Contention Cost: With a linked list, every single
GETmust move a node to the front of the list. That touches shared list pointers, turning read-only operations into writes against a shared, lock-guarded structure. Random sampling avoids this: aGETcan update a local timestamp without contending over a global linked list.
In this project, we chose exact
- The doubly-linked list lives inside an isolated shard rather than globally across the entire store.
- Contention on the linked list is localized only to concurrent requests hashing to the exact same shard.
- We deliberately trade a minor amount of pointer memory for 100% deterministic LRU eviction correctness, relying on lock striping to eliminate the contention bottleneck that makes global linked lists prohibitive in single-threaded or globally-locked architectures.
# Build binary
go build -o kvserver ./cmd/server
# Run with default configuration (:6379, 32 shards, 1M keys capacity)
./kvserver
# Customize shards, capacity, and enable AOF persistence
./kvserver -port 6379 -shards 64 -capacity 500000 -aof appendonly.aof -fsync everysec$ redis-cli -p 6379
127.0.0.1:6379> PING
PONG
127.0.0.1:6379> SET user:100 "Alice Smith"
OK
127.0.0.1:6379> GET user:100
"Alice Smith"
127.0.0.1:6379> DBSIZE
(integer) 1
127.0.0.1:6379> EXISTS user:100
(integer) 1
127.0.0.1:6379> DEL user:100
(integer) 1
127.0.0.1:6379> INFO
"# Stats\r\nkeys:0\r\ncapacity:1000000\r\nshards:32"$ nc 127.0.0.1 6379
SET greeting "Hello World"
+OK
GET greeting
$11
Hello World
QUIT
+OK# Run all unit and integration tests with Go Race Detector
go test -v -race ./...
# Run parallel microbenchmarks
go test -bench=. -benchmem ./pkg/sharded/...kvstore/
βββ .github/
β βββ workflows/
β βββ ci.yml # GitHub Actions automated test & race detection CI
βββ assets/
β βββ shard_scaling_benchmark.svg # Benchmark visualization chart
βββ cmd/
β βββ server/ # TCP Server main entrypoint
β β βββ main.go
β βββ benchmark/ # Load tester & shard scaling experiment CLI
β βββ main.go
βββ pkg/
β βββ basic/ # Phase 1: Simple single-threaded map store
β β βββ store.go
β β βββ store_test.go
β βββ lru/ # Phase 2: Doubly-linked list + LRU cache
β β βββ list.go
β β βββ cache.go
β β βββ cache_test.go
β βββ sharded/ # Phase 3: Sharded concurrent store (lock striping)
β β βββ hasher.go
β β βββ shard.go
β β βββ store.go
β β βββ store_test.go
β β βββ stress_test.go
β β βββ bench_test.go
β βββ resp/ # Phase 4: RESP protocol parser & writer
β β βββ resp.go
β β βββ resp_test.go
β βββ server/ # Phase 4: TCP Server with client goroutine pool
β β βββ server.go
β β βββ server_test.go
β βββ persistence/ # Phase 6: AOF log & replay engine
β βββ aof.go
β βββ aof_test.go
βββ go.mod
βββ README.md