Architecture · Distributed Systems · Field Guide

Distributed Systems Pro Playbook

The patterns a tech lead reaches for when one machine isn't enough — consistency, consensus, replication, sharding, caching, and resilience. Explained for students and pros.

Companion to the Event-Driven Architecture playbook — this half is the machinery; that half is the messaging
What it is Why it matters How it works In plain words ASCII diagram
Part I · Distributed Systems Foundations
01What a distributed system is — and why it's hard★ start here

Scenario: your app outgrows one server, so you split it across three. Suddenly a "simple" call can half-succeed, arrive twice, or vanish — problems that never existed on one box. Welcome to distributed systems.

What

A system whose parts run on separate machines connected by a network, coordinating to look like one system to the user.

Why

You go distributed for scale, availability, and locality — but you inherit partial failure, network delay, and no shared clock or memory.

How

Nodes pass messages over an unreliable network. Every guarantee (ordering, consistency, delivery) you must design in — nothing is free.

One box vs many boxes
  MONOLITH (easy)              DISTRIBUTED (hard)
  ┌───────────────┐           ┌────┐  net  ┌────┐  net  ┌────┐
  │ one process   │           │ A  │──╳───►│ B  │──────►│ C  │
  │ shared memory │           └────┘       └────┘       └────┘
  │ one clock     │            ▲ message may be lost, delayed,
  │ fails all/none│              duplicated, reordered — and a node
  └───────────────┘              can die mid-request (partial failure)
🧠
Analogy 1 — one chef vs a kitchen brigade: one chef remembers everything and either finishes the dish or doesn't. A brigade must shout orders across a loud kitchen — messages get missed, two cooks salt the same pot, one walks out mid-service. Coordination becomes the hard part.
📬
Analogy 2 — talking by postcard: in one room you just speak. Across cities you send postcards — some arrive late, some never, some out of order, and you can't tell "lost" from "slow." Every distributed call is a postcard, not a conversation.
The call that can't fail on one box
# monolith: either both happen or neither (one transaction)
db.debit(a, 100); db.credit(b, 100)

# distributed: TWO network calls — any gap can fail independently
account_svc.debit(a, 100)      # ✓ succeeded
# ...network drops here...
payment_svc.credit(b, 100)     # ✗ never ran → money vanished
# you now NEED idempotency (topic 8), retries (topic 18), and — for
# multi-step flows — sagas & the outbox (see the Event-Driven playbook)
🧒
In plain wordsWhen your program lives on one computer, things are simple — it works or it doesn't. Split it across many computers talking over a network, and now messages can get lost, show up twice, or arrive in the wrong order, and one computer can crash while the others keep going. Distributed systems is the craft of making many computers act like one despite all that.

✅ Do

  • Stay on one machine as long as it works — vertical scaling is underrated
  • Assume the network will drop, delay, and duplicate every message
  • Design each cross-service call to tolerate partial failure (topics 8, 18)

❌ Don't

  • Split into services for fashion — you're buying a permanent tax in complexity
  • Assume a remote call behaves like a local function call — it doesn't
Gotcha: the deadliest failure isn't a node crashing — it's a node that's slow or unreachable but not dead. You can't distinguish "crashed," "slow," and "network partitioned" from the outside, so timeouts become guesses and a "successful" write may have actually happened after you gave up. This ambiguity is the root of nearly every hard distributed bug (topics 3, 6).
02The 8 fallacies of distributed computing

Scenario: a service works perfectly in dev, then falls over in production — timeouts, dropped calls, mystery latency. Almost always, someone assumed one of the eight things that are never true about networks.

What

Eight false assumptions engineers make about networks (Deutsch/Gosling): the network is reliable, latency is zero, bandwidth infinite, it's secure, topology stable, one admin, transport cost zero, network homogeneous.

Why

Every one of them is false in production, and code that assumes them breaks exactly when it matters most — under load, during a partition, at 2am.

How

Design against each: add timeouts, retries, backpressure, encryption, and capacity limits because the opposite is true.

The eight lies your code believes
  1  the network is reliable      → it drops packets
  2  latency is zero              → every hop costs ms
  3  bandwidth is infinite        → big payloads clog it
  4  the network is secure        → assume hostile
  5  topology never changes       → nodes come & go
  6  there is one administrator    → many owners, many rules
  7  transport cost is zero        → serialization + $$ add up
  8  the network is homogeneous    → mixed hardware, versions, MTUs
     believe any one of these → an outage with your name on it
🧠
Analogy 1 — assuming every road is empty and free: plan a delivery route as if there's no traffic, no tolls, no closures, and every truck is identical — it works on a quiet Sunday and collapses on Monday. The fallacies are those rosy assumptions; production is Monday rush hour.
☎️
Analogy 2 — assuming every phone call connects instantly and clearly: real calls drop, lag, and get crossed wires. Code that assumes the "call" is instant and flawless is like planning a business on the belief that no call ever fails.
Naïve vs fallacy-aware
# FALLACY-BELIEVING: assumes reliable, zero-latency, infinite bandwidth
data = remote.fetch_everything()          # blocks forever if network hiccups

# FALLACY-AWARE
data = remote.fetch(page=1, limit=100,     # bounded payload (fallacy 3)
        timeout=2.0, retries=3, backoff=True)  # unreliable + latency (1,2)
# + TLS (4), + health checks for changing topology (5), + budget calls (7)
🧒
In plain wordsPeople keep pretending the network is fast, free, safe, and never breaks. It's none of those. These eight "fallacies" are the comforting lies that make code look fine in testing and blow up in the real world. Good engineers write code that expects the network to be slow, flaky, and hostile.

✅ Do

  • Put a timeout and a retry budget on every network call
  • Bound payload sizes and paginate — bandwidth is finite
  • Encrypt in transit and treat the network as hostile

❌ Don't

  • Write code that blocks forever waiting on a "quick" remote call
  • Assume the service you called is the same version/hardware as yours
Gotcha: fallacy #2 (latency is zero) silently wrecks systems that were "fine" — a loop that makes one remote call per item is invisible at 10 items and a 30-second stall at 10,000. The fix isn't a faster network; it's batching and fewer round-trips. Chatty designs die by a thousand hops (topics 5, 15).
03The CAP theorem — pick 2 under a partition★ core idea

Scenario: the network splits your cluster in two. A write lands on one side. Do you serve possibly-stale reads on the other side (stay available), or refuse until healed (stay consistent)? CAP says you must choose.

What

Under a network Partition, a system can't have both Consistency (every read sees the latest write) and Availability (every request gets a non-error response). Pick one.

Why

Partitions are inevitable, so CAP is really a forced choice between CP (refuse to serve stale) and AP (serve, maybe stale) — a business decision, not just technical.

How

Choose per use case: a bank ledger leans CP; a shopping cart or feed leans AP. Many systems tune it per operation.

Partition forces the choice
          ┌── network partition ──┐
   client │   NODE A  ✗✗✗  NODE B │ client
     write→│  x=2            x=1   │←read
          └───────────────────────┘
   CP: NODE B refuses the read  → consistent, NOT available
   AP: NODE B returns x=1 (stale) → available, NOT consistent
       (when there is NO partition, you get both C and A)
🧠
Analogy 1 — two shop tills that lose their link: a customer buys the last item at till A. Till B can't hear A. Either till B stops selling that item (consistent, but a closed register = unavailable) or keeps selling it (available, but may oversell = inconsistent). You can't have both while the link is down.
📝
Analogy 2 — a shared shopping list on two phones with no signal: both partners add items offline. You either block edits until they reconnect (consistent, annoying) or let both edit and merge later (available, but temporarily disagreeing). Partition = no signal; you pick the lesser pain.
The choice, stated plainly
# CP store (e.g. etcd, ZooKeeper, a strongly-consistent RDBMS quorum)
#   during a partition, the minority side returns ERRORS, never stale data
value = cp_store.get("x")      # may raise Unavailable — but never lies

# AP store (e.g. Cassandra, Dynamo-style with low consistency)
value = ap_store.get("x")      # ALWAYS answers — but might be stale
# choose by cost of being WRONG vs cost of being DOWN
🧒
In plain wordsImagine two copies of your data on different computers, and the wire between them breaks. Now you face a choice: always answer but maybe with old data, or only answer when you're sure it's correct, else say "try later." You can't do both while the wire is broken. Banks pick "be correct." Social feeds pick "always answer." That's CAP.

✅ Do

  • Decide CP vs AP by the business cost of stale data vs downtime
  • Remember when there's no partition you get both C and A — CAP only bites during faults

❌ Don't

  • Say "we chose CA" — you don't get to opt out of partitions
  • Treat CAP as all-or-nothing; real systems tune consistency per operation (topic 4)
