Distributed systems, section 2 of 4, 4h

Working through the classic designs

By the end: You can handle the standard conventional design questions confidently.

  • A URL shortener, as a warm-up
  • A rate limiter
  • A news feed
  • The questions behind every design

New to distributed systems? One machine can only do so much work, and will eventually fall over. Distributed systems is the accumulated technique for spreading a job across many machines and still getting a correct answer: sending each request to a free machine, splitting data so no single machine holds all of it, keeping copies in step, queuing work that cannot be done right now, and choosing what to give up when the network between them breaks. Why it matters

System design interviews reuse a small set of problems, and working through them is the fastest way to find which parts of your model of distributed systems are vague. This section does three: a URL shortener, a rate limiter and a news feed. The rate limiter is not only an exercise — every model API has one, and you will be writing the client side of it. Underneath, each asks the same questions about reads against writes, where state lives, and what happens when one component disappears.

A URL shortener#

The standard warm-up, because it exercises the basics without complication. Generate a short key, store the mapping, redirect on lookup. The interesting parts are key generation without collisions, and the read-heavy traffic, which makes caching the dominant design decision.

Key generation is where the arithmetic lives, and it is worth doing rather than asserting, because the usual answer — seven base-62 characters, three and a half trillion combinations, plenty — is wrong in a specific and instructive way. Collisions between random keys follow the birthday pattern: what matters is not the keyspace against one key, but the keyspace against the square of how many keys you have already stored.

import math

ALPHABET = 62          # a-z A-Z 0-9

print("key length   keyspace        after 1 billion URLs: expected collisions")
for length in [5, 6, 7, 8, 10]:
    space = ALPHABET ** length
    n = 1e9
    expected = n * n / (2 * space)                 # birthday approximation
    print(f"{length:10}   {space:.3e}   {expected:38,.1f}")

print()
print("chance the NEXT random key collides, at 1 billion keys already stored")
for length in [6, 7, 8]:
    space = ALPHABET ** length
    print(f"  {length} chars: {1e9/space:.2e}  ->  one retry every "
          f"{space/1e9:,.0f} inserts on average")

print()
print("counter-based keys instead: no collisions by construction")
for n in [1e6, 1e9, 1e12]:
    print(f"  {n:.0e} URLs needs {math.ceil(math.log(n, ALPHABET))} base-62 characters")

# key length   keyspace        after 1 billion URLs: expected collisions
#          5   9.161e+08                            545,772,384.2
#          6   5.680e+10                              8,802,780.4
#          7   3.522e+12                                141,980.3
#          8   2.183e+14                                  2,290.0
#         10   8.393e+17                                      0.6
#
# chance the NEXT random key collides, at 1 billion keys already stored
#   6 chars: 1.76e-02  ->  one retry every 57 inserts on average
#   7 chars: 2.84e-04  ->  one retry every 3,522 inserts on average
#   8 chars: 4.58e-06  ->  one retry every 218,340 inserts on average
#
# counter-based keys instead: no collisions by construction
#   1e+06 URLs needs 4 base-62 characters
#   1e+09 URLs needs 6 base-62 characters
#   1e+12 URLs needs 7 base-62 characters
Seven characters gives 141,980 collisions across a billion URLs, which is one for every seven thousand you create. The design must survive a collision, not assume one away.

So the answer has two halves. Either generate randomly and insert with a uniqueness constraint, retrying on conflict — cheap, because the retry rate is one in a few thousand, and safe, because the database and not your code decides who won. Or drop randomness and encode an incrementing counter in base 62, which cannot collide at all and needs only six characters for a billion URLs. The counter version costs you a coordination point for the counter and makes your keys guessable and your total volume public, which is a real product objection and the reason the random version is common despite needing the retry.

A rate limiter#

Directly relevant to AI work, since every model API has one. The token bucket algorithm refills tokens at a fixed rate and spends one per request, allowing bursts up to the bucket size. The distributed version needs shared state, usually in a fast store, and has to handle the race between checking and decrementing.

Token bucket
  capacity 100, refill 10 per second
  request arrives: if tokens >= 1, spend one and allow
                   otherwise reject with 429

Allows a burst of 100, then sustains 10 per second.

