This one goes underneath net/httpentirely. Redis doesn't expose a RESTful API — it defines its own Redis Serialization Protocol (RESP) and pushes bytes straight over TCP. So before any of the interesting key-value work, there's a more basic question to answer: reading a stream of bytes, how do you know when a message has actually finished arriving? That problem is called framing.
Framing
There are three common approaches. Fixed length is the simplest — every frame is exactly N bytes, which is what UDP headers do. Delimiter separated means reading until you hit a marker, like the blank line that ends HTTP headers; the cost is that the marker can never appear in the payload, which is where escaping comes from. Length prefix skips the guesswork by telling the reader up front how many bytes to expect, which is how WebSocket frames work.
Most real protocols mix them. HTTP is delimiter separated for headers and length prefixed for the body via Content-Length. RESP picks the same two.
Type Prefix Example encoding ------------- ------ ------------------------------------ Simple String + +OK\r\n Error - -ERR unknown command\r\n Integer : :42\r\n Bulk String $ $5\r\nhello\r\n ($-1\r\n = null) Array * *2\r\n$3\r\nGET\r\n$3\r\nfoo\r\n
Simple strings, errors and integers are trivial — and they only ever appear in responses. The two interesting types are bulk strings and arrays, and both combine a length prefix with CRLF delimiters. Commands are alwayssent as arrays of bulk strings, so that's the only shape the parser has to ingest.
The subset of commands I implemented:
PING liveness +PONG (or echo arg) ECHO <msg> return the arg bulk string of <msg> SET <k> <v> store, no expiry +OK GET <k> fetch bulk string of value DEL <k> [k...] delete keys :<count deleted> EXISTS <k> [k...] count existing :<count> INCR <k> atomic +1, init 1 :<new value> EXPIRE <k> <secs> set TTL :1 set, :0 no such key TTL <k> seconds left :<n>, :-1 no TTL, :-2 no key
Parsing bulk strings
A bulk string like $5\r\nhello\r\n is: a $, the length N written as base-10 digits (one byte each), a CRLF, N bytes of payload, and a closing CRLF.
My first attempt was to scan for the first CRLF and split there. That turned out to be wrong: a malformed frame can be missing its length CRLF entirely while the payload happens to contain one, and the parser would happily cut in the wrong place. The correct approach is to walk the bytes deliberately — assert the leading $, read digits one at a time until CR, verify the CR is followed by LF, validate the length is a positive integer, and only then read the payload.
// read length until we get CR
i := 1
for i = 1; i < len(buff); i++ {
if buff[i] >= '0' && buff[i] <= '9' {
continue // it is a digit
} else if buff[i] == '\r' {
break
} else {
return nil, 0, invalidDataErr
}
}
lengthEnd := i // position of \r
if lengthEnd == 1 {
return nil, 0, invalidDataErr // missing length: $\r\n
}
// now we are at \r - need to validate next byte is \n
i++
if i >= len(buff) {
return nil, 0, nil // buffer too small, incomplete data
}
if buff[i] != '\n' {
return nil, 0, invalidDataErr
}
length, err := strconv.Atoi(string(buff[1:lengthEnd]))ParseData returns the payload, the total number of bytes consumed from the buffer (always more than the payload length), and an error — so $4\r\nPING\r\n yields []byte("PING"), 10, nil. Tracking bytes consumed is what lets the caller keep reading from the right offset.
Getting every edge case right here was genuinely tedious, and it took a lot of tests. The payoff is a clean three-way distinction between valid, invalid, and merely incomplete data — which the read loop later depends on entirely. One thing worth noting: the length is user supplied, so something like $10000000000 would have the server reading forever. Real Redis caps it, and adding the same guard is trivial.
Parsing arrays
Once bulk strings work, arrays are nearly free. *2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n is a *, a base-10 length, a CRLF, then N bulk strings back to back. So ParseMessage reads the length exactly the same way and then calls ParseData in a loop, returning a [][]byte, the bytes consumed, and an error. That example parses to [[]byte("ECHO"), []byte("hello")] with 25 bytes consumed.
The store
The easiest part: a map[string]string behind a mutex. The only real design question was expiry. My first instinct was to spin up a goroutine per expiring key to delete it later, which is wildly wasteful. The simpler answer is lazy expiration — keep a second map of expiry timestamps and check it on access.
type RedisStore struct {
mu sync.RWMutex
data map[string]string
expire map[string]int64
now func() time.Time
}
func (r *RedisStore) checkExpire(key string) {
now := r.now().Unix()
if expiration, toBeExpired := r.expire[key]; toBeExpired && now >= expiration {
delete(r.data, key)
delete(r.expire, key)
}
}
func (r *RedisStore) Get(key string) (string, bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.checkExpire(key)
value, exists := r.data[key]
return value, exists
}Real Redis does both: lazy checks on access, plus a sweeper that samples keys with a TTL roughly ten times a second, so keys that are set and never touched again still get reclaimed eventually.
Responder
The glue between parser and store. Every message has the same shape — COMMAND args — so the responder is a switch over the first element that validates the argument count, calls the matching store method, and encodes the reply. Nothing clever, which is the point.
Buffered read loop
This is where the parser's return values earn their keep. A single conn.Read can hand you less than one message, exactly one, or several at once. ParseMessageconsumes at most one, so the loop is: parse from the buffer; if bytes consumed is greater than zero, reslice the buffer past the message and break; otherwise read more from the connection and try again. A non-nil error means the data is invalid and the connection dies; a nil error with zero bytes consumed just means the frame hasn't fully arrived yet.
func RedisConnection(conn net.Conn, kv responder.Store) {
defer conn.Close()
buff := make([]byte, 0, 1024)
for {
var message [][]byte
for {
// read buff first - in case more than 1 message came
readMessage, bytesRead, err := parser.ParseMessage(buff)
if err != nil {
log.Printf("Got error parsing: %v\n", err)
return
}
if bytesRead > 0 {
newBuff := make([]byte, 0, 1024)
buff = append(newBuff, buff[bytesRead:]...) // reslice
message = readMessage
break
}
data := make([]byte, 1024)
n, err := conn.Read(data)
if err != nil {
log.Printf("Got error reading: %v\n", err)
return
}
buff = append(buff, data[:n]...)
}
response, err := responder.GetResponse(message, kv)
if err != nil {
return
}
if _, err = conn.Write(response); err != nil {
return
}
}
}main itself is barely anything: open a TCP listener, and hand each accepted connection to this function in its own goroutine.
Testing
Two findings worth keeping. The first is time. Coming from JavaScript, my reflex is to fake the clock globally and remember to restore it. In Go the neater option is the now func() time.Time field on the store — tests inject their own. Testing a 5-second TTL becomes moving the fake clock forward 5 seconds instead of actually sleeping for them.
The second is concurrency. I added a test that fires INCR from 100 goroutines and asserts the counter landed exactly 100 higher, and ran the suite with -race to let the built-in race detector flag unsynchronised writes.
The interesting bit is what the race detector couldn't catch. My memory access was correctly guarded by mutexes — clean under -race — but the logic was still wrong: INCR on a missing key returned 0 instead of initialising to 1. Only the assertion on the final value caught it. Synchronisation and correctness are separate problems.