Gotcha: CAP's "C" is linearizability, not the "C" in ACID, and its trade-off only applies during a partition — which makes "we're CA" a category error, since you can't wish partitions away. The more useful everyday model is PACELC: during a Partition choose A-or-C, Else (normal operation) choose Latency-or-Consistency. That "else" is where most of your latency budget actually goes (topics 4, 5).
04Consistency models — strong, eventual, causal★ core idea

Scenario: a user updates their profile, refreshes, and sees the old value. Bug? Not necessarily — it depends on which consistency model your store promises. "Consistency" isn't one thing; it's a spectrum of guarantees.

What

The contract for what a read is allowed to return relative to prior writes — from strong (always latest) through causal (respects cause→effect) to eventual (converges someday).

Why

Stronger consistency = easier reasoning but higher latency and lower availability. Weaker = fast and available but surprising reads. It's a direct cost dial.

How

Pick the weakest model your feature can tolerate. Reads-your-writes and causal often feel "correct" to users at far less cost than strong.

The consistency spectrum
  STRONG ────────── CAUSAL ────────── EVENTUAL
  every read sees   cause precedes    all replicas agree
  the latest write  effect (your      EVENTUALLY; a read
  (linearizable)    reply after a     may be stale for now
                    post shows post)
  ◄── more correct, slower, less available ──────────────►
  ◄──────────────── faster, more available, more surprising ──►
🧠
Analogy 1 — a group chat's message order: strong = everyone sees every message in the exact same instant order. Causal = you never see a reply before the message it answers (cause before effect), even if unrelated messages shuffle. Eventual = everyone ends up with the same log, but briefly people see different orders.
📰
Analogy 2 — newspaper vs live TV: strong consistency is live TV — everyone sees the same thing at once. Eventual is the morning paper — accurate, but everyone reads it at different times and briefly "knows" different things. Causal is making sure the paper never prints the reaction before the event.
Reads-your-writes: cheap and user-pleasing
# eventual store, but route a user's OWN reads to the node they wrote to
write("profile:42", new_bio, node=primary)
# without care: user reads a replica → sees OLD bio → "bug!"
bio = read("profile:42", prefer=session.write_node)   # read-your-writes
# they see their own change immediately; everyone else converges shortly
🧒
In plain wordsWhen you save something, how soon must everyone see the new value? Strong = instantly, everywhere (safe but slow). Eventual = soon-ish, so a quick refresh might show the old value (fast but surprising). Causal = the middle: things that depend on each other always show in the right order. You pick how patient you can afford to be.

✅ Do

  • Use the weakest model the feature tolerates — it's cheaper and more available
  • Give users read-your-writes so their own actions feel instant
  • Reach for strong consistency on money, inventory, and uniqueness

❌ Don't

  • Assume "eventual" means "a few ms" — under load it can be seconds
  • Demand strong consistency everywhere — you'll pay in latency and outages
Gotcha: "eventual consistency" quietly pushes conflict resolution onto you — when two replicas took different writes, someone must decide the winner. Last-write-wins silently drops data; you often need version vectors or CRDTs to merge correctly (topic 14). The consistency model you skimped on reappears as a data-loss bug months later (topics 7, 10).
05Latency, throughput & the tyranny of tail latency

Scenario: your service averages 20ms, but users complain it's "slow." You check p99: 800ms. The average lied — a small fraction of slow requests is quietly ruining the experience, especially when one request fans out to many services.

What

Latency = time per request; throughput = requests per second. Tail latency (p95/p99/p999) = how slow your worst requests are.

Why

Users feel the tail, not the average. And when one request calls 10 services, the odds it hits someone's slow tail multiply — tail latency amplifies with fan-out.

How

Measure percentiles, not means. Cut the tail with timeouts, hedged requests, caching, and by reducing fan-out and payload size.

Why the average lies — and fan-out amplifies
  latency histogram:   most fast │▇▇▇▇▇▇▇▂     ← p50 = 20ms
                        slow tail │        ▁▁▂  ← p99 = 800ms

  fan-out amplification: 1 user request → 10 backend calls
  P(all fast) = 0.99^10 ≈ 0.90  → ~1 in 10 user requests hits a
  slow tail SOMEWHERE. At 100 calls: 0.99^100 ≈ 0.37 → most are slow!
🧠
Analogy 1 — a group only moves as fast as its slowest member: a tour of 10 people leaves when the last person is ready. Even if 9 are quick, one straggler sets the pace. A fan-out request waits for its slowest backend — that's the tail.
🎢
Analogy 2 — the longest line at the theme park: the park's "average wait" is meaningless if the one ride you want has a 90-minute line. Users remember the worst wait, not the mean. p99 is the line that ruins the day.
Hedged request — cut the tail
import asyncio
async def hedged(call, arg, hedge_after=0.05):
    first = asyncio.create_task(call(arg))
    done, _ = await asyncio.wait({first}, timeout=hedge_after)
    if done:
        return first.result()
    second = asyncio.create_task(call(arg))     # backup after 50ms
    done, pending = await asyncio.wait({first, second},
                        return_when=asyncio.FIRST_COMPLETED)
    for p in pending: p.cancel()
    return done.pop().result()      # whichever replica answered first wins
🧒
In plain wordsAverage speed is a trap. If most requests are fast but 1 in 100 is really slow, users notice the slow ones. And when one click secretly triggers ten behind-the-scenes calls, the chance that at least one is slow gets big fast. So you measure the "worst 1%" (p99), not the average, and work to make the slow ones rarer.

✅ Do

  • Track p95/p99/p999, not averages — the tail is the user experience
  • Reduce fan-out and payloads; cache hot reads (topic 15)
  • Use timeouts + hedged/backup requests to clip slow outliers

❌ Don't

  • Optimize the mean while the tail balloons — that's optimizing the wrong number
  • Fan out to dozens of services synchronously without a latency budget
Gotcha: tail latency and throughput fight each other — pushing utilization toward 100% to maximize throughput makes queues form, and queueing sends the tail vertical (this is why a system at 95% CPU feels fine and at 99% feels dead). You must leave headroom; a server run flat-out is a server with a monstrous p99 (topics 17, 16).
06Partial failure — the defining problem★ core idea

Scenario: you call a service, it times out. Did the write happen or not? You genuinely cannot know. On one machine, code either runs or doesn't. Across a network, it can half-run — and that ambiguity is the whole game.

What

Partial failure: some parts of an operation succeed while others fail or hang, and the caller often can't tell which — a timeout means "unknown," not "failed."

Why

It breaks the local-code intuition that operations are atomic. Every remote call has three outcomes: success, failure, and "I don't know."

How

Make operations idempotent (topic 8) so retrying an "unknown" is safe, and use sagas/outbox (see the Event-Driven playbook) to reconcile half-done work.

The third outcome: "I don't know"
  request ──────────────► SERVER
                            │ processes... writes DB ✓
          ✗ reply lost ◄────┘
  CLIENT sees: TIMEOUT
     did it work?  ┌─ YES, reply was just lost      → retry = duplicate!
                   ├─ NO, server died before write  → retry = correct
                   └─ MAYBE, still processing        → retry = race
     you cannot tell these apart → design for ALL of them
🧠
Analogy 1 — texting "you still on for dinner?" with no reply: did they not get it, get it and ignore it, or reply and that got lost? Silence is ambiguous. You either re-text (risk annoying/double-booking) or wait (risk no dinner). Every timed-out RPC is that unanswered text.
📦
Analogy 2 — a package marked "delivery attempted": was it actually delivered, left next door, or never came? "Attempted" is the timeout of the shipping world — you must design your day around not knowing, e.g. a signature (idempotency key) that proves whether it truly arrived.
Treat timeout as "unknown," retry safely
key = idem_key(order_id)          # stable id for THIS logical operation
try:
    resp = payment.charge(amount, idempotency_key=key, timeout=3)
except Timeout:
    # UNKNOWN — maybe it charged, maybe not. Safe to retry BECAUSE
    # the server dedupes on `key`: a duplicate charge is ignored.
    resp = payment.charge(amount, idempotency_key=key, timeout=3)
# without the key, this retry could double-charge the customer (topic 8)
🧒
In plain wordsOn one computer, an action either happens or it doesn't. Across computers, there's a scary third option: you asked, and you have no idea if it worked — the answer got lost on the way back. If you just try again, you might do it twice (double charge!). So you tag each action with an ID, and the other side ignores repeats. Now "try again" is always safe.

