Lesson 7
TCP server & protocol
Framing, parsing, and a production-shaped API.
Course Navigation
Back to courseLearning Objectives
-
Master network protocol design principles
-
Implement RESP (Redis Protocol) from scratch
-
Build a production-ready TCP server in Go
-
Handle concurrent connections efficiently
-
Parse and validate network requests
Lesson 7.1: Network Protocol Design
Text-based vs Binary Protocols
When designing a database protocol, you must choose between text and binary approaches. Each has distinct advantages and trade-offs.
Text-based Protocol (Redis Protocol - RESP)
Example: SET user:1 alice
Text format (human-readable):
*3\r\n # Array of 3 elements
$3\r\n # First element: 3 bytes
SET\r\n # Command
$6\r\n # Second element: 6 bytes
user:1\r\n # Key
$5\r\n # Third element: 5 bytes
alice\r\n # Value
Advantages:
- Human-readable for debugging
- Easy to test with telnet/nc
- Text editors can read/write
- Language-agnostic
Disadvantages:
- Larger wire size (more bytes)
- Parsing requires careful handling
- Line breaks matter
Binary Protocol (Protocol Buffers, MessagePack)
Example: SET user:1 alice
Binary format (compact):
01 # Op: SET
06 # Key length: 6
75 73 65 72 3A 31 # "user:1"
05 # Value length: 5
61 6C 69 63 65 # "alice"
Advantages:
- Compact representation
- Faster parsing
- Type safety
- Smaller wire size
Disadvantages:
- Not human-readable
- Requires code generation
- Harder to debug
Decision for our course: Use RESP (Redis Serialization Protocol)
-
Text-based (easier debugging)
-
Well-defined standard
-
Widely supported in clients
-
Simpler to implement correctly
RESP Protocol Specification
RESP has multiple data types for different use cases:
Simple String: +OK\r\n
Error: -ERR key not found\r\n
Integer: :42\r\n
Bulk String: $6\r\nhello!\r\n
Array: *2\r\n$3\r\nGET\r\n$4\r\nkey1\r\n
Null Bulk String: $-1\r\n
Simple Strings
Format: +\r\n
Examples:
+OK\r\n # Success response
+PONG\r\n # Ping response
Errors
Format: -\r\n
Examples:
-ERR unknown command\r\n
-ERR key not found\r\n
-WRONGTYPE Operation against a key holding wrong kind of value\r\n
Integers
Format: :\r\n
Examples:
:1000\r\n # Response to incr
:0\r\n # False/no
:1\r\n # True/yes
Bulk Strings
Format: $\r\n\r\n
Examples:
$6\r\nfoobar\r\n # 6-byte string "foobar"
$-1\r\n # Null (key not found)
$0\r\n\r\n # Empty string
Arrays
Format: *\r\n...
Examples:
*2\r\n
$4\r\nLLEN\r\n
$6\r\nmylist\r\n
*3\r\n
$3\r\nSET\r\n
$3\r\nkey\r\n
$5\r\nvalue\r\n
Complete RESP Example
Client sends:
*3\r\n
$3\r\nSET\r\n
$6\r\nuser:1\r\n
$5\r\nalice\r\n
Server responds:
+OK\r\n
Client sends:
*2\r\n
$3\r\nGET\r\n
$6\r\nuser:1\r\n
Server responds:
$5\r\nalice\r\n
Request-Response Patterns
Synchronous Request-Response
Client connects:
└─ TCP connection established
Client sends request:
GET key\r\n
Server processes:
└─ Reads key
Server sends response:
$5\r\nvalue\r\n
Pattern repeats for next request
Pipelining
Client sends multiple requests without waiting:
*2\r\n$3\r\nGET\r\n$2\r\nk1\r\n
*2\r\n$3\r\nGET\r\n$2\r\nk2\r\n
*2\r\n$3\r\nGET\r\n$2\r\nk3\r\n
Server processes and sends responses in order:
$2\r\nv1\r\n
$2\r\nv2\r\n
$2\r\nv3\r\n
Benefits:
- Reduces round-trip latency
- Increases throughput
- Better network utilization
Lesson 7.2: TCP Server in Go
net Package Fundamentals
Go’s net package provides low-level networking primitives for building servers and clients.
import "net"
// Listen creates TCP listener
listener, err := net.Listen("tcp", ":6379")
if err != nil {
log.Fatal(err)
}
defer listener.Close()
// Accept connections
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("accept error: %v", err)
continue
}
// Handle connection
go handleConnection(conn)
}
func handleConnection(conn net.Conn) {
defer conn.Close()
// Read from connection
buf := make([]byte, 1024)
n, err := conn.Read(buf)
// Write to connection
conn.Write([]byte("response"))
}
Connection Handling Patterns
Goroutine-Per-Connection Model
type Server struct {
listener net.Listener
done chan struct{}
}
func (s *Server) Start(addr string) error {
listener, err := net.Listen("tcp", addr)
if err != nil {
return err
}
s.listener = listener
go s.acceptLoop()
return nil
}
func (s *Server) acceptLoop() {
defer s.listener.Close()
for {
select {
case <-s.done:
return
default:
}
conn, err := s.listener.Accept()
if err != nil {
select {
case <-s.done:
return
default:
log.Printf("accept error: %v", err)
}
continue
}
// Handle each connection in separate goroutine
go s.handleConnection(conn)
}
}
func (s *Server) handleConnection(conn net.Conn) {
defer conn.Close()
// Process client requests
for {
// Read request
// Process
// Write response
}
}
func (s *Server) Stop() {
close(s.done)
}
Goroutine-Per-Connection Trade-offs
Advantages:
-
Simple to implement
-
Good for many concurrent connections
-
Go’s scheduler handles goroutines efficiently
Disadvantages:
-
Memory per goroutine (~2MB)
-
Can have 100K+ goroutines
-
Context switching overhead
Typical limits: 10K concurrent connections per machine. High-performance servers use event loops (epoll, kqueue).
Connection State Management
type ClientState struct {
conn net.Conn
reader *bufio.Reader
writer *bufio.Writer
lastActive time.Time
authenticated bool
db int // Selected database number
}
func (cs *ClientState) Update() {
cs.lastActive = time.Now()
}
func (cs *ClientState) Idle() time.Duration {
return time.Since(cs.lastActive)
}
// Per-connection context
type ConnectionContext struct {
state *ClientState
txn *Transaction // If in transaction
subscription *Subscription // If subscribed
}
Connection Pooling
// Server-side pooling (limit concurrent connections)
type ConnPool struct {
maxConns int
current int
mu sync.Mutex
waitQueue chan struct{}
}
func (cp *ConnPool) Acquire() error {
cp.mu.Lock()
if cp.current >= cp.maxConns {
cp.mu.Unlock()
// Wait for connection to be available
<-cp.waitQueue
cp.mu.Lock()
}
cp.current++
cp.mu.Unlock()
return nil
}
func (cp *ConnPool) Release() {
cp.mu.Lock()
cp.current--
cp.mu.Unlock()
// Signal waiting connection
select {
case cp.waitQueue <- struct{}{}:
default:
}
}
Lesson 7.3: Request Parsing
Efficient Buffer Management
import "bufio"
type RESPParser struct {
reader *bufio.Reader
}
func NewRESPParser(conn net.Conn) *RESPParser {
return &RESPParser{
reader: bufio.NewReaderSize(conn, 64*1024), // 64KB buffer
}
}
// Read line (until \r\n)
func (p *RESPParser) readLine() ([]byte, error) {
line, err := p.reader.ReadBytes('\n')
if err != nil {
return nil, err
}
// Remove \r\n
if len(line) > 0 && line[len(line)-1] == '\n' {
line = line[:len(line)-1]
}
if len(line) > 0 && line[len(line)-1] == '\r' {
line = line[:len(line)-1]
}
return line, nil
}
// Peek into buffer without consuming
func (p *RESPParser) peek() (byte, error) {
b, err := p.reader.Peek(1)
if len(b) > 0 {
return b[0], nil
}
return 0, err
}
// Read N bytes exactly
func (p *RESPParser) readN(n int) ([]byte, error) {
buf := make([]byte, n)
_, err := io.ReadFull(p.reader, buf)
return buf, err
}
Streaming Large Values
// For very large values, stream instead of buffering all
func (p *RESPParser) ParseBulkString(maxSize int64) (io.Reader, error) {
// Parse length
line, _ := p.readLine()
size := parseBulkSize(line)
if size > maxSize {
return nil, ErrValueTooLarge
}
// Return limited reader
return io.LimitReader(p.reader, size), nil
}
Protocol State Machines
type ParserState int
const (
StateWaitingForCommand ParserState = iota
StateWaitingForArgs
StateWaitingForBulk
)
type StatefulParser struct {
state ParserState
command string
args [][]byte
reader *bufio.Reader
}
func (sp *StatefulParser) Parse() (*Command, error) {
switch sp.state {
case StateWaitingForCommand:
return sp.parseCommand()
case StateWaitingForArgs:
return sp.parseArgs()
case StateWaitingForBulk:
return sp.parseBulk()
}
return nil, ErrInvalidState
}
func (sp *StatefulParser) parseCommand() (*Command, error) {
// Read array marker
marker, _ := sp.peek()
if marker != '*' {
return nil, ErrInvalidFormat
}
// Parse array length
line, _ := sp.readLine()
count := parseArrayCount(line)
sp.state = StateWaitingForArgs
return nil, ErrNeedMoreData
}
Complete Server Implementation
package server
import (
"bufio"
"context"
"fmt"
"io"
"log"
"net"
"strconv"
"sync"
"time"
)
// Server accepts client connections and processes commands
type Server struct {
addr string
listener net.Listener
done chan struct{}
wg sync.WaitGroup
db Store
maxConns int
connCount int
connMu sync.Mutex
}
// NewServer creates a new server
func NewServer(addr string, db Store) *Server {
return &Server{
addr: addr,
done: make(chan struct{}),
db: db,
maxConns: 10000,
}
}
// Start begins listening for connections
func (s *Server) Start() error {
listener, err := net.Listen("tcp", s.addr)
if err != nil {
return err
}
s.listener = listener
log.Printf("Server listening on %s", s.addr)
s.wg.Add(1)
go s.acceptLoop()
return nil
}
// acceptLoop accepts incoming connections
func (s *Server) acceptLoop() {
defer s.wg.Done()
defer s.listener.Close()
for {
select {
case <-s.done:
return
default:
}
conn, err := s.listener.Accept()
if err != nil {
select {
case <-s.done:
return
default:
log.Printf("accept error: %v", err)
}
continue
}
// Check connection limit
s.connMu.Lock()
if s.connCount >= s.maxConns {
s.connMu.Unlock()
conn.Close()
continue
}
s.connCount++
s.connMu.Unlock()
s.wg.Add(1)
go s.handleClient(conn)
}
}
// handleClient processes commands from a client
func (s *Server) handleClient(conn net.Conn) {
defer s.wg.Done()
defer conn.Close()
defer func() {
s.connMu.Lock()
s.connCount--
s.connMu.Unlock()
}()
// Set connection timeouts
conn.SetDeadline(time.Now().Add(30 * time.Second))
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
parser := NewRESPParser(bufio.NewReader(conn))
writer := bufio.NewWriter(conn)
defer writer.Flush()
ctx := context.Background()
for {
// Parse next command
cmd, err := parser.ParseCommand()
if err == io.EOF {
return
}
if err != nil {
s.sendError(writer, fmt.Sprintf("ERR %v", err))
continue
}
// Reset timeout on each successful command
conn.SetDeadline(time.Now().Add(30 * time.Second))
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
// Execute command
s.executeCommand(ctx, cmd, writer)
}
}
// executeCommand processes a single command
func (s *Server) executeCommand(ctx context.Context, cmd *Command, w *bufio.Writer) {
switch cmd.Name {
case "GET":
s.handleGet(ctx, cmd, w)
case "SET":
s.handleSet(ctx, cmd, w)
case "DEL":
s.handleDel(ctx, cmd, w)
case "PING":
s.sendSimpleString(w, "PONG")
case "ECHO":
if len(cmd.Args) < 1 {
s.sendError(w, "ERR wrong number of arguments for 'echo' command")
return
}
s.sendBulkString(w, cmd.Args[0])
case "INFO":
s.handleInfo(w)
default:
s.sendError(w, fmt.Sprintf("ERR unknown command '%s'", cmd.Name))
}
w.Flush()
}
// handleGet executes GET command
func (s *Server) handleGet(ctx context.Context, cmd *Command, w *bufio.Writer) {
if len(cmd.Args) != 1 {
s.sendError(w, "ERR wrong number of arguments for 'get' command")
return
}
value, err := s.db.Get(ctx, cmd.Args[0])
if err != nil {
s.sendNullBulkString(w)
return
}
s.sendBulkString(w, value)
}
// handleSet executes SET command
func (s *Server) handleSet(ctx context.Context, cmd *Command, w *bufio.Writer) {
if len(cmd.Args) != 2 {
s.sendError(w, "ERR wrong number of arguments for 'set' command")
return
}
err := s.db.Put(ctx, cmd.Args[0], cmd.Args[1])
if err != nil {
s.sendError(w, fmt.Sprintf("ERR %v", err))
return
}
s.sendSimpleString(w, "OK")
}
// handleDel executes DEL command
func (s *Server) handleDel(ctx context.Context, cmd *Command, w *bufio.Writer) {
if len(cmd.Args) == 0 {
s.sendError(w, "ERR wrong number of arguments for 'del' command")
return
}
deleted := 0
for _, key := range cmd.Args {
err := s.db.Delete(ctx, key)
if err == nil {
deleted++
}
}
s.sendInteger(w, int64(deleted))
}
// handleInfo returns server info
func (s *Server) handleInfo(w *bufio.Writer) {
s.connMu.Lock()
connCount := s.connCount
s.connMu.Unlock()
info := fmt.Sprintf("# Server\r\nconnected_clients:%d\r\n", connCount)
s.sendBulkString(w, []byte(info))
}
// Response writers
func (s *Server) sendSimpleString(w *bufio.Writer, msg string) {
fmt.Fprintf(w, "+%s\r\n", msg)
}
func (s *Server) sendError(w *bufio.Writer, msg string) {
fmt.Fprintf(w, "-%s\r\n", msg)
}
func (s *Server) sendInteger(w *bufio.Writer, n int64) {
fmt.Fprintf(w, ":%d\r\n", n)
}
func (s *Server) sendBulkString(w *bufio.Writer, data []byte) {
fmt.Fprintf(w, "$%d\r\n", len(data))
w.Write(data)
fmt.Fprintf(w, "\r\n")
}
func (s *Server) sendNullBulkString(w *bufio.Writer) {
fmt.Fprintf(w, "$-1\r\n")
}
// Stop gracefully shuts down the server
func (s *Server) Stop() {
close(s.done)
s.wg.Wait()
}
// Command represents a parsed client command
type Command struct {
Name string
Args [][]byte
}
// Store interface for persistence
type Store interface {
Get(ctx context.Context, key []byte) ([]byte, error)
Put(ctx context.Context, key, value []byte) error
Delete(ctx context.Context, key []byte) error
}
// RESPParser parses Redis Serialization Protocol
type RESPParser struct {
reader *bufio.Reader
}
// NewRESPParser creates a new parser
func NewRESPParser(r *bufio.Reader) *RESPParser {
return &RESPParser{reader: r}
}
// ParseCommand parses next RESP command
func (p *RESPParser) ParseCommand() (*Command, error) {
// Read array marker
marker, err := p.reader.ReadByte()
if err != nil {
return nil, err
}
if marker != '*' {
return nil, fmt.Errorf("expected '*', got %c", marker)
}
// Read array length
line, err := p.readLine()
if err != nil {
return nil, err
}
count, err := strconv.Atoi(string(line))
if err != nil {
return nil, fmt.Errorf("invalid array length: %v", err)
}
if count == 0 {
return nil, fmt.Errorf("empty array")
}
// Read first element (command name)
cmdName, err := p.parseBulkString()
if err != nil {
return nil, err
}
// Read remaining arguments
args := make([][]byte, count-1)
for i := 0; i < count-1; i++ {
arg, err := p.parseBulkString()
if err != nil {
return nil, err
}
args[i] = arg
}
return &Command{
Name: string(cmdName),
Args: args,
}, nil
}
// parseBulkString parses a RESP bulk string
func (p *RESPParser) parseBulkString() ([]byte, error) {
marker, err := p.reader.ReadByte()
if err != nil {
return nil, err
}
if marker != '$' {
return nil, fmt.Errorf("expected '$', got %c", marker)
}
line, err := p.readLine()
if err != nil {
return nil, err
}
length, err := strconv.Atoi(string(line))
if err != nil {
return nil, fmt.Errorf("invalid length: %v", err)
}
if length == -1 {
return nil, nil // Null bulk string
}
// Read data
data := make([]byte, length)
_, err = io.ReadFull(p.reader, data)
if err != nil {
return nil, err
}
// Read trailing \r\n
_, err = p.readLine()
if err != nil {
return nil, err
}
return data, nil
}
// readLine reads until \r\n
func (p *RESPParser) readLine() ([]byte, error) {
line, err := p.reader.ReadBytes('\n')
if err != nil {
return nil, err
}
// Remove \r\n
if len(line) > 0 && line[len(line)-1] == '\n' {
line = line[:len(line)-1]
}
if len(line) > 0 && line[len(line)-1] == '\r' {
line = line[:len(line)-1]
}
return line, nil
}
Lab 7.1: Implement Redis Protocol Server
Objective
Build a complete RESP-compatible TCP server with connection handling, command parsing, and response generation.
Requirements
-
TCP Server: Listen on configurable address, accept multiple concurrent connections
-
RESP Protocol: Parse all RESP data types, handle errors gracefully
-
Command Handlers: GET, SET, DEL, PING, ECHO, INFO
-
Error Handling: Invalid commands, wrong argument counts, malformed RESP
-
Testing: Test with redis-cli, concurrent client tests
-
Benchmarks: Throughput (commands/sec), connection setup overhead
Starter Code
package server
import (
"bufio"
"context"
"log"
"net"
"sync"
"time"
)
// Server accepts and handles client connections
type Server struct {
addr string
// TODO: Add fields for listener, storage, lifecycle
}
// NewServer creates a new server
// TODO: Implement
func NewServer(addr string, db Store) *Server {
return nil
}
// Start begins listening
// TODO: Implement
func (s *Server) Start() error {
return nil
}
// acceptLoop accepts incoming connections
// TODO: Implement goroutine-per-connection model
func (s *Server) acceptLoop() {
}
// handleClient processes commands from one client
// TODO: Implement command loop with parser
func (s *Server) handleClient(conn net.Conn) {
}
// executeCommand processes a single command
// TODO: Implement dispatch to handlers
func (s *Server) executeCommand(ctx context.Context, cmd *Command, w io.Writer) {
}
// Command handlers
// TODO: Implement handleGet, handleSet, handleDel
// Response writers
// TODO: Implement sendSimpleString, sendError, sendBulkString, etc
// Stop gracefully shuts down
// TODO: Implement
func (s *Server) Stop() {
}
// RESPParser parses RESP format
type RESPParser struct {
// TODO: Add fields
}
// NewRESPParser creates parser
// TODO: Implement
func NewRESPParser(r *bufio.Reader) *RESPParser {
return nil
}
// ParseCommand parses next command
// TODO: Implement complete RESP parsing
func (p *RESPParser) ParseCommand() (*Command, error) {
return nil, nil
}
// Command represents parsed command
type Command struct {
Name string
Args [][]byte
}
// Store interface
type Store interface {
Get(ctx context.Context, key []byte) ([]byte, error)
Put(ctx context.Context, key, value []byte) error
Delete(ctx context.Context, key []byte) error
}
Test Template
func TestServerBasic(t *testing.T) {
// Create mock store
store := &MockStore{data: make(map[string][]byte)}
// Start server
server := NewServer("localhost:0", store)
if err := server.Start(); err != nil {
t.Fatal(err)
}
defer server.Stop()
// Connect client
conn, _ := net.Dial("tcp", server.Addr())
defer conn.Close()
// Send SET command
fmt.Fprintf(conn, "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n")
// Read response
reader := bufio.NewReader(conn)
resp, _ := reader.ReadString('\n')
if resp != "+OK\r\n" {
t.Errorf("expected +OK, got %s", resp)
}
}
func BenchmarkServerThroughput(b *testing.B) {
store := &MockStore{data: make(map[string][]byte)}
server := NewServer("localhost:0", store)
server.Start()
defer server.Stop()
conn, _ := net.Dial("tcp", server.Addr())
defer conn.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
fmt.Fprintf(conn, "*2\r\n$4\r\nPING\r\n")
// Read response
bufio.NewReader(conn).ReadString('\n')
}
}
Acceptance Criteria
-
✅ All tests pass
-
✅ Handles concurrent clients
-
✅ Parses RESP correctly
-
✅ redis-cli compatible
-
✅ Graceful shutdown
-
✅ 100K+ commands/sec throughput
-
✅ > 80% code coverage
Summary: Week 7 Complete
By completing Week 7, you’ve learned:
1. Protocol Design
-
Text-based vs binary protocols
-
RESP (Redis Protocol) specification
-
Request-response patterns
-
Pipelining support
2. TCP Server Fundamentals
-
Go’s
netpackage -
Goroutine-per-connection model
-
Connection lifecycle management
-
Timeouts and error handling
3. Request Parsing
-
Efficient buffer management
-
RESP parsing state machine
-
Streaming large values
-
Protocol validation
4. Complete Server Implementation
-
Full working TCP server
-
RESP protocol parser
-
Command execution framework
-
Response writers
-
Connection pooling basics
Metrics Achieved:
-
✅ Handles 10,000+ concurrent connections
-
✅ Sub-millisecond command parsing
-
✅ Graceful shutdown with connection draining
-
✅ Connection timeouts to prevent hangs
Ready for Week 8?
Next week we’ll build a production-ready client library with connection pooling, retry logic, circuit breakers, and advanced networking features.
Continue to Week 8: Client Library →