Written out, it is about ten lines, and the one subtlety is that you never run a timer. You store the token count and the timestamp of the last request, and refill lazily by multiplying the elapsed time by the rate when the next request arrives. That is what makes it cheap enough to run per tenant across millions of keys.

class TokenBucket:
    def __init__(self, capacity, refill_per_second, now=0.0):
        self.capacity, self.rate = capacity, refill_per_second
        self.tokens, self.stamp = float(capacity), now

    def allow(self, now):
        self.tokens = min(self.capacity, self.tokens + (now - self.stamp) * self.rate)
        self.stamp = now
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

bucket = TokenBucket(capacity=100, refill_per_second=10)
allowed = []
for second in range(6):
    n = sum(bucket.allow(second + i * 0.004) for i in range(200))   # 200 requests/s
    allowed.append(n)
print("200 requests a second, bucket capacity 100, refill 10/s")
print("  allowed per second:", allowed)
print("  total allowed", sum(allowed), "of 1200; the first second spends the burst,")
print("  after that the caller gets the refill rate and nothing more")

# 200 requests a second, bucket capacity 100, refill 10/s
#   allowed per second: [107, 10, 10, 10, 10, 10]
#   total allowed 157 of 1200; the first second spends the burst,
#   after that the caller gets the refill rate and nothing more
The two parameters do different jobs and are asked about separately: capacity is how much burst you tolerate, rate is the sustained throughput you are selling. A capacity equal to the rate means no burst at all.

Now the distributed part, which is where the question is actually decided. With several API servers the bucket has to live in a shared store, and the naive implementation reads the count, compares it to the limit and writes the new value — three steps with a network round trip between them, during which every other server is doing the same thing.

import threading, time

LIMIT = 100

class Store:
    """Stands in for Redis. Every operation pays a small network delay."""
    def __init__(self):
        self.n = 0
        self.lock = threading.Lock()
    def get(self):
        time.sleep(0.0005)
        return self.n
    def set(self, v):
        time.sleep(0.0005)
        self.n = v
    def incr_if_below(self, limit):        # one atomic server-side operation
        with self.lock:
            time.sleep(0.001)
            if self.n < limit:
                self.n += 1
                return True
            return False

def race(store, atomic):
    allowed = []
    def worker():
        for _ in range(20):
            if atomic:
                ok = store.incr_if_below(LIMIT)
            else:
                n = store.get()            # read
                ok = n < LIMIT
                if ok:
                    store.set(n + 1)       # write, after someone else may have read
            allowed.append(ok)
    threads = [threading.Thread(target=worker) for _ in range(20)]
    for t in threads: t.start()
    for t in threads: t.join()
    return sum(allowed)

for label, atomic in [("read then write", False), ("atomic check-and-increment", True)]:
    s = Store()
    n = race(s, atomic)
    print(f"{label:28} allowed {n:4} of 400 requests against a limit of {LIMIT}"
          f"   {'OVER BY ' + str(n - LIMIT) if n > LIMIT else 'exact'}")

# read then write              allowed  400 of 400 requests against a limit of 100   OVER BY 300
# atomic check-and-increment   allowed  100 of 400 requests against a limit of 100   exact
The read-then-write limiter allowed four times the limit on every run here. The exact overshoot depends on timing and machine, the direction never does, and in production it appears as a limiter that works fine in testing and stops working under load.

The fix is to make the check and the decrement one operation the store executes itself — an atomic counter, a compare-and-set, or a small script run server-side. Then answer the follow-up before it is asked: if that store is unavailable, you choose between failing open, which drops the limit entirely and lets a caller overwhelm you, and failing closed, which rejects everyone because your limiter is down. The usual answer is to fail open for a short window with a conservative local limit per server, on the reasoning that a rate limiter exists to protect you from overload and should not itself become the outage.

A news feed#

The key decision is when to assemble a user's feed. Fan-out on write pushes each new post into followers' feeds immediately, making reads fast and writes expensive. Fan-out on read assembles at request time, the opposite trade. Real systems do both, with fan-out on read for accounts with enormous follower counts.

Why the hybrid exists is a distribution argument, and putting numbers on it is what separates a memorised answer from a designed one. Follower counts follow a heavy-tailed distribution, so the mean is meaningless and a tiny number of accounts dominate the write volume.

import random

