Advancedgo~300 min

📦 mini-Redis

Write a tiny Redis-compatible server in Go. Implement the RESP wire protocol, handle SET / GET / DEL / KEYS / PING, add concurrency with mutexes, and persist state with an RDB-style snapshot + an AOF append-only log. Real Redis is bigger, but the bones are the same.

You're going to write a Redis-compatible server in Go. Not a Redis clone — a teaching toy that nails the bones: the RESP wire protocol, the SET/GET/DEL/KEYS/PING command surface, mutex-protected concurrent connections, and persistence via RDB snapshot + AOF log. By the end you'll be able to point redis-cli at it and it'll just work.

This is the longest project in the catalog. Pace yourself. Each milestone is a working artefact you can stop at — v1 round-trips a PING; v2 actually stores values; v3 survives restarts and concurrent clients.

Before you start

brew install go for Go itself; brew install redis ships redis-cli alongside the (full) Redis server. We won't run real Redis — but redis-cli is the easiest way to talk to your toy.

The walkthrough

Make the project + Go module

In your terminal, make a project folder and initialise a Go module:

mkdir mini-redis && cd mini-redis
go mod init mini-redis
mkdir cmd internal
touch cmd/server/main.go

cmd/server/main.go is where the entrypoint lives; we'll put RESP + store + persistence under internal/. That layout matches Go convention — anything in internal/ can't be imported by other modules, which is exactly right for a toy.

After this step your file should look like… (go.mod)
module mini-redis

go 1.22

A TCP listener + accept loop

