Skip to Content
Course content

250: Interview Practice: System Design Basics for Go Backend Roles

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you hit the system design portion of a Go interview, the interviewer isn't just looking for your ability to draw boxes and arrows on a whiteboard. They want to see if you understand how Go's concurrency model interacts with the physical limits of a distributed system. A common mistake I see is candidates treating a Go service as a magic black box that scales infinitely just because goroutines are cheap. They forget that while CPU and memory are manageable, the network and the database are almost always the bottleneck.

The 'Global Map and Mutex' Trap

Imagine we're building a real-time API hit counter. You need to track how many requests each endpoint is receiving per minute. The naive approach—the one that works perfectly on your local machine but fails the interview—is to create a global map protected by a sync.Mutex. You'd probably write something where every single incoming request locks the mutex, increments the count for the specific URL, and then unlocks it.

type Counter struct {
    mu    sync.Mutex
    counts map[string]int
}

func (c *Counter) Inc(url string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.counts[url]++
}

On the surface, it looks clean. But in a high-traffic backend role, this is a red flag. You've created a massive point of contention. As your request volume grows, your goroutines will spend more time waiting for that single lock than actually doing work. Even worse, this approach only works if you have one single server. The second you scale to two or three pods in a Kubernetes cluster, your data is fragmented. Pod A has no idea what Pod B is counting, and your "system" is now just a collection of isolated islands.

Batching Local State Before the Network Hop

To fix this, we have to move the state to a distributed store like Redis, but we can't just replace the Mutex with a network call. If you call INCR in Redis for every single request, you're trading a memory lock for network latency. Your throughput will plummet because you're now bound by the round-trip time (RTT) of your network.

The professional way to handle this in Go is to embrace the "fan-in" pattern. Instead of updating the global state immediately, you treat the request handler as a producer that sends an event into a buffered channel. Then, you have a separate worker goroutine that consumes those events, aggregates them in a local map for a short window (say, one second), and performs a single batch update to Redis.

type Event struct {
    URL string
}

func (s *Server) startAggregator(ctx context.Context, events <-chan Event) {
    ticker := time.NewTicker(1 * time.Second)
    localCounts := make(map[string]int)

    for {
        select {
        case e := <-events:
            localCounts[e.URL]++
        case <-ticker.C:
            if len(localCounts) == 0 {
                continue
            }
            s.flushToRedis(localCounts)
            localCounts = make(map[string]int) // Reset for next window
        case <-ctx.Done():
            return
        }
    }
}

I prefer this approach because it decouples the request latency from the persistence latency. The user gets a response immediately, and the system handles the heavy lifting asynchronously. We've shifted the bottleneck from a locking mechanism to a throughput-oriented pipeline.

The Trade-off: Consistency vs. Throughput

Now, if you propose this in an interview, a sharp interviewer will immediately ask: "What happens if the pod crashes before the ticker fires?" This is where you show your seniority. You admit that you've traded strong consistency for high availability and throughput.

In the naive Mutex version, if the process survives, the data is "accurate" (for that one instance). In the batching version, you might lose up to one second of data if a pod is killed. In most analytics scenarios, losing 0.1% of hits is an acceptable trade-off for a 10x increase in request capacity. If the data must be 100% accurate—like in a financial ledger—you can't use this pattern; you'd need a write-ahead log (WAL) or a distributed queue like Kafka. Knowing when to use which pattern is exactly what "System Design" is actually about.




📋 Practical Task

Implement a Buffered Event Aggregator with Graceful Shutdown

Your task is to build a production-ready version of the event aggregator discussed in the lesson. You need to ensure that no data is lost when the service shuts down.

Requirements:

  • Create an Aggregator struct that accepts events via a channel.
  • Implement a Flush() method that simulates writing the current local map to a database (use fmt.Printf to show the counts being "saved").
  • Implement a Stop() method that closes the event channel and ensures all remaining buffered events are flushed to the database before the function returns.
  • Use a time.Ticker to trigger automatic flushes every 500 milliseconds.

Constraint: Your Stop() method must not simply exit; it must ensure the final state of the local map is processed. Think about how you can signal the worker goroutine to perform one last flush after the channel is closed.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.