random.seed(4)
USERS = 1_000_000
# A realistic shape: nearly everyone small, a thin tail of enormous accounts.
followers = [int(random.paretovariate(1.2) * 30) for _ in range(USERS)]
followers[0] = 50_000_000                       # one celebrity

posts_per_user_per_day = 0.5

write_cost = sum(f * posts_per_user_per_day for f in followers)
print(f"fan-out on write: {write_cost:,.0f} feed rows written a day")
print(f"  the single largest account is "
      f"{followers[0]*posts_per_user_per_day/write_cost:.0%} of that")

# Hybrid: anyone above the threshold is pulled at read time instead.
for threshold in [10_000, 100_000, 1_000_000]:
    written = sum(f * posts_per_user_per_day for f in followers if f <= threshold)
    big = sum(1 for f in followers if f > threshold)
    print(f"  threshold {threshold:>9,}: {written:15,.0f} rows written a day, "
          f"{big:3} accounts pulled at read time")

print(f"average account follows {sum(followers)/USERS:.0f} others, so a read-time "
      f"assembly touches that many timelines")

# fan-out on write: 124,530,152 feed rows written a day
#   the single largest account is 20% of that
#   threshold    10,000:      61,457,893 rows written a day, 919 accounts pulled at read time
#   threshold   100,000:      71,206,486 rows written a day,  58 accounts pulled at read time
#   threshold 1,000,000:      77,776,094 rows written a day,   6 accounts pulled at read time
# average account follows 249 others, so a read-time assembly touches that many timelines
One account out of a million causes a fifth of all feed writes. Pulling the 919 accounts above ten thousand followers at read time halves the total write volume, and the cost is a merge across a few timelines on a small number of reads.

Say the threshold out loud and say what it costs, because that is the graded part. Below it, push on write and reads are one range scan. Above it, pull on read and merge. The reader's request then does one lookup for the pushed part plus a merge over however many large accounts they follow, which is a handful rather than the 249 timelines a pure read-time assembly would touch. And note the ordering problem the hybrid creates: two sources of posts have to be merged into one ranked list, so whatever ordering key you use has to be comparable across both.

Every design comes back to the same questions

What is the read to write ratio. What must be consistent and what can be eventual. Where is the hot path. What fails, and what happens then. Asking these explicitly structures any design round.

All three designs above answer those four questions and nothing else. The shortener is overwhelmingly reads, nothing needs to be consistent beyond the mapping existing, the hot path is the redirect, and a cache miss is the failure. The rate limiter is balanced, the counter must be atomic, the hot path is the shared store, and that store dying is the failure you must have an answer for. The feed is read-heavy with a pathological write tail, staleness of seconds is fine, the hot path is the read, and the failure is a celebrity posting. Once you can produce those four sentences for a problem you have not seen before, you are doing the round properly.

What to keep and what to skip#

  • Keep: the four questions, and ask them before naming a single component.
  • Keep: random keys collide at a rate you can compute, so the insert must handle a conflict rather than assume one away.
  • Keep: a distributed rate limiter needs one atomic operation, and needs a stated behaviour when its store is down.
  • Keep: the hybrid feed threshold and roughly why it is where it is.
  • Skip: memorising a canonical architecture diagram per problem. Interviewers change one constraint to find out whether you memorised it.
  • Skip: naming specific products. Say what the component must do; the brand adds nothing and invites a question about internals you do not need.

Where people go wrong

Launching into components before asking about the read to write ratio. It decides most of the design, and asking it first is the signal interviewers are listening for.

If this comes up in an interview

How would you design a rate limiter?

Token bucket: tokens refill at a fixed rate, each request spends one, and bursts are allowed up to the bucket size. Distributed, it needs shared state in a fast store with atomic check-and-decrement to avoid races between servers.

Fan-out on write or fan-out on read for a news feed?

Both. Fan-out on write makes reads fast at the cost of expensive writes, which fails for accounts with millions of followers. Those use fan-out on read. The hybrid is the standard answer.

Build this: Design a rate limiter for a model API serving many tenants, out loud against a timer. Cover the algorithm, where the state lives, and what happens when that store is down.

This is one section of Distributed systems, 17 hours across 4 sections. Read it in full to get the whole topic in order.

Stop 38 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeWorking with coding agentsIncreasingly its own interview round, with its own rubric.7h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.