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.
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)
# 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)
✅ 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
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.
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
# 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)✅ 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
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.
┌── 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)
# 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✅ 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)
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.
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 ──►
# 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✅ 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
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.
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!
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✅ 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
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.
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
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)✅ 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"
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).
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)
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.✅ 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
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.
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
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.✅ 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
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).
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
# 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.
✅ 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
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.
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)
# 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✅ 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
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(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
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✅ 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)
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.
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
# 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✅ 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
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.
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)
# 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✅ 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)
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.
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 ✓
# 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✅ 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
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.
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)
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)✅ 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)
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.
┌── 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
# 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
✅ 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)
/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: 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
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✅ Do
- Rate-limit per client/key and return
429with aRetry-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)
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.
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)
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✅ 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)
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.
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)
# ✗ 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?"✅ 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
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.
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
# 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✅ 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
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.
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)
# 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✅ 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
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.
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)
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)✅ 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
Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.