Rediska: Architecture and Performance Notes for a Go-Based Redis Clone

go
redis
concurrency
systems-design
benchmarking
Author

Vinay Pandya

Published

August 25, 2026

1 Introduction

Rediska is a Redis clone I’ve been building in Go, mostly as a way to actually understand what Redis does under the hood — RESP parsing, an in-memory store, AOF persistence, transactions, HyperLogLog, pipelining, eviction policies — rather than just using it. This post walks through how it’s put together, what it currently supports, and how it stacks up against real Redis on redis-benchmark.

NoteWhat this post covers
  • The dispatch architecture: connections, a shared command channel, and a single worker
  • What’s implemented so far: persistence, expiry, eviction, transactions, HyperLogLog, pipelining
  • Graceful shutdown, and why it needs more than “just close the socket”
  • How it compares to real Redis on benchmarks, and what the gap is likely coming from
  • What’s left to test to pin that down further

2 Architecture: One Channel, One Worker

Every client connection runs on its own goroutine, reading RESP commands off the socket. None of those goroutines touch the store directly. Instead, every command — plain, pipelined, or part of a transaction — gets wrapped into a request (holding the command itself, or a batch, or a pipeline, plus a reply channel) and dropped into a single shared, buffered channel. Exactly one goroutine, the dispatch worker, ever reads from that channel: it loops for as long as the channel stays open, pulls one request at a time, runs the appropriate command logic, and sends the result back down that request’s own reply channel.

flowchart LR
    C1[connection goroutines] -->|CommandRequest| CH[(commandCh)]
    C2[active expiry goroutine] -->|CommandRequest| CH
    CH --> DW[dispatchWorker]
    DW --> STORE[(store.Default)]
    DW -.only if --appendOnly.-> AOF[(AOF file)]

Dispatch path: every producer funnels through one channel to one consumer

This is deliberate: the store is a plain map with zero locking. As long as exactly one goroutine ever touches it, that’s safe with no mutex required. The trade-off is that every command now has to travel through a channel to reach that goroutine — the connection goroutine sends its request and blocks until the worker sends a reply back, before it can write anything to the socket.

3 What’s Implemented

3.1 Persistence and expiry

Rediska supports an append-only file (AOF) mode: every write command is logged, and on startup the server replays the file to recover state. Keys can carry a TTL, and expiry is handled two ways — lazily, when a read touches an already-expired key, and actively, via a ticking goroutine that periodically sweeps the keyspace for expired entries and removes them (backing off once it stops finding many, the same general pattern real Redis uses).

3.2 Eviction policies

When a maxmemory limit is set, rediska supports both an approximate-LRU policy (sampling a handful of keys and evicting the least-recently-used among them, rather than tracking exact global recency) and an LFU policy with a logarithmic counter and configurable decay, matching the shape of Redis’s own LFU implementation. Memory usage is tracked per-key so eviction decisions have real data to sample from.

3.3 Transactions

MULTI/EXEC/DISCARD are implemented via a small per-connection state machine. Every parsed command while inside a MULTI block is classified as either a normal command to queue (acknowledged with +QUEUED), a transaction-control reply that doesn’t touch the store, or EXEC itself — which sends the whole queued batch through the dispatch channel as one request. The worker runs every command in that batch back-to-back with nothing else able to interleave, and wraps the results into a single RESP array, matching Redis’s atomicity guarantee: a transaction executes as one indivisible block.

3.4 HyperLogLog

Rediska implements PFADD, PFCOUNT, and PFMERGE — a probabilistic cardinality estimator that lets you count approximately how many distinct elements have been added to a key using a fixed, small amount of memory regardless of how many elements go in. It supports both the sparse and dense internal encodings Redis itself uses (sparse for small cardinalities, promoting to dense as a key sees more distinct values), rather than always paying the dense representation’s memory cost upfront.

3.5 Pipelining

A pipelining client fires several commands without waiting for replies between them. After decoding one command, the connection handler checks whether more data is already sitting in the read buffer — meaning it arrived over the network already, no blocking read needed — and if so, keeps decoding into a batch instead of dispatching one command at a time. Once everything currently available is collected, the whole batch ships through the channel as a single request, and the worker concatenates each command’s individual reply into one response — one channel round trip for N commands instead of N.

3.6 Monitoring and graceful shutdown

An INFO command exposes keyspace and server stats in the format a Redis exporter expects, so Prometheus/Grafana can scrape it like real Redis. On shutdown (SIGINT/SIGTERM), the server stops accepting new connections, waits for every in-flight connection and the expiry goroutine to finish (tracked via a sync.WaitGroup), only then closes the dispatch channel, waits for the worker to fully drain it, and finally syncs and closes the AOF file — in that order, since each step depends on the one before it having actually completed, not just been requested.