✅ Do

  • Treat every timeout as "unknown outcome," never as "definitely failed"
  • Make retriable operations idempotent so a re-send can't double-apply (topic 8)
  • Reconcile half-finished multi-step work with sagas / the outbox (Event-Driven playbook)

❌ Don't

  • Blindly retry a non-idempotent write after a timeout — hello, duplicate
  • Assume "no error returned" means "it definitely happened"
Gotcha: the most expensive partial failures are the ones that look like success — the remote write committed, but the acknowledgment was lost, so your code "fails," retries, and silently does the thing twice. Duplicate charges, double-shipped orders, and doubled inventory decrements almost always trace back to a retried non-idempotent operation after an ambiguous timeout (topic 8).
07Time, clocks & ordering events

Scenario: two servers write the same record "at the same time." You use their wall clocks to decide the winner — and pick the wrong one, because one server's clock was 40ms ahead. In distributed systems, you cannot trust wall-clock time to order events.

What

Physical clocks drift and disagree across machines, so you can't use timestamps to reliably order events. Logical clocks (Lamport, vector clocks) order by causality instead.

Why

"Which write happened last?" decides conflict winners, log order, and correctness. Wall-clock skew makes that answer wrong in subtle, data-losing ways.

How

Use a monotonic counter that increments on each event and travels with messages (Lamport), or vector clocks to detect true concurrency (topic 14).

Lamport clock: order by causality, not wall time
  Node A:  a1 ──msg(ts=1)──►         a3(ts=3)
            │                          ▲
  Node B:   └──────► b2(ts=max(0,1)+1=2)┘ sends msg(ts=2)
  rule: on send, ts++; on receive, ts = max(local, msg)+1
  → guarantees: if X caused Y, then ts(X) < ts(Y)
    (equal/again does NOT imply order — use vector clocks to be sure)
🧠
Analogy 1 — dating letters by postmark vs by content: two people's watches disagree, so postmarks lie about order. But if letter B says "in reply to your letter A," you know A came first regardless of postmarks. Logical clocks track that "in reply to" chain — causality, not wall time.
🎬
Analogy 2 — a movie clapperboard: instead of trusting each camera's clock, you snap a clapper so every camera shares one reference event. Logical clocks are that shared, incrementing reference — everyone agrees on "after the clap," even if their watches differ.
A Lamport clock in a few lines
class LamportClock:
    def __init__(self): self.t = 0
    def tick(self):                 # local event or send
        self.t += 1; return self.t
    def on_receive(self, msg_t):    # incoming message carries its ts
        self.t = max(self.t, msg_t) + 1; return self.t
# now: if event X causally precedes Y, clock(X) < clock(Y) — always.
# wall clocks can't promise that across machines.
🧒
In plain wordsEvery computer's clock is a little bit wrong, and they disagree. So you can't use "what time is it" to decide which thing happened first — you'll pick wrong and lose data. Instead you use a shared counter that ticks up with each event and rides along on messages. That reliably tells you what caused what, even when the clocks lie.

✅ Do

  • Order events by causality (logical/vector clocks), not raw timestamps
  • Use monotonic clocks for measuring durations (they never jump backward)
  • Use NTP/true-time for display, not for correctness-critical ordering

❌ Don't

  • Resolve write conflicts with wall-clock "last write wins" — skew loses data
  • Assume two machines' clocks agree to the millisecond — they don't
Gotcha: "last write wins" using wall-clock timestamps is the single most common silent data-loss bug in distributed stores — a laggy client's clock (or clock skew) can make an older write overwrite a newer one, and you'll never see an error. If you must use LWW, use it knowingly; otherwise reach for version vectors or an authoritative sequencer (topics 4, 14).
08Idempotency — the retry superpower★ core idea

Scenario: a payment request times out (topic 6). You retry. If the first one actually went through, you just charged the customer twice — unless your endpoint is idempotent: doing it twice has the same effect as doing it once.

What

An operation is idempotent if applying it multiple times yields the same result as applying it once — so a duplicate is a harmless no-op.

Why

Because networks force retries and at-least-once delivery (topic 6), idempotency is what makes retrying safe. Without it, every retry risks duplication.

How

Attach an idempotency key per logical operation; the server records processed keys and returns the stored result on a repeat instead of re-doing it.

Idempotency key dedupes retries
  client ─ charge(key=ABC) ─►┌ server ┐
                              │ seen ABC? no → charge, store result ✓
          ◄── ok ────────────┘
  ...timeout, client retries...
  client ─ charge(key=ABC) ─►┌ server ┐
                              │ seen ABC? YES → return stored result
          ◄── ok (no 2nd charge)─┘   same effect as one call
🧠
Analogy 1 — an elevator call button: pressing "up" ten times doesn't summon ten elevators — the request is already registered. Idempotent operations are that button: extra presses change nothing. A non-idempotent one is like each press ordering a new elevator.
🎟️
Analogy 2 — a ticket with a unique number: hand the same numbered ticket to the cashier twice and they say "already redeemed" — you don't pay twice. The idempotency key is that ticket number; the server remembers which tickets it has honored.
Server-side dedupe
def charge(amount, idempotency_key):
    if row := db.get_result(idempotency_key):
        return row.result                 # already done → return same result
    result = payment_gateway.charge(amount)
    db.save_result(idempotency_key, result)   # record so retries are no-ops
    return result
# GET/PUT/DELETE are naturally idempotent; POST usually is NOT →
# that's exactly where you add a key.
🧒
In plain wordsBecause messages get lost, you often have to try again — but "try again" is scary for things like payments (do it twice = double charge). The fix: give each action a unique ticket number. The server remembers tickets it already handled, so if the same ticket shows up again, it just says "already done" instead of doing it again. Now retrying is totally safe.

✅ Do

  • Require an idempotency key on every non-idempotent write (payments, orders)
  • Store the result keyed by the id and return it on repeats
  • Prefer naturally-idempotent designs: "set balance to X" over "add X"

❌ Don't

  • Generate the key server-side per request — the client must reuse it across retries
  • Expire dedupe records faster than your max retry window
Gotcha: idempotency is only real if the key is stable across retries and the check-and-store is atomic. Generate a fresh key on each attempt and you've built nothing. Check "have I seen this key?" and write in two non-atomic steps and two concurrent retries both pass the check — you need a unique constraint or an atomic upsert to close that race (topic 6).
09Consensus — getting nodes to agree

Scenario: five replicas must agree on "who is the leader" or "what's the next log entry" — even if some crash or messages are lost. This is consensus, and it's the hard core that powers leader election, distributed locks, and replicated logs.

What

A protocol (Paxos, Raft) by which a group of nodes agrees on a single value/order despite failures, as long as a majority (quorum) is alive.

Why

It's the foundation for a consistent replicated log, leader election, and locks — anything where the cluster must speak with one voice.

How