In cmd/server/main.go, listen on port 6380 (one off from real Redis' 6379 so you can run both side-by-side) and accept connections in a loop. For now, every connection just gets logged + closed.

After this step you should see…
mini-redis listening on :6380
accepted connection from 127.0.0.1:54321

Define the RESP value type

Create internal/resp/value.go. RESP is five primitive types. Encode them as one tagged union (Go-flavoured: a struct with a kind discriminator + per-kind fields).

+OK\r\n              SimpleString
-ERR something\r\n   Error
:42\r\n              Integer
$5\r\nhello\r\n      BulkString (length-prefixed; nil = $-1\r\n)
*2\r\n$3\r\nfoo\r\n... Array
After this step your file should look like… (internal/resp/value.go)
package resp

type Kind int

const (
  KindSimpleString Kind = iota
  KindError
  KindInteger
  KindBulkString
  KindArray
)

type Value struct {
  Kind   Kind
  Str    string
  Int    int64
  Bulk   []byte
  IsNull bool
  Array  []Value
}

Read RESP from a `bufio.Reader`

In internal/resp/parser.go, write Parse(r *bufio.Reader) (Value, error). Read the first byte to learn the type, then dispatch.

After this step you should see…
array of length 2: PING hello

Encode RESP to a `bufio.Writer`

Add func Write(w *bufio.Writer, v Value) error to the same package. Mirror the parser exactly. After writing, the caller must w.Flush().

After this step your file should look like… (internal/resp/writer.go)
func Write(w *bufio.Writer, v Value) error {
  switch v.Kind {
  case KindSimpleString:
  	fmt.Fprintf(w, "+%s
", v.Str)
  case KindError:
  	fmt.Fprintf(w, "-%s
", v.Str)
  case KindInteger:
  	fmt.Fprintf(w, ":%d
", v.Int)
  case KindBulkString:
  	if v.IsNull {
  		fmt.Fprint(w, "$-1
")
  	} else {
  		fmt.Fprintf(w, "$%d
", len(v.Bulk))
  		w.Write(v.Bulk)
  		fmt.Fprint(w, "
")
  	}
  case KindArray:
  	fmt.Fprintf(w, "*%d
", len(v.Array))
  	for _, item := range v.Array {
  		if err := Write(w, item); err != nil {
  			return err
  		}
  	}
  }
  return nil
}

Per-connection goroutine: read frame, dispatch, write reply

Back in main.go, replace the "log + close" placeholder with a per-connection goroutine that reads RESP frames in a loop and replies. For now, the dispatch is a stub that returns +PONG\r\n for everything.

After this step you should see…
# in another terminal
$ redis-cli -p 6380 PING
PONG
$ redis-cli -p 6380 PING hello
PONG

The Store type — a map plus a method API

Create internal/store/store.go. Plain map[string]string for now (we'll add a mutex in milestone 3). Methods: Set, Get, Del, Keys.

After this step your file should look like… (internal/store/store.go)
package store

import "path/filepath"

type Store struct {
  data map[string]string
}

func New() *Store {
  return &Store{data: make(map[string]string)}
}

func (s *Store) Set(key, value string) {
  s.data[key] = value
}

func (s *Store) Get(key string) (string, bool) {
  v, ok := s.data[key]
  return v, ok
}

func (s *Store) Del(keys ...string) int {
  n := 0
  for _, k := range keys {
  	if _, ok := s.data[k]; ok {
  		delete(s.data, k)
  		n++
  	}
  }
  return n
}

func (s *Store) Keys(pattern string) []string {
  out := []string{}
  for k := range s.data {
  	matched, _ := filepath.Match(pattern, k)
  	if matched {
  		out = append(out, k)
  	}
  }
  return out
}

Implement SET

In cmd/server/main.go, replace the +PONG stub with a dispatch(v Value, store *Store) Value function. Start with SET. Argument shape: *3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n — an array whose first element is the command name (case-insensitive), followed by args.

After this step you should see…
$ redis-cli -p 6380 SET name alice
OK

Implement GET

Add a case "GET": arm. Validate argument count, look up the key, return either a bulk string or the nil bulk ($-1\r\n).

After this step you should see…
$ redis-cli -p 6380 SET name alice
OK
$ redis-cli -p 6380 GET name
"alice"
$ redis-cli -p 6380 GET missing
(nil)

Implement DEL (variadic) — return the count deleted

DEL key [key ...] accepts ≥ 1 key. Return an Integer with the count of keys actually removed (a missing key counts as 0, not as an error).

After this step you should see…
$ redis-cli -p 6380 SET a 1
OK
$ redis-cli -p 6380 SET b 2
OK
$ redis-cli -p 6380 DEL a b c
(integer) 2

Implement KEYS (glob match) — return an array of matches

KEYS * returns every key. KEYS user:* returns every key starting with user:. Use the filepath.Match pattern matcher you already wired up.

After this step you should see…
$ redis-cli -p 6380 SET user:1 alice
OK
$ redis-cli -p 6380 SET user:2 bob
OK
$ redis-cli -p 6380 SET other 99
OK
$ redis-cli -p 6380 KEYS user:*
1) "user:1"
2) "user:2"

Implement PING [msg] — returns PONG or echoes the message

PING+PONG\r\n. PING hello$5\r\nhello\r\n (a bulk string echo). One arg max.

After this step you should see…
$ redis-cli -p 6380 PING
PONG
$ redis-cli -p 6380 PING hello
"hello"

Why does PING with no argument return SimpleString +PONG\\r\\n but PING hello return a BulkString?

Wrap the store with sync.RWMutex

Two clients writing the same map at the same time = data race + crash. Add an sync.RWMutex. Reads (Get, Keys) take RLock; writes (Set, Del) take Lock. Run go test -race ./... to confirm it's clean.

After this step you should see…
# in two terminals at once
$ for i in $(seq 1 1000); do redis-cli -p 6380 SET "k$i" "$i"; done
$ for i in $(seq 1 1000); do redis-cli -p 6380 GET "k$i"; done
# both finish without server crash; final KEYS '*' shows 1000 entries.

Periodic RDB snapshot — full-state dump to a file

Add func (s *Store) Snapshot(path string) error that writes the entire map to disk via encoding/gob. In main, kick off a goroutine that calls it every 10 seconds.

After this step your file should look like… (internal/store/snapshot.go)
func (s *Store) Snapshot(path string) error {
  s.mu.RLock()
  defer s.mu.RUnlock()
  tmp := path + ".tmp"
  f, err := os.Create(tmp)
  if err != nil {
  	return err
  }
  defer f.Close()
  if err := gob.NewEncoder(f).Encode(s.data); err != nil {
  	return err
  }
  if err := f.Sync(); err != nil {
  	return err
  }
  return os.Rename(tmp, path)
}

Load the RDB if it exists at boot

Add func (s *Store) LoadSnapshot(path string) error. In main, call it before the listener starts. Missing file is not an error; a load-failure on a present file is.

After this step you should see…
# After: SET name alice; (wait 11s for snapshot); restart server
$ redis-cli -p 6380 GET name
"alice"

Append-only log — every write replicated to disk; replayed on startup

The snapshot loses up to 10 seconds of writes on a crash. Add an append-only file (AOF) that records every mutating command (SET, DEL). On startup, replay the AOF AFTER loading the snapshot so we recover everything.

After this step you should see…
# After SET name alice; SET name bob; (no time for snapshot); kill -9 server
$ redis-cli -p 6380 GET name
"bob"

Integration tests — start the server in a goroutine, talk to it via net.Dial

Write cmd/server/main_test.go that boots the server in-process, opens a TCP connection from the test, and exercises SET → GET → DEL → KEYS. Use go test ./... to run.

After this step you should see…
$ go test ./...
ok      mini-redis/cmd/server   0.024s
ok      mini-redis/internal/resp 0.011s
ok      mini-redis/internal/store 0.018s

Why do we run the server in-process (in a goroutine) for the integration test, rather than spawning it as a subprocess?

Write the README — what works, what doesn't, how to run

End by writing README.md covering: what mini-redis implements, what it intentionally doesn't (EXPIRE, transactions, replication, RESP3), how to build (go build ./cmd/server), how to run (./server from the project dir), how to point redis-cli at it. Include a line about the on-disk files (dump.rdb, appendonly.aof) and how to reset (rm them).

After this step your file should look like… (README.md)
# mini-redis

A teaching toy: a Redis-compatible server in ~600 lines of Go. Speaks
RESP, supports SET / GET / DEL / KEYS / PING, persists state via RDB
snapshot + AOF log.

## What works

- The 5 commands above
- Concurrent clients (mutex-protected store)
- Snapshot every 10s + AOF after every write
- Replay on startup: snapshot first, then AOF tail

## What is intentionally out of scope

- EXPIRE / TTL
- MULTI / EXEC / WATCH
- Pub/Sub
- Replication
- Cluster
- RESP3 (we only do RESP2)
- SCAN (we use KEYS, which is O(n))

## Build + run

```bash
go build -o server ./cmd/server
./server                  # listens on :6380
redis-cli -p 6380 PING    # in another terminal
```

## On-disk files

- `dump.rdb` — RDB snapshot, written every 10s.
- `appendonly.aof` — append-only command log, fsynced after every mutation.

To reset state, delete both files before starting the server.

You did it

Run go build -o server ./cmd/server && ./server once more. In another terminal, redis-cli -p 6380 and play. You wrote a real wire-protocol server. The pattern — accept loop, per-connection goroutine, framed parser/writer, mutex-protected state, snapshot + AOF — is how you build any networked server worth running.

What you covered:

  • TCP + the accept-loop pattern
  • A binary-safe wire-format parser (RESP)
  • Tagged-union design in idiomatic Go
  • sync.RWMutex and the rules for holding locks
  • The atomic-replace + fsync persistence patterns
  • RDB-style full-state snapshot
  • AOF-style append-only command log + replay
  • In-process integration testing with net.Dial

Stretch goals