flowchart TD
    SIG[SIGINT / SIGTERM] --> CTX["ctx.Done() closes"]
    CTX --> L[listener: closes socket]
    CTX --> A[accept loop: sees ctx.Err]
    CTX --> CN[connections: watcher force-closes conn]
    CTX --> E[active expiry: exits select]
    CN --> WG["producers.Wait returns"]
    E --> WG
    WG --> CLOSE["close(commandCh)"]
    CLOSE --> WD["dispatchWorker drains channel, closes workerdone"]
    WD --> AOFC["AOF Sync() + Close()"]

How a SIGINT/SIGTERM signal fans out and drains the pipeline before shutdown

4 How It Compares to Redis

Benchmarked with redis-benchmark against real Redis on the same machine. The starting point was roughly half of Redis’s throughput and double its latency. After a round of profiling-driven fixes — removing unconditional per-command debug logging, replacing per-command read-deadline syscalls with a single shutdown-watcher goroutine, pooling reply channels instead of allocating one per command, and reducing RESP parsing overhead — single-command (non-pipelined) throughput closed to roughly 1.18x Redis’s, and pipelined throughput improved from around 60k requests/sec to 300k+, once pipelined commands were batched into a single dispatch round trip instead of paying a channel handoff per command.

There’s still a real, measurable gap on the non-pipelined path, and it’s worth being precise about where that’s likely coming from rather than assuming.

4.1 What profiling actually showed

Here’s the CPU breakdown from an earlier profiling run (go tool pprof, ~20 seconds sampled under sustained redis-benchmark load), by line — I don’t have the raw trace files anymore, but the numbers are from that run:

Cost % of total CPU Cause
writer.Flush() 37.6% The TCP write syscall for each reply — largely inherent to non-pipelined request/response
core.Decode() 19.3% RESP parsing off the socket
dispatchWorker command dispatch 6.4% Actual command execution (store lookup, RESP encode)
Channel send/recv/alloc (replych, commandCh) ~4.7% The cost of handing each command off to the single dispatch worker via a channel

Two things stand out. First, the single biggest cost (writer.Flush()) is largely inherent to a non-pipelined request/response protocol in general — every command needs its own network round trip, and Redis pays a version of this cost too, just without a userspace scheduler involved. Second, the channel-handoff cost, while real, was the smallest line item in that particular run — smaller than logging and deadline-polling overhead were before those got fixed. That’s a useful reminder: it’s easy to reason your way to “the channel design must be the bottleneck” from first principles (Go’s goroutine park/wake is genuinely more expensive than a C event loop’s inline execution), and that reasoning is sound, but the actual weight of that cost in the total budget is something only a fresh profile can confirm — not intuition.

TipWhy real Redis doesn’t pay the channel cost

Redis is also single-threaded for command execution — one command at a time, same as rediska’s one-worker model. But it runs inside a plain C event loop calling epoll_wait, with no second thread to hand a command off to. Rediska’s design adds a cross-goroutine hop through a channel for every command, which the Go runtime implements via a goroutine park/wake cycle — real overhead, even if the profile above shows it isn’t currently the dominant one.

5 What’s Left to Test

A few things would sharpen this picture rather than leave it at “roughly 1.18x slower”:

  1. Re-profile on current code. The breakdown above predates some of the later fixes; a fresh pprof run under the same redis-benchmark load would show whether the channel-handoff share has grown relative to Flush/Decode now that logging and deadline overhead are gone.
  2. Try a mutex-guarded direct-dispatch path as an experiment, comparing it against the current channel design under identical load, to get a real before/after number rather than an estimate.
  3. Benchmark a wider command set. Current numbers are mostly SET/GET/INCR; transactions, HyperLogLog, and pipelined batches with mixed command types haven’t been benchmarked in isolation yet.
  4. Test under memory pressure. Exercise the LRU/LFU eviction paths under a constrained maxmemory with realistic access patterns, not just correctness tests, to see how eviction sampling overhead behaves under load.
  5. Test AOF durability under load and crash. Recovery correctness has been checked; recovery time and AOF write overhead under sustained write-heavy load haven’t been measured.
  6. Scale concurrent clients further. Current benchmarks use a fixed concurrency level; sweeping connection counts would show whether the gap to Redis widens, narrows, or stays flat as concurrency increases.

6 Appendix: Repository

Code referenced in this post is from vinayhpandya/learning-redis.