Raft elects a leader that orders writes; an entry commits once a majority acknowledges it. A minority partition cannot make progress (that's CP).

Raft: majority commits, minority can't
  5 nodes, quorum = 3
  ┌─ leader ─┐  append(x) ──►  followers
  │   L      │ ──────────────► F F F F
  └──────────┘   acks: L+2 = 3 (majority) → x COMMITTED ✓

  partition: {L, F} vs {F, F, F}
   old leader (2 nodes) can't reach quorum → STALLS (no split-brain)
   the 3-node side elects a new leader → keeps serving
🧠
Analogy 1 — a committee that needs a majority vote: nothing passes without more than half agreeing. If the room splits and neither half has a majority, no decisions are made — frustrating, but you never get two conflicting decisions. Quorum is that majority rule.
👥
Analogy 2 — a group picking a restaurant by majority text: the choice is final once 3 of 5 friends reply "yes." If two friends lose signal, the other three still decide. But two friends alone can't overrule the group — they lack a majority. That's why a minority partition can't cause chaos.
Consensus you actually use (don't roll your own)
# leader election / locks / config via a Raft-backed store
$ etcdctl put /leader "node-A" --lease=$LEASE   # only one wins
$ etcdctl elect my-service node-A               # built-in election
# etcd, ZooKeeper, Consul implement consensus for you.
# Writing Paxos/Raft by hand is a multi-year footgun — use a library.
🧒
In plain wordsSometimes a bunch of computers must agree on one answer — like "who's in charge?" — even if some crash. The trick is majority rules: an answer counts only when more than half agree. If the group splits, only the bigger half can decide, so you never get two "leaders" giving conflicting orders. Getting this exactly right is famously hard, so you use a battle-tested tool (etcd, ZooKeeper) instead of building it.

✅ Do

  • Use a proven consensus system (etcd, ZooKeeper, Consul) for leadership/locks
  • Run an odd number of nodes (3 or 5) so a majority is well-defined
  • Understand consensus is CP — it stalls the minority rather than diverging

❌ Don't

  • Implement Paxos/Raft yourself for production — it's a classic disaster
  • Run an even number of voters — you invite split votes and dead time
Gotcha: consensus needs a majority, so it trades availability for safety — lose quorum (e.g. 2 of 3 nodes) and the whole cluster stops accepting writes, by design, to avoid split-brain. Teams are shocked when "we lost one node and everything still froze" — that's consensus working correctly. Size and place your voters so a single failure or one AZ outage never costs you quorum (topics 3, 10).
10Replication — copies for safety & scale

Scenario: your single database is a single point of failure and a read bottleneck. You add replicas. Now: how do writes propagate, what happens if the primary dies, and can readers see stale data? That's replication.

What

Keeping copies of data on multiple nodes. Common shapes: leader-follower (one writes, many read) and quorum (write/read to W/R of N nodes) — topic 13.

Why

Replication buys durability (survive node loss), read scale (spread reads), and locality (data near users) — the backbone of availability.

How

Leader accepts writes, ships a log to followers. Sync replication = safe but slow; async = fast but can lose recent writes on failover.

Leader-follower & the sync/async trade
          writes            reads
   client ───► ┌ LEADER ┐ ──log──► FOLLOWER 1 ──► reads
               └────────┘ ──log──► FOLLOWER 2 ──► reads
  SYNC:  leader waits for follower ack → no data loss, higher latency
  ASYNC: leader replies immediately    → fast, but if it dies before
         shipping the log, those writes are LOST on failover
  quorum: N=3, write W=2, read R=2 → W+R>N guarantees fresh reads (topic 13)
🧠
Analogy 1 — a manager and note-takers: the manager (leader) makes all decisions; assistants (followers) copy the notes so they can answer questions (reads). If the manager copies notes before acting (sync) nothing is lost but it's slow; if assistants copy up later (async) it's fast but a sudden manager exit loses the latest decisions.
📒
Analogy 2 — photocopying a ledger: the master ledger is copied to branch offices. Branches answer customers from their copy (read scale). Quorum reading is like checking 2 of 3 copies and trusting the newest — as long as writes also hit 2 of 3, you always overlap the fresh one.
Route writes to leader, reads to replicas
# read scaling: send reads to followers, writes to the leader
def query(sql, write=False):
    pool = LEADER if write else pick(FOLLOWERS)   # spread reads
    return pool.execute(sql)

# beware replication lag: a user who just wrote may read a stale follower
def read_after_write(user):
    return LEADER.execute(...) if user.wrote_recently else pick(FOLLOWERS)
# quorum stores (Cassandra): tune consistency per query: W+R > N = fresh
🧒
In plain wordsKeeping only one copy of your data is risky (if it dies, you're down) and slow (everyone reads the same place). So you keep copies on several computers: one boss handles changes and the others keep copies to answer questions. If the boss copies changes out before confirming, nothing's ever lost but it's slower; if it confirms first and copies later, it's faster but a sudden crash can lose the newest changes. Pick based on how much a lost second of data hurts.

✅ Do

  • Use sync (or quorum) replication for data you cannot afford to lose
  • Send reads to replicas for scale, but handle replication lag for read-your-writes (topic 4)
  • Automate failover and test it — an untested failover is a future outage

❌ Don't

  • Assume async replicas are up to date — they lag, sometimes by seconds
  • Fail over to a stale async replica for money data without accepting the loss
Gotcha: async replication + automatic failover can quietly lose committed writes — the leader acked writes it hadn't yet shipped, then died, and the new leader never saw them. Worse, if the old leader comes back thinking it's still primary you get split-brain and divergent data. Fencing tokens and a consensus-backed leader lease (topic 9) are what stop a zombie leader from corrupting everything.
Part II · Data at Scale
11Partitioning & sharding — scaling writes★ core idea

Scenario: your data no longer fits (or writes no longer fit) on one database. You split it across many — but a bad split creates hot shards, and re-splitting later is agony. Sharding is how you scale writes, and choosing the key is everything.

What

Sharding/partitioning splits data across nodes by a shard key — by hash (even spread) or by range (ordered, range queries) — so each node owns a slice.

Why

Replication (topic 10) scales reads; sharding scales writes and storage past one machine. It's how you grow beyond a single DB's ceiling.

How

Hash the key → node (even but no range scans), or range-partition (range scans but risk hot ranges). Consistent hashing limits reshuffling when nodes change.

Hash vs range, and why the key matters
  HASH(key) % N → node   (even spread, no range queries)
    user 7→n1  user 8→n2  user 9→n0 ...

  RANGE(key)             (range scans work, but hot ranges)
    a–h→n0   i–p→n1   q–z→n2   ← if 80% of keys start with "s", n2 melts

  CONSISTENT HASHING (ring): add/remove a node moves only ~1/N of keys,
    not everything → avoids a full reshuffle on scaling
🧠
Analogy 1 — filing cabinets by last name: split records A–H, I–P, Q–Z across three cabinets. If half your customers are named "Smith," the Q–Z cabinet overflows while A–H sits empty — a hot shard. Choosing the split so drawers fill evenly is the whole art.
🍕
Analogy 2 — cutting a pizza for a crowd: you slice so everyone gets a fair share (even shard key). Cut it so one slice has all the toppings and the rest are plain, and everyone fights over one slice (hot shard). And re-cutting a pizza mid-party (resharding) is messy — plan the cuts up front.
Route by shard key
def shard_for(user_id, nodes):
    return nodes[hash(user_id) % len(nodes)]    # even spread

db = shard_for(user_id, NODES)
db.query("select * from orders where user_id = %s", user_id)  # local to shard
# ⚠ cross-shard queries (e.g. "all orders today") must scatter/gather
# ⚠ choose a key that spreads load AND keeps related data together
🧒
In plain wordsWhen your data is too big or too busy for one database, you split it across many — each one holds a chunk. The trick is how you split: if you divide by a rule that dumps most of the traffic on one chunk, that one melts while the others nap (a "hot shard"). And re-splitting later is a huge pain, so you pick the dividing key carefully up front — one that spreads the load evenly.

✅ Do

  • Choose a shard key that spreads load evenly and co-locates related data
  • Use consistent hashing so scaling moves ~1/N of keys, not all of them
  • Delay sharding until you must — it's a big one-way-ish complexity jump

❌ Don't

  • Shard on a skewed key (e.g. country, status) — you'll create hot shards
  • Design flows needing frequent cross-shard joins/transactions — they're painful (topic 12)
Gotcha: the shard key is a near-permanent decision — changing it means physically moving most of your data while live, so a poor early choice (a low-cardinality or time-based key that hot-spots) haunts you for the system's life. And any query not keyed by the shard key becomes an expensive scatter-gather across every shard. Model your access patterns first, then pick the key that serves the majority (topics 10, 13).
12Distributed transactions — 2PC & why sagas win★ core idea

Scenario: an order must debit inventory in one database and charge payment in another, atomically. There's no COMMIT that spans both. The textbook answer is two-phase commit (2PC) — which is slow, blocking, and fragile, so most systems reach for sagas instead.

What

2PC is a protocol where a coordinator asks all participants to "prepare," then "commit" — atomic across nodes. A saga instead runs local transactions with compensating undos (see the Event-Driven playbook).

Why

True cross-node atomicity (2PC) blocks participants holding locks and stalls entirely if the coordinator dies. Sagas trade strict atomicity for availability and scale.

How

2PC: prepare → all vote yes → commit (else abort). Blocking. Sagas: do each step, and on failure run compensating actions backward — eventual atomicity, no global lock.

Two-phase commit — and its fatal window
  COORDINATOR                    PARTICIPANTS (A, B)
   phase 1: PREPARE  ──────────►  lock rows, vote YES/NO
            ◄──────────────────  YES, YES
   phase 2: COMMIT   ──────────►  commit & release locks
   ⚠ if coordinator DIES after "prepare": participants sit LOCKED,
     blocked, waiting — a stuck 2PC can freeze a whole system
   → sagas avoid this: local commits + compensations, no held locks
🧠
Analogy 1 — a group of friends all signing a lease at once: 2PC is everyone holding a pen, promising to sign, then all signing together — if the organizer vanishes mid-ceremony, everyone's stuck holding a pen, unable to move. A saga is each person signing their own copy and, if someone backs out, tearing theirs up (compensating) — no one is frozen.
🔒
Analogy 2 — reserving a shared meeting room across offices: 2PC locks every office's calendar while it confirms, and a network blip leaves them all locked. Sagas book each office optimistically and cancel the ones that don't line up. Slower to be perfectly consistent, but nothing seizes up.
Prefer local commits + compensation
# 2PC (avoid across microservices): coordinator holds everyone hostage
#   prepare(A); prepare(B); if both yes: commit(A); commit(B)
#   coordinator crash between phases → A & B blocked, locked, waiting ✗

# SAGA (preferred): local commits, compensate on failure
def place_order(o):
    charge(o);            undo = [refund]        # each step + its undo
    reserve(o);           undo += [release]
    try: ship(o)
    except Exception:
        for c in reversed(undo): c(o)            # compensate backward
# eventual atomicity, no global lock — details in the Event-Driven playbook
🧒
In plain wordsWhen one action needs to change data in two separate databases together, there's no magic "do both or neither" button. The old textbook trick (two-phase commit) makes everyone lock up and wait for a coordinator — and if that coordinator crashes, everything freezes. So modern systems usually do it a safer way: do each step on its own, and if a later step fails, undo the earlier ones (a "saga"). It's not instant-perfect, but nothing gets stuck.

✅ Do

  • Prefer sagas (local commits + compensation) across services — see the Event-Driven playbook
  • Reserve 2PC for tightly-coupled, low-latency, same-datacenter cases if at all
  • Design each step to be idempotent so retries and compensations are safe (topic 8)

❌ Don't

  • Reach for 2PC across microservices — it blocks and doesn't scale
  • Assume a distributed transaction is as safe as a single-DB one — the coordinator is a SPOF
Gotcha: two-phase commit has a notorious blocking window — if the coordinator crashes after participants vote "yes" but before it says "commit," those participants are stuck holding locks with no way to safely decide, and they can freeze indefinitely. This is why 2PC is avoided across services and why sagas (eventual atomicity, no held locks) dominate microservice design. But sagas give up isolation — the world briefly sees half-done state — so neither is free (see the Event-Driven playbook, topics 8, 3).
13Quorums & tunable consistency (W + R > N)

Scenario: in a replicated store with 3 copies, how many must you write to, and read from, to be sure a read sees the latest write? The answer is a simple, powerful inequality — and it lets you dial consistency vs latency per query.

What

In quorum replication with N replicas, a write goes to W of them and a read to R of them. If W + R > N, read and write sets overlap, guaranteeing the read sees the latest write.

Why

It turns consistency into a tunable dial: raise W/R for stronger consistency (slower), lower them for speed (riskier). One store, many consistency levels.

How

Pick per operation. Strong: W=N or R=N. Balanced: W=R=quorum (⌈(N+1)/2⌉). Fast/AP: W=1 or R=1 (may read stale). Dynamo-style stores expose exactly this.

Overlap guarantees a fresh read
  N = 3 replicas.   W + R > N  → write set & read set MUST overlap
    W=2, R=2 (2+2=4 > 3):  ✓ the read touches ≥1 node with the new write
       write → [A✔][B✔][C ]      read ← [B✔][C ]  → sees B's fresh value

    W=1, R=1 (1+1=2 ≤ 3):  ✗ may miss it → fast but eventually consistent
  dial it per query: strong (W=3/R=3) ⟷ fast (W=1/R=1)
🧠
Analogy 1 — telling news to enough friends: if you tell 2 of 3 friends a secret, and later ask any 2 of 3, at least one you ask must be one you told — so you'll hear the truth. Tell only 1 and ask only 1, and you might miss each other entirely. The overlap is what guarantees freshness.
🗳️
Analogy 2 — cross-checking witnesses: to be sure of a fact, you record it with enough witnesses and later consult enough that your groups can't help but share one. Overlap = certainty. Skimp on witnesses to save time, and you might consult only people who never heard the update.
Tune consistency per query
# quorum store (Cassandra-style): choose W and R per operation
write("balance:42", 100, consistency="QUORUM")   # W = ⌈(N+1)/2⌉
read("balance:42",       consistency="QUORUM")    # R = quorum → W+R>N ✓

# money: strong → read/write with ALL or QUORUM
# feed/likes: fast → W=1 / R=1 (ONE) → accept eventual consistency (topic 4)
# rule: W + R > N  ⇒  reads observe the latest acknowledged write
🧒
In plain wordsSay your data is copied on 3 machines. When you save, you write to some of them; when you read, you check some of them. The magic rule: if the number you write to plus the number you read from is more than 3, then your read is guaranteed to touch at least one machine that has the newest value — so you can't miss it. Turn those numbers up for safety (slower) or down for speed (might see old data). Same database, your choice per request.

✅ Do

  • Use W + R > N when a read must see the latest write
  • Tune W/R per operation — strong for money, fast for feeds
  • Remember higher W/R = more nodes to wait on = higher latency

❌ Don't

  • Use W=1/R=1 for data that must be fresh — reads can miss recent writes
  • Forget that during a partition even quorums can't reach enough nodes (topic 3)
Gotcha: W + R > N guarantees you read a copy that has the latest write, but not that all copies agree yet — and it says nothing about concurrent writes, which can still conflict and need resolution (topic 14). Quorum reads can also return multiple versions that the client (or store) must reconcile. It's a powerful dial, but "quorum" doesn't magically equal "linearizable" — read the fine print of your store, because the guarantees vary (topics 4, 14).
14Conflict resolution — LWW, vector clocks & CRDTs

Scenario: two users edit the same shopping cart on two replicas during a partition. Both writes succeed. When the replicas reconnect, they disagree — whose write wins, or can you merge them? Conflict resolution is the price of high availability (topic 3).

What

Deciding the outcome when concurrent writes to the same data diverge: last-write-wins (LWW) (pick by timestamp), vector clocks (detect concurrency, let the app merge), or CRDTs (data types that merge automatically).

Why

Available (AP) systems accept writes during partitions, so divergence will happen. How you resolve it decides whether you lose data or merge it correctly.

How

LWW is simple but drops data. Vector clocks flag true conflicts for app-level merge. CRDTs (counters, sets, maps) are designed so any merge order gives the same, correct result.

Three ways to resolve divergence
  concurrent writes to cart: A adds milk, B adds eggs (during partition)

  LWW:   keep the one with the newer timestamp → the other is LOST ✗
         (and clock skew can pick the wrong one — topic 7)
  VECTOR CLOCKS: detect "these were concurrent" → hand both to the app
         to merge (e.g. union) → nothing lost, but app must decide
  CRDT:  a merge-able set → {milk} ∪ {eggs} = {milk, eggs} automatically ✓
🧠
Analogy 1 — two people editing a shared doc offline: LWW is "whoever saved last wins, the other's edits vanish." Vector clocks are "we noticed you both edited — here's both, please merge." A CRDT is Google-Docs-style automatic merging where both edits survive without anyone choosing. Same problem, escalating cleverness.
🛒
Analogy 2 — two clerks restocking the same shelf: LWW throws away one clerk's work. Vector clocks flag "both touched this shelf" so a manager combines it. A CRDT shelf is designed so their additions naturally stack — no manager needed. You pick based on whether losing an edit is acceptable.
Merge instead of dropping data
# LWW — simple, lossy (skew can pick wrong; topic 7)
winner = a if a.ts > b.ts else b        # the loser's write is GONE

# CRDT — a grow-only set merges without losing anything
def merge(cart_a: set, cart_b: set) -> set:
    return cart_a | cart_b              # union: both items survive
# CRDTs exist for counters, sets, maps, sequences (text) — commutative,
# so ANY replica merging in ANY order converges to the SAME state
🧒
In plain wordsIf two people change the same thing at the same time on different copies, the copies end up disagreeing. Three ways to fix it: (1) keep whichever was "latest" — easy, but you throw away the other change. (2) Notice they clashed and let your app decide how to combine them. (3) Use special self-merging data (CRDTs) where both changes automatically survive — like how a shared doc merges two people's edits. Choose based on whether losing someone's change is okay.

✅ Do

  • Use CRDTs for collaborative/mergeable data (carts, counters, sets, docs)
  • Use vector clocks to detect concurrency and merge at the app level
  • Reach for LWW only when losing a concurrent write is genuinely acceptable

❌ Don't

  • Default to timestamp LWW for important data — it silently drops writes (topic 7)
  • Assume "the store handles it" — know your store's conflict policy exactly
Gotcha: the default conflict policy of many stores is last-write-wins by wall clock, which combines two hazards — it silently discards one of two concurrent writes, and it picks the "winner" using clocks that drift (topic 7), so it can even keep the older write. Data vanishes with no error and no log. If your data can be edited concurrently and matters, either use a CRDT (auto-merge) or version vectors (detect + merge), and never assume LWW is "good enough" until you've accepted exactly what it throws away (topics 4, 7).
Part III · Resilience & Operations
15Distributed caching — and its traps★ core idea

Scenario: your database is melting under repeated reads of the same data. You add Redis in front. Instant relief — until stale data, a thundering herd, or a cache stampede after a restart bites you. Caching is easy to add and hard to get right.

What

Storing hot data in fast memory (Redis) to avoid re-fetching from a slow source. Common pattern: cache-aside (read cache → miss → load DB → fill cache).

Why

Caches slash latency and offload the DB by orders of magnitude. But they add a second copy of truth that can go stale — invalidation is the hard part.

How

On read: try cache, on miss load and set with a TTL. On write: update DB and invalidate/update the cache. Guard against stampedes and hot keys.

Cache-aside read path
  read(key):
    v = redis.get(key)
    if v: return v                 # HIT — fast
    v = db.query(key)              # MISS — slow path
    redis.set(key, v, ttl=60)      # fill for next time
    return v
  write(key,val): db.update(key,val); redis.delete(key)  # invalidate
  ⚠ two dangers → stampede (many misses at once) & stale (TTL vs writes)
🧠
Analogy 1 — a notepad on your desk vs the archive room: you jot frequently-needed facts on a notepad (cache) instead of walking to the archive (DB) each time. Fast — until the archived record changes and your notepad is now wrong. Keeping the notepad in sync is the whole challenge.
🧊
Analogy 2 — a fridge vs the grocery store: the fridge (cache) saves trips to the store (DB). But milk expires (TTL), and if you toss everything at once, you make a huge store run all at the same time (stampede). Good caching is stocking wisely and not letting everything expire together.
Cache-aside + stampede guard
def get_user(id):
    key = f"user:{id}"
    if v := redis.get(key):
        return v                       # hit
    # miss: use a short lock so ONE caller rebuilds, others wait (no herd)
    with redis.lock(f"lock:{key}", timeout=2):
        if v := redis.get(key):        # double-check inside lock
            return v
        v = db.get_user(id)
        redis.set(key, v, ex=60 + random.randint(0, 10))  # jittered TTL
        return v
# jitter prevents thousands of keys expiring the same second (stampede)
🧒
In plain wordsA cache is a fast little memory that holds copies of things you look up a lot, so you don't keep asking the slow database. Huge speed-up! The catch: the real data can change and your copy goes out of date, and if lots of copies expire at once, everyone rushes the database at the same instant. So you set expiry times, spread them out a bit, and clear the cache when data changes.

✅ Do

  • Set TTLs with jitter so keys don't all expire at once (stampede)
  • Invalidate or update the cache on writes to bound staleness
  • Cache the hottest, most-read, least-changing data first

❌ Don't

  • Cache without a TTL and assume you'll always remember to invalidate
  • Ignore the "cold cache" problem — a restart can hammer the DB (topic 18)
Gotcha: "there are only two hard things: cache invalidation and naming" is a joke because it's true — the moment you cache, you own a second copy of truth that reality will drift from, and every stale-data bug traces back to a missed invalidation. Worse is the thundering herd: a popular key expires, a thousand requests miss simultaneously, and they all stampede the DB at once — the exact overload the cache was meant to prevent (topics 17, 18).
16Load balancing & service discovery

Scenario: you run five identical instances of a service. How does traffic spread across them, and how does a caller even find their addresses when instances come and go (autoscaling, crashes, deploys)? That's load balancing and service discovery.

What

Load balancing distributes requests across healthy instances. Service discovery is how callers find the current set of instances as they change.

Why

They give you horizontal scale and availability — add/remove instances freely, route around dead ones, and never hardcode an address.

How

A balancer (L4/L7) spreads traffic by round-robin/least-connections and does health checks. A registry (DNS, Consul, K8s Service) tracks live instances.

Discover the live set, balance across it
            ┌── registry (K8s / Consul / DNS) ──┐
            │  svc-A: [10.0.0.3, .4, .5]        │  ← updates as
            └───────────────┬───────────────────┘    pods come/go
                            ▼
   client ──► [ LOAD BALANCER ] ──► .3  (health ✓)
                  round-robin /   ──► .4  (health ✓)
                  least-conn      ──► .5  (health ✗ → skipped)
   dead instance fails health check → removed from rotation
🧠
Analogy 1 — a host seating diners across waiters: the host (load balancer) sends each new party to the waiter with the fewest tables, skipping any waiter on break (failed health check). The staff roster (service registry) updates as waiters clock in and out. Diners never need to know waiters' names.
🚕
Analogy 2 — a taxi dispatcher: you don't call a specific driver's cell; you call dispatch (discovery), which knows who's on shift and free, and assigns the nearest available cab (balancing). Drivers start and end shifts constantly; dispatch always has the current list.
Discovery + client-side balancing
# service discovery via DNS (Kubernetes gives every Service a name)
$ nslookup orders.default.svc.cluster.local   # → current healthy pod IPs

# the Service load-balances across ready pods automatically:
#   readinessProbe gates membership → unhealthy pods get no traffic
$ kubectl get endpoints orders           # the live set behind the name
# L7 balancers add: least-connections, sticky sessions, retries, TLS
🧒
In plain wordsYou run several copies of a service so you can handle more load and survive a copy dying. Two problems: how do you spread requests evenly across the copies, and how do callers find the copies when they keep changing? A load balancer hands each request to a healthy copy (and skips broken ones); a registry keeps the up-to-date list of who's alive. Callers just ask by name, never by address.

✅ Do

  • Use health/readiness checks so traffic only goes to instances that can serve
  • Address services by logical name (discovery), never hardcoded IPs
  • Pick a balancing policy that fits (least-connections beats round-robin for uneven work)

❌ Don't

  • Hardcode instance addresses — they change on every deploy/scale event
  • Route to instances that pass a shallow ping but can't actually serve (topic 19)
Gotcha: a health check that only proves "the process is up" (a trivial /ping) will happily send traffic to an instance whose DB pool is exhausted or whose cache is cold — it's alive but can't serve, so you get errors with a green dashboard. Readiness checks must exercise real dependencies, and you must separate "live" (don't kill me) from "ready" (send me traffic) or a restart storm ensues (topics 18, 19).
17Rate limiting & backpressure

Scenario: a traffic spike (or a buggy client, or a retry storm) sends 10× normal load. Without limits, your service accepts it all, runs out of memory, and dies — taking everything with it. Rate limiting and backpressure let it shed load and survive.

What

Rate limiting caps how many requests a client/system may make (e.g. token bucket). Backpressure is signaling "slow down" upstream when you're overloaded.

Why

Accepting more than you can handle doesn't serve more users — it collapses and serves zero. Limits keep you healthy and degrade gracefully.

How

Token bucket / leaky bucket for limiting; bounded queues, 429 Too Many Requests, and blocking producers when consumers lag for backpressure.

Token bucket & backpressure
  TOKEN BUCKET: refill R tokens/sec, capacity C
    request → has token? take it (allow) : reject (429)
    [●●●●●○○○]  bursts allowed up to C, sustained rate = R

  BACKPRESSURE (bounded queue):
    producer ──►[ □□□□ full! ]──► consumer(slow)
                   │ queue full → producer BLOCKS or sheds
    → the slowdown propagates UP instead of exploding memory
🧠
Analogy 1 — a nightclub with a bouncer: the club holds 200 safely. The bouncer (rate limiter) lets people in at a sustainable pace and turns away the overflow rather than cramming 2,000 in and causing a crush. Turning some away keeps everyone inside safe — accepting all would be a disaster for all.
🚰
Analogy 2 — a sink with a small drain: if water pours in faster than it drains, it overflows (crash). Backpressure is turning the tap down when the sink fills — the "slow down" signal travels back to the source. A bounded sink that pushes back beats an overflowing one.
Token-bucket limiter
import time
class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate, self.cap = rate, capacity
        self.tokens, self.ts = capacity, time.monotonic()
    def allow(self):
        now = time.monotonic()
        self.tokens = min(self.cap, self.tokens + (now - self.ts) * self.rate)
        self.ts = now
        if self.tokens >= 1:
            self.tokens -= 1; return True     # allow
        return False                          # reject → return 429
# backpressure: use a BOUNDED queue; when full, block or shed, never grow forever
🧒
In plain wordsIf too many requests arrive at once and you try to do them all, you run out of memory and crash — helping nobody. Instead, you set a limit ("only this many per second") and politely say "too many, try again soon" to the rest (rate limiting). And when you're falling behind, you send a "slow down!" signal back up the line (backpressure). Serving some and staying alive beats accepting everyone and dying.

✅ Do

  • Rate-limit per client/key and return 429 with a Retry-After
  • Use bounded queues everywhere — an unbounded queue is a memory bomb
  • Shed load early and predictably rather than collapsing under it

❌ Don't

  • Accept unlimited work "to be nice" — you'll serve zero when you crash
  • Let clients retry aggressively without backoff — that is the DDoS (topic 18)
Gotcha: naïve retries turn a small blip into a retry storm that DDoSes you from the inside — a service hiccups, every client retries at once, the extra load makes it hiccup more, and the feedback loop takes you fully down. Rate limiting plus exponential backoff with jitter on the client side (topic 18) is what breaks the loop; without it, your own clients become the attack (topics 5, 18).
18Retries, timeouts & circuit breakers★ core idea

Scenario: a downstream service gets slow. Your calls pile up waiting, threads exhaust, and your service goes down too — a cascading failure. Timeouts, smart retries, and a circuit breaker stop one sick service from taking out the whole system.

What

Three resilience primitives: timeouts (don't wait forever), retries with backoff+jitter (survive transient blips), circuit breakers (stop calling a failing service and fail fast).

Why

Together they contain failure. Without them, one slow dependency exhausts your resources and the failure cascades across services (topic 19).

How

Bound every call with a timeout; retry only idempotent ops with exponential backoff + jitter; trip a breaker after N failures to fail fast, then probe to recover.

Circuit breaker states
            failures >= threshold
   CLOSED ───────────────────────► OPEN ──(cooldown)──► HALF-OPEN
   (calls    fail fast, don't call    probe: 1 test call
    flow)    the sick service            │ success → CLOSED
      ▲                                   │ fail    → OPEN
      └───────────────────────────────────┘
  OPEN = fail instantly (protect yourself & give downstream time to heal)
🧠
Analogy 1 — an electrical circuit breaker: when current surges, the breaker trips and cuts power to protect the house from fire. It doesn't keep feeding a shorted circuit. After a while you flip it back to test. A software breaker "trips" on failures so you stop feeding a dying service.
📞
Analogy 2 — giving up on a number that keeps ringing: if a phone line's been dead all morning, you stop calling every 5 seconds (wasting your time and jamming the line) and try again much later. Backoff is waiting longer between attempts; the breaker is deciding to stop calling entirely for a while.
Timeout + backoff + breaker
breaker = CircuitBreaker(fail_max=5, reset_timeout=30)

def call_downstream(req):
    if breaker.is_open():
        raise ServiceUnavailable("circuit open — failing fast")
    for attempt in range(3):
        try:
            return http.post(url, req, timeout=2.0)   # ALWAYS a timeout
        except (Timeout, ConnErr):
            breaker.record_failure()
            time.sleep((2 ** attempt) + random.random())  # backoff + jitter
    raise
# retry ONLY idempotent calls (topic 8); jitter avoids synchronized retries
🧒
In plain wordsThree safety habits when calling another service: (1) timeout — never wait forever; give up after a couple seconds. (2) retry gently — if it fails, wait a bit (and a bit more each time) before trying again, so you don't pile on. (3) circuit breaker — if it keeps failing, stop calling it for a while and fail instantly, giving it room to recover. Together, these stop one broken service from dragging you down too.

✅ Do

  • Put a timeout on every network call — no exceptions
  • Use exponential backoff with jitter; retry only idempotent operations
  • Add a circuit breaker so a failing dependency makes you fail fast, not hang

❌ Don't

  • Retry non-idempotent writes blindly — double effects (topics 6, 8)
  • Retry without backoff/jitter — you create synchronized retry storms (topic 17)
Gotcha: the scariest outages are cascading failures, and they usually come from missing timeouts — one slow dependency makes callers hold threads/connections waiting, those callers then look slow to their callers, and the stall climbs the whole dependency graph until everything is "down" though nothing crashed. A single unbounded call is all it takes. Timeouts + breakers + bulkheads (topic 19) are what contain the blast radius (topics 5, 17).
19Bulkheads & blast radius★ core idea

Scenario: one slow dependency exhausts your shared thread pool, and now every endpoint is starved — a single feature took down the whole service. Bulkheads isolate resources so one failure floods only its own compartment, containing the blast radius.

What

Bulkheads partition resources (thread pools, connection pools, instances, even whole cells) so a failure in one can't starve the others. Blast radius = how much breaks when one thing does.

Why

Shared resources create shared fate — one greedy dependency drains the pool and everything fails. Isolation turns a total outage into a contained, partial one.

How

Give each dependency/tenant its own bounded pool; use separate instances or "cells" per tenant group; ask of every design "if this dies, what else dies?" and shrink that set.

Isolate so one leak doesn't sink the ship
  SHARED POOL (fragile):
    [ one thread pool ] ← payments(slow) hogs all threads
       → search, profile, checkout ALL starve → total outage ✗

  BULKHEADS (isolated):
    payments → [pool A]   search → [pool B]   profile → [pool C]
       payments floods pool A only → B & C keep serving ✓
  cells: tenant group 1 → cell 1 · group 2 → cell 2 (blast radius = 1 cell)
🧠
Analogy 1 — watertight compartments on a ship: ships have bulkheads so one flooded compartment doesn't sink the vessel. Partition your resources the same way — one failing dependency floods only its compartment, and the ship sails on. One big shared hold means a single leak sinks everything.
🔌
Analogy 2 — separate circuit breakers per room: a house wires each room on its own breaker so a short in the kitchen doesn't black out the bedrooms. Separate pools/instances per dependency are those per-room breakers — a fault stays local instead of tripping the whole house.
Give each dependency its own bounded pool
# ✗ shared pool: one slow dep starves everything
shared = ThreadPool(50)

# ✓ bulkheads: isolate so a flood is contained
pools = {
    "payments": ThreadPool(20),    # payments can use at most 20
    "search":   ThreadPool(20),    # its own budget
    "profile":  ThreadPool(10),
}
def call(dep, fn):
    return pools[dep].submit(fn)   # payments saturating its 20 can't
                                   # starve search or profile
# combine with timeouts + breakers (topic 18); ask "if this dies, what else?"
🧒
In plain wordsIf everything in your service shares one pool of workers, then one slow part can grab all the workers and starve everything else — one broken feature takes down the whole app. Bulkheads fix this by giving each part its own separate pool, like watertight compartments on a ship: if one floods, the others stay dry. The goal is to shrink the "blast radius" — when something breaks, keep the damage to a small area instead of the whole system.

✅ Do

  • Give each dependency/tenant its own bounded pool so failures stay contained
  • Use cells (separate instance groups per tenant set) to cap the blast radius
  • Ask of every component: "if this dies, what else dies?" and shrink that set

❌ Don't

  • Share one unbounded pool across all dependencies — one greedy dep sinks all
  • Let a non-critical dependency share fate with your critical path
Gotcha: bulkheads are the defense that only helps if you set them up before the incident — you can't partition a shared pool mid-cascade. The subtle failure is thinking a circuit breaker alone is enough: a breaker stops you calling a dead service, but if the calls to it already share a pool with everything else, they can exhaust that pool before the breaker trips. Isolation (bulkheads) + fail-fast (breakers, topic 18) + limits (topic 17) are three layers that work together, not substitutes.
20Distributed tracing & observability★ core idea

Scenario: a user says "checkout is slow." The request touched eight services. Your logs are eight separate haystacks with no thread connecting them. Distributed tracing stitches that one request into a single timeline so you can see where the time went.

What

Observability = metrics + logs + traces. A trace follows one request across services as linked spans, tied together by a propagated trace id.

Why

In a distributed system, no single log tells the story. You must correlate across services to answer "what happened to this request?" and "where's the latency?"

How

Generate a trace id at the edge, propagate it in headers through every hop, emit a span per operation. Tools (OpenTelemetry, Jaeger) assemble the timeline.

One trace id, a timeline across services
  trace_id=abc123  (user: "checkout")
  ├─ gateway        ▇▇ 8ms
  ├─ order-svc      ▇▇▇▇▇▇ 40ms
  │   ├─ payment    ▇▇▇▇ 30ms   ◄── the slow span, found instantly
  │   └─ inventory  ▇ 6ms
  └─ email-svc      ▇ 5ms
  the trace id rides in headers on every hop → logs/spans correlate
🧠
Analogy 1 — a package tracking number: one number follows your parcel through every depot and truck, so you see exactly where it sat too long. A trace id is that tracking number for a request — every "depot" (service) scans it, and you get the full journey with timestamps.
🧾
Analogy 2 — an itemized receipt for time: instead of "the meal took an hour," an itemized bill shows the wait for each course. A trace itemizes a request's latency service-by-service, so "checkout is slow" becomes "payment took 30 of the 40ms."
Propagate the trace id through every hop
# edge: start a trace; every downstream call carries the context
with tracer.start_span("checkout") as span:
    span.set_attribute("user.id", user.id)
    headers = {}
    inject(headers)                      # W3C traceparent header
    payment.post(url, req, headers=headers)   # child span, same trace_id
# each service: extract(headers) → continue the SAME trace
# logs include trace_id → grep one id to see the whole request
🧒
In plain wordsWhen one click bounces through eight services, and something's slow, each service only has its own diary — none of them tells the whole story. Tracing gives the request a tracking number that travels with it everywhere, so you can line up all eight diaries into one timeline and instantly see which step ate the time. Without it, debugging is guessing across eight separate logs.

✅ Do

  • Propagate a trace id (W3C traceparent) through every service and log it
  • Adopt OpenTelemetry so metrics, logs, and traces share context
  • Instrument spans around network calls and slow operations

❌ Don't

  • Rely on isolated per-service logs to debug cross-service latency
  • Drop the trace context at async/queue boundaries — propagate it into events too
Gotcha: tracing breaks exactly where you need it most — at async boundaries. When a request becomes an event on a queue, the trace context doesn't ride along unless you deliberately inject it into the message and extract it in the consumer. Teams instrument their sync calls, feel covered, then hit a wall the moment work goes through Kafka and the trace "ends" at the producer. Propagate context into events, not just HTTP headers (see the Event-Driven playbook).
21Testing distributed systems — chaos & contracts

Scenario: every service passes its own unit tests, yet the system fails in production when a network blip or a schema change hits. Distributed bugs live between services and only appear under failure — so you must test the failures and the contracts explicitly.

What

Techniques for the gaps unit tests miss: contract tests (services agree on message shapes), chaos engineering (inject failures on purpose), and integration/load tests under fault.

Why

Distributed failures are emergent — partitions, latency, duplicates, schema drift. You can't unit-test them into existence; you must induce them and verify you survive.

How

Contract tests pin producer/consumer message shapes in CI. Chaos tools (kill pods, add latency, drop packets) verify resilience and recovery — in staging, carefully in prod.

Test the gaps between services
  CONTRACT TEST (in CI):
    producer says: OrderPlaced{id:int, total:number}
    consumer expects: {id, total}  → CI fails if the shapes drift

  CHAOS (inject real failure):
    ✂ kill a pod        → does traffic reroute? (topic 16)
    🐌 add 500ms latency → do timeouts/breakers hold? (topic 18)
    🔌 drop the network  → does it stay up, not collapse? (topic 19)
🧠
Analogy 1 — a fire drill: you don't wait for a real fire to learn the exits are blocked. You practice the emergency on purpose, calmly, and fix what fails. Chaos engineering is scheduled fire drills for your system — break it deliberately so real breaks are boring.
🔌
Analogy 2 — checking plugs fit before the gig: two bands each sound great alone, but if their cables don't match, the show fails. A contract test is checking the plugs fit before showtime — verifying the producer's output matches what the consumer expects, so integration doesn't surprise you live.
Contract test + a chaos experiment
# CONTRACT: consumer pins the shape it needs; CI fails on drift
def test_order_placed_contract():
    event = producer.sample("OrderPlaced")
    assert set(event) >= {"id", "total", "customer_id"}   # required fields
    assert isinstance(event["total"], (int, float))

# CHAOS: assert the system SURVIVES failure
def test_survives_payment_slowness():
    with fault(payment_service, mode="latency", ms=800):  # inject failure
        r = checkout(order)
        assert r.status in ("ok", "queued")   # breaker/timeout held, no hang
🧒
In plain wordsEach service can pass all its own tests and the whole thing still breaks — because the bugs hide between services and only show up when the network misbehaves. So you do two extra things: contract tests make sure services still agree on the shape of their messages (like checking two puzzle pieces still fit), and chaos testing means you break things on purpose (kill a service, add delay) to prove the system bends instead of shattering. Practice the disaster so the real one is boring.

✅ Do

  • Add contract tests so producer/consumer schema drift fails in CI, not prod
  • Run chaos experiments (latency, node kills, partitions) to verify resilience
  • Load-test under induced failure — that's when systems really break

❌ Don't

  • Assume green unit tests mean the system works — the bugs are in the seams
  • Run chaos in prod without blast-radius limits and a kill switch
Gotcha: your carefully-built resilience — retries, breakers, bulkheads, fallbacks — is untested code until failure actually exercises it, and untested code doesn't work. The first time a circuit breaker trips shouldn't be during a real incident. Teams discover their breaker was misconfigured, their timeout too long, or their fallback throws only because they never induced the failure on purpose. Chaos testing is how you find out on a Tuesday afternoon instead of at 3am (topics 18, 19).
22Capstone — a resilient distributed service★ capstone

Scenario: assemble everything into one real service — a read-heavy API that stays correct under partial failure, scales its reads and writes, and degrades instead of dying. This is the whole playbook working together.

What

A production service wiring together replication, sharding, quorum reads, caching, idempotent writes, timeouts/breakers, bulkheads, and tracing — the concepts from all 21 topics.

Why

Any one pattern is easy alone. Production is making them coexist: correct under failure, fast enough, observable, and able to degrade gracefully.

How

Route reads to replicas/cache, writes to the sharded leader with idempotency; wrap every dependency in timeout+breaker+bulkhead; trace every request; keep quorum for the data that must be correct.

The whole system, assembled
  request ─► [ LB + discovery (16) ] ─► service (stateless)
     reads  ─► Redis cache-aside (15) → miss → replica (10) or quorum (13)
     writes ─► idempotency key (8) → sharded leader (11), quorum W (13)
        every dependency call: timeout + retry + breaker (18) in its own
        BULKHEAD pool (19); trace_id on every hop (20)
     partition? choose CP/AP per feature (3); consistency dial per op (4,13)
     failure? rate-limit & shed (17); degrade, don't collapse (19)
🧠
Analogy 1 — a well-run logistics hub: reads come from the nearby stockroom (cache) or a branch (replica); writes go to the right warehouse aisle (shard) with a signed receipt (idempotency); each loading dock has its own crew and cap (bulkheads + limits); and every parcel carries a tracking number (tracing). One dock jamming reroutes; the hub keeps running.
🏗️
Analogy 2 — a finished building vs a pile of materials: you learned bricks (idempotency, quorums), wiring (replication, sharding), and safety codes (breakers, bulkheads) separately. The capstone is the standing, inspected, occupied building — every system connected and up to code. That integration is the engineering.
Everything, wired together
def handle(req):
    trace = start_trace(req)                        # tracing (20)
    if req.is_read:
        if v := cache.get(req.key): return v        # cache-aside (15)
        v = replica.read(req.key, consistency="QUORUM")  # replica + quorum (10,13)
        cache.set(req.key, v, ttl=jitter(60)); return v
    # write path — idempotent, sharded, bounded, isolated:
    if seen(req.idem_key): return stored(req.idem_key)   # idempotency (8)
    shard = shard_for(req.key)                      # sharding (11)
    with bulkhead("db"), breaker("db"):             # isolation + fail-fast (18,19)
        r = shard.leader.write(req, timeout=2.0, consistency="QUORUM")  # (13)
    cache.delete(req.key); remember(req.idem_key, r)
    return r
# overload → rate-limit & shed (17); partition → CP/AP per feature (3)
🧒
In plain wordsThis is everything put together: a service that answers fast by keeping copies (cache + replicas), handles more data by splitting it (shards), never double-charges (idempotency), never waits forever or lets one broken dependency sink it (timeouts, breakers, separate pools), and tags every request so you can debug it. Each part was simple alone — the real skill is making them all work together so the system stays correct and up even when pieces fail.

✅ Do

  • Start with one machine; add replication, then caching, then sharding as real pain appears
  • Wrap every dependency in a timeout + breaker + bulkhead from the start
  • Decide consistency per feature; bake in idempotency and tracing early

❌ Don't

  • Adopt every pattern here for a small system — most apps need very few of them
  • Bolt on resilience and observability "later" — later is after the incident
Gotcha: the hardest part of a distributed system isn't any single pattern — it's that they interact. A cache hides the very replication lag your quorum was tuned to avoid; a shard key that's great for writes wrecks a cross-shard read; a bulkhead you sized for normal load starves under a retry storm. Ship the smallest system that solves the real problem, then add each pattern deliberately — because every one you add is complexity you'll debug at 3am. The discipline of not distributing until you must is the most senior skill of all. 🚀
Test yourself · Round-based quiz

Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.

Score: 0 / 0 answered · 0 total