SSystem Design

Block C · Topics 10–15

Distributed behaviour

Everything here follows from one fact: a network call can fail in a way where you cannot tell whether it happened. Messaging, retries, idempotency and locks are all different responses to that single uncertainty.

10

Messaging

Kafkaqueuespub-suborderingdelivery semantics
Why it exists

A synchronous call couples two services in availability (if the callee is down, the caller fails), in capacity (a traffic spike hits both at once), and in time (the caller waits). A broker breaks all three: the producer writes and moves on, the broker absorbs the spike, and the consumer works at its own pace. You are trading immediate feedback for decoupling.

Queue vs log

Queue (SQS, RabbitMQ)Log (Kafka, Pulsar, Kinesis)
ModelMessage is consumed and deletedAppend-only log; consumers track an offset
ConsumersCompeting — one message, one workerIndependent groups each read everything
ReplayNo — it's goneYes — rewind the offset. Huge for bugs and new consumers.
OrderingBest-effort (FIFO queues cost throughput)Strict, per partition
RetentionUntil consumedTime or size based (days to forever)
ThroughputHighVery high — sequential disk writes, batching
Best forTask distribution: "someone process this"Event streams: "this happened", many interested parties

Delivery semantics

SemanticMechanismRiskUse for
At-most-onceAck before processingMessage lost on a crashMetrics, telemetry, where loss beats duplication
At-least-onceAck after processingDuplicates on retryAlmost everything. The default.
Exactly-onceOnly within a closed system (e.g. Kafka read-process-write transactions)Cannot span an external side effect — the moment you call Stripe, it's off the tableStream processing inside one platform
The exactly-once trap

If an interviewer asks for exactly-once delivery, the strong answer is: "Exactly-once delivery isn't achievable across a network — the ack itself can be lost. What's achievable is at-least-once delivery plus idempotent processing, which gives exactly-once effects. That's what I'd build." This one sentence separates candidates.

Ordering

Global ordering across a distributed log is a throughput ceiling — it means one partition, one consumer, no parallelism. What you actually want is ordering within a key:

partition = hash(ordering_key) % num_partitions

ordering_key = conversation_id   # messages in a chat stay ordered
ordering_key = account_id        # ledger entries per account stay ordered
ordering_key = order_id          # created → paid → shipped, in order

# Consequences to state out loud:
#  · One key's throughput is capped by one partition.
#  · Increasing partition count re-maps keys — ordering breaks across the change.
#  · A slow message blocks its partition (head-of-line blocking).

The dual-write problem and the outbox pattern

The single most valuable messaging pattern to know, because the naive version is broken and everyone writes the naive version first.

# BROKEN — two systems, no shared transaction
db.save(order)             # succeeds
kafka.publish(OrderPlaced) # crashes here → order exists, nobody knows

# FIXED — transactional outbox
BEGIN
  INSERT INTO orders  (...)
  INSERT INTO outbox  (topic, payload, created_at)   # same transaction
COMMIT
# A separate relay tails the outbox (poll, or change-data-capture via
# Debezium) and publishes. It may publish twice after a crash — which is
# fine, because consumers are idempotent. Atomicity is preserved because
# both rows commit together or neither does.
Order svc one txn DB + outbox atomic together Relay CDC / poll Kafka topic P0 ▸ ▸ ▸ ▸ ▸ P1 ▸ ▸ ▸ ▸ P2 ▸ ▸ ▸ ▸ ▸ ▸ ordered per partition Email svc offset 8,231 Analytics offset 8,229 Search index offset 1,004 · lagging each group owns its offset — a slow consumer lags but never blocks the others, and can replay from any point
Outbox for atomicity, partitions for ordered parallelism, independent offsets for isolation.

Consumer groups and rebalancing

  • Each partition is owned by exactly one consumer in a group. More consumers than partitions means idle consumers — partition count is your parallelism ceiling, so over-provision partitions early (they're cheap; adding them later breaks key→partition mapping).
  • A consumer joining, leaving or timing out triggers a rebalance, which stops consumption briefly. Frequent rebalances usually mean processing takes longer than the poll interval — fix by processing faster, batching smaller, or raising max.poll.interval.ms.
  • Consumer lag (latest offset − committed offset) is the single most important metric on any queue. Alert on it. Rising lag means you're falling behind permanently, not temporarily.
Say this

"Kafka rather than a plain queue, because I want replay and multiple independent consumers — the search indexer and the email service both need order events and shouldn't compete for them. Partition by order_id so one order's events stay ordered, at-least-once delivery with idempotent consumers keyed on event_id, and the producer uses a transactional outbox so we never have an order in the database that nobody was told about."

11

Async Processing

workersjobsbackpressureDLQscheduling
Why it exists

Some work must not happen inside a request. Video transcoding takes minutes; an emailer being down shouldn't fail a signup; a report can be built while the user does something else. Moving work off the request path shortens p99 and decouples your availability from every downstream service's.

What belongs async

Async
  • Slow: transcoding, PDF generation, ML inference
  • Third-party: email, SMS, push, webhooks
  • Fan-out: notify 10,000 followers
  • Batch: nightly reports, reindexing
  • Retryable: anything that can safely happen a bit later
Must stay synchronous
  • Anything whose result the user needs on screen now
  • Authorisation and validation decisions
  • Reserving scarce inventory (the last seat)
  • Anything where "we'll tell you later it failed" is unacceptable

Designing a job

  • Idempotent. It will run twice. Key on a stable job ID.
  • Small. One job = one unit. "Send 10,000 emails" should be a fan-out job that enqueues 10,000 jobs — otherwise a failure at email 9,999 replays all of them.
  • Reference, don't embed. Put an ID in the payload and let the worker fetch. Embedding a 5 MB blob bloats the broker and the payload goes stale.
  • Versioned. Jobs enqueued before a deploy are consumed after it. Old payloads must still parse.
  • Checkpointed, if long-running. A 4-hour job that restarts from zero on a deploy will never finish.
  • Time-bounded. A visibility timeout / lease longer than the worst-case runtime, or another worker picks it up while the first is still going.

Backpressure

Producers are usually faster than consumers. Without a response to that, queues grow without bound until the broker fills, memory dies, or latency becomes meaningless. Pick one deliberately:

StrategyBehaviourRight for
BufferLet the queue absorb the spikeShort bursts with known bounds — the default and often enough
Block / throttle producerSlow the source downInternal pipelines where the source can wait
Shed loadReject with 429/503 at the edgeUser-facing: a fast rejection beats a 5-minute timeout
Sample or degradeDrop some, or reduce qualityMetrics, logs, non-critical enrichment
Autoscale consumersAdd workers on queue depthElastic workloads — but watch the downstream database, which does not autoscale
Autoscaling on queue depth has a trap

Scaling workers up because the queue is deep can make things worse: 500 new workers all hit the same database and take it down, which slows processing, which deepens the queue, which adds workers. Cap concurrency at what the slowest shared dependency can take, not at what the queue suggests.

Retries and the dead-letter queue

attempt 1  → fail → wait  1s ± jitter
attempt 2  → fail → wait  2s ± jitter
attempt 3  → fail → wait  4s ± jitter
attempt 4  → fail → wait  8s ± jitter
attempt 5  → fail → DEAD LETTER QUEUE

# Retry only what can succeed later:
#   retry:     timeouts, 429, 503, connection reset, deadlock
#   never:     400, 401, 403, 422, malformed payload — these fail forever

The DLQ is not a graveyard, it's an inbox. It needs: an alert when anything lands in it, the original message plus the error and stack trace, and a documented, tested replay path for after you fix the bug. A DLQ nobody looks at is just a slower way to lose data.

Watch for the poison message: one malformed message that fails, gets redelivered, fails again, and blocks its partition forever while consuming all your retry capacity. A max-attempts count that routes to the DLQ is what stops it.

Scheduling and priority

  • Delayed jobs — a delay parameter (SQS), a Redis sorted set keyed on run-at, or a scheduler topic.
  • Cron across N instances — needs a leader or a lock, or every instance runs the job. See topic 15.
  • Priority — use separate queues per priority with dedicated worker pools, not a priority field. A single priority queue starves the low tier completely; separate pools guarantee low-priority work still moves.
  • Fairness — one tenant enqueueing a million jobs must not delay everyone. Per-tenant queues, or weighted round-robin across tenants.
Say this

"The upload API returns 202 with a job ID as soon as the file lands in object storage; transcoding happens on a worker fleet reading from a queue. Jobs are keyed on upload_id so a redelivery is a no-op, retries use exponential backoff with jitter, and after five attempts the job goes to a DLQ that pages us. Workers autoscale on queue depth but with a hard concurrency cap, because 500 workers hammering the metadata database is a worse outage than a slow queue."

12

Rate Limiting

token bucketleaky bucketsliding window429
Why it exists

Capacity is finite and demand isn't. Rate limiting protects you from abuse, from a buggy client in a retry loop, from one tenant starving the others, and from yourself. Without it, the failure mode is total: one caller takes down the service for everyone.

The algorithms

AlgorithmHow it worksBurstsMemoryWeakness
Fixed windowCounter per clock window; reset each windowAllowed, badly1 int/key2× burst at the boundary — 100 requests at 0:59 and 100 at 1:01 is 200 in two seconds
Sliding window logStore every request timestamp; count those inside the windowExactO(limit)/key — expensiveMemory at high limits
Sliding window counterWeighted blend of current + previous windowSmoothed2 ints/keyApproximate (assumes even distribution) — but the approximation is fine
Token bucketTokens refill at rate r, capacity b; each request spends oneAllows a controlled burst up to b2 values/keyNeeds care with clock and refill maths
Leaky bucketQueue drains at a fixed rateSmooths them away entirelyQueue sizeAdds latency; drops on overflow
Token bucket — burst-friendly refill: r tokens/sec capacity b spend 1 per request Empty bucket → 429. A quiet client banks tokens and may spend b at once — usually what you want. Leaky bucket — smoothing bursty arrivals constant drain Output rate is flat no matter the input. Overflow is dropped. Use when the downstream cannot absorb bursts at all.
Token bucket permits bursts and is the usual choice for APIs; leaky bucket enforces a flat rate and is right when the thing you're protecting genuinely cannot burst.

Distributed rate limiting

With 50 servers, a per-server limit of limit/50 is wrong the moment traffic is uneven, and a shared counter adds a network hop to every request. The standard answer is Redis with an atomic script:

-- Token bucket in one atomic Redis Lua call (no read-modify-write race)
local tokens, last = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local now, rate, cap, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), 1
tokens = math.min(cap, (tokens or cap) + (now - (last or now)) * rate)
if tokens < cost then return {0, tokens} end
redis.call('HMSET', KEYS[1], 'tokens', tokens - cost, 'ts', now)
redis.call('EXPIRE', KEYS[1], 3600)
return {1, tokens - cost}

To avoid the hop on every request, use local buckets with periodic reconciliation: each server holds a slice of the budget locally and syncs with Redis every second. Slightly over-permissive during the sync interval, which is almost always an acceptable trade for removing a network call from the hot path.

Fail open or fail closed?

If Redis is unreachable, do you allow or deny? Fail open for normal API protection — a rate limiter outage shouldn't be a service outage. Fail closed for anything guarding a hard resource limit or an abuse-critical path (login attempts, SMS sending, expensive AI inference), where unlimited is worse than unavailable. Say which you chose and why.

What to limit, and where

LayerLimitsWhy there
CDN / edgePer IP, obvious floods, L3/L4 volumetricCheapest place to drop a packet is before it reaches you
API gatewayPer API key, per user, per endpoint classOne place to enforce plan quotas
ServicePer tenant, per expensive operation, concurrency capsOnly the service knows a "search" costs 50× a "get"
Database / dependencyConnection pool caps, per-tenant concurrencyThe last line — protects the thing that can't scale quickly

Choose the key deliberately. Per-IP alone punishes corporate NAT and mobile carriers, and is trivially bypassed with a proxy pool; per-user requires authentication, so unauthenticated endpoints still need an IP or device dimension. Most real systems layer several: per-IP and per-user and per-tenant, with different windows. Weight by cost too — a single expensive query can be worth 100 tokens.

Always return the metadata clients need to behave:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 1756483200
Say this

"Token bucket in Redis, keyed per API key, executed as a Lua script so the check-and-decrement is atomic. Token bucket rather than fixed window because I want to allow a short burst — real clients are bursty and a fixed window lets through 2× the limit at the boundary anyway. 429 with Retry-After so well-behaved clients back off instead of hammering. If Redis is down I fail open on read APIs and fail closed on the SMS endpoint, because there the cost of unlimited is a real bill."

13

Reliability

timeoutsretriescircuit breakersbulkheadsdegradation
Why it exists

Failure is not an edge case at scale — with 1,000 machines, something is always broken. Reliability engineering isn't about preventing failure; it's about making sure a failure stays small. Every pattern below is a way of containing blast radius.

Timeouts — non-negotiable

Every network call gets a timeout. A call without one waits forever, holding a thread and a connection; enough of them and your service is down because someone else's service is slow. Set the timeout from the dependency's p99, not its average (say p99 × 1.5), and make sure timeouts decrease as you go deeper: if the client waits 3 s, the API can wait 2 s and the database 1 s. The other way round means you keep working on requests nobody is listening for any more.

Retries — and how they cause outages

delay = min(cap, base × 2^attempt) × random(0.5, 1.5)   # full jitter

# Without jitter, every client that failed at T retries at T+1s together —
# a synchronised thundering herd on a service that is already struggling.

# Retry amplification: 3 retries per layer over 4 layers = 3^4 = 81×
# load on the bottom service. THIS is how a slow database becomes an outage.
# Fixes:
#   · Retry at ONE layer (usually the outermost), not every layer.
#   · Retry budgets: cap retries at ~10% of total requests; when the budget
#     is spent, fail fast instead.
#   · Never retry a non-idempotent call without an idempotency key.

Circuit breakers

CLOSED all calls pass through counting failures OPEN fail instantly, no call serve the fallback HALF-OPEN let a few probes through >50% errors in 10s window after 30s probe failed probes succeeded → close
The point is not to protect the caller — it's to stop hammering a struggling dependency so it can recover, and to fail in 1 ms instead of 30 s.

Bulkheads

Named after ship compartments: partition your resources so one flooded section doesn't sink the vessel. Concretely — separate connection pools and thread pools per dependency, so that when the recommendations service hangs, it exhausts only its own 20 connections and checkout still has its own pool. Without bulkheads, one slow dependency consumes every thread in a shared pool and every endpoint goes down with it.

Bulkheads also apply at coarser grains: separate fleets per tenant tier, separate clusters per region, separate queues per priority. Shuffle sharding is the elegant version — give each tenant a random pair of shards from a pool, and one abusive tenant degrades only the small fraction of tenants sharing both of its shards.

Graceful degradation

Decide in advance what to drop. A product page that loses recommendations is a product page; a product page that returns 500 is lost revenue.

When this failsDon'tDo
Recommendation serviceFail the pageShow a static "popular items" list
PersonalisationFail the feedServe the global/chronological feed
Search backendFail the queryReturn cached results with a staleness notice
Live inventory countBlock checkoutHide the number, accept the order, verify async
Analytics pipelineBlock the write pathDrop events; it's telemetry

Availability arithmetic

99%     "two nines"    3d 15h down/year   ≈ 7.2h/month
99.9%   "three nines"  8h 46m/year        ≈ 43m/month
99.99%  "four nines"   52m/year           ≈ 4.3m/month
99.999% "five nines"   5m 15s/year        ≈ 26s/month

# Dependencies in SERIES multiply — this is the number that surprises people
5 services at 99.9% each  →  0.999^5  =  99.5%   (≈ 3.6 hours/month)

# Redundant components in PARALLEL: failure probabilities multiply
2 replicas at 99% each    →  1 − 0.01²  =  99.99%

# Which is the whole argument for redundancy AND for having fewer
# hard dependencies on the critical path. Making a dependency optional
# (degrade instead of fail) removes it from the series product entirely.
Say this

"Every outbound call has a timeout derived from the dependency's p99, retries with exponential backoff and full jitter but only at the edge so we don't get retry amplification, and a circuit breaker that opens at a 50% error rate so we stop hammering something that's already down. Each dependency gets its own connection pool, so a hang in recommendations can't starve checkout. And recommendations is explicitly optional — if it's down we serve a static list, which keeps it out of the availability product entirely."

14

Idempotency

duplicatesdedupreplay safetyexactly-once effects
Why it exists

The client sends "charge ₹5,000", the network drops the response, and the client has no way to know whether the charge happened. It must either retry (risking a double charge) or give up (risking a lost payment). Idempotency removes the dilemma: retrying is always safe, so the client can always retry.

This is not an edge case. At-least-once delivery is the default everywhere — load balancers retry, queues redeliver, mobile clients reconnect — so duplicates are the normal condition of a distributed system, not an anomaly.

Three ways to get it

ApproachHowBest for
Natural idempotencyMake the operation a set, not a delta. status = 'shipped' is idempotent; count = count + 1 is not.State updates. Always prefer this — it needs no extra machinery.
Idempotency keyClient sends a unique key; server stores key → response and replays it on a repeat.Creates and side effects: payments, orders, sending mail.
Dedup on a natural IDUnique constraint on a business key (event_id, transfer_id); a duplicate insert is a caught conflict, not an error.Event consumers. The database enforces it for you.

A correct idempotent handler

def charge(idem_key, amount, card):
    # 1. Claim the key. The UNIQUE constraint IS the lock — this is why
    #    it works under concurrency, not just under sequential retries.
    try:
        db.insert(idempotency, key=idem_key, state='IN_PROGRESS',
                  request_hash=sha256(amount, card))
    except UniqueViolation:
        row = db.get(idempotency, idem_key)
        # Same key, different body = a client bug. Never silently accept it.
        if row.request_hash != sha256(amount, card):
            raise Conflict("idempotency key reused with different payload")
        if row.state == 'IN_PROGRESS':
            raise Conflict("in flight, retry shortly")   # 409
        return row.response                              # replay, do nothing

    # 2. Do the work, passing the key downstream so the PSP dedups too.
    result = psp.charge(amount, card, idempotency_key=idem_key)

    # 3. Store the response WITH the key, atomically with any state change.
    with db.transaction():
        db.insert(payments, id=result.id, amount=amount)
        db.update(idempotency, key=idem_key, state='DONE', response=result)
    return result
Details that separate a real answer from a hand-wave
  • Store the response, not a flag. A retry must return the original body — otherwise the client can't distinguish "already done" from a new outcome.
  • Hash the request. Same key with a different payload means the client is broken; return 409 rather than silently returning the old result for a different charge.
  • Scope the key. Per (endpoint, tenant). A global key space lets one caller's UUID collide with another's semantics.
  • Expire keys (24h–7d is typical) and say so — infinite retention is unbounded storage growth.
  • Pass the key downstream. Your idempotency is worthless if your retry to the payment processor creates a second charge there.
  • Handle the crash between work and record. Either write the record in the same transaction as the effect, or make the effect itself idempotent so a replay is harmless.

Exactly-once, honestly

exactly-once DELIVERY  =  impossible across a network
                          (the ack can always be lost)

exactly-once EFFECTS   =  at-least-once delivery
                        + idempotent processing
                        =  achievable, and what everyone actually means

# Kafka's "exactly-once semantics" is real but scoped: it covers
# read → process → write when both ends are Kafka, via transactional
# offset commits. The moment you call an external API inside that
# loop, you are back to at-least-once + dedup.
Say this

"Every mutating endpoint takes an Idempotency-Key. I insert the key first with a unique constraint — that insert is the lock, so two simultaneous retries can't both proceed — do the work, then store the response against the key so a later retry replays the exact same response. I also hash the request body and reject a reused key with different content, because that's a client bug I'd rather surface than paper over. And I pass the same key to the payment processor, so the dedup holds end to end."

15

Distributed Locks

leasesfencing tokensleader electionRaft
Why it exists

Sometimes exactly one process may act: one instance runs the nightly job, one node is the leader, one worker owns a partition. A lock in a single process is a solved problem; across machines with no shared memory and an unreliable network, it is genuinely hard — and the naive version is subtly, expensively wrong.

The basic lease

SET lock:job123 <random-uuid> NX PX 30000
#  NX = only if absent (atomic acquire)
#  PX = auto-expire in 30s, so a crashed holder does not deadlock forever
#  the UUID is the ownership proof — release must be conditional:

if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])       # atomic check-and-delete
else return 0 end
# A plain DEL would let you delete a lock that has already expired and
# been acquired by someone else.
The problem no TTL can solve

Process A acquires a 30-second lease. A GC pause (or a VM migration, or a disk stall) freezes it for 40 seconds. The lease expires; process B legitimately acquires the lock. Process A wakes up — still believing it holds the lock, because nothing told it otherwise — and writes. Two writers, corrupted state, and no amount of TTL tuning fixes it: any timeout you pick can be exceeded by a pause you didn't predict.

Fencing tokens — the actual fix

Client A Client B acquire · token 33 — GC pause, 40s — writes with token 33 lease expired → acquire · token 34 writes with token 34 ✓ Storage: "I have seen 34. 33 < 34 → REJECT." The lock service hands out a monotonically increasing number with every acquisition; the resource remembers the highest it has seen and refuses anything lower.
Correctness comes from the resource rejecting stale writers, not from the lock service being clever.

The critical implication: fencing requires cooperation from the resource you're protecting. If the database or object store won't check a token, you cannot make the lock safe — you can only make it usually work. That is the honest framing, and stating it is a senior-level signal.

Where to keep the lock

BackendGuaranteesTradeoff
Single RedisFast, simple leaseNot fault-tolerant for correctness: a failover can lose the lock (async replication) and grant it twice
Redlock (N Redis nodes)Majority acquisitionPublicly disputed. Relies on bounded clock drift and bounded pauses. Fine for efficiency, contested for correctness.
Zookeeper / etcd / ConsulConsensus (ZAB/Raft), sessions, ephemeral nodes, monotonic revision numbers as fencing tokensAnother cluster to run; higher latency. The right answer when correctness matters.
Database row lockReal transactional guaranteesSimple and correct; limited by database throughput and connection holding
Efficiency locks vs correctness locks

Ask which one you need — the answer changes everything.

Efficiency: the lock exists to avoid duplicate work (don't send the same email twice, don't recompute the same cache entry). A rare double-run is a wasted resource, not a bug. Redis is fine.

Correctness: a double-run corrupts data or double-spends money. Use consensus plus fencing tokens — or, better, redesign so you don't need a distributed lock at all.

Leader election

Often what you actually want. One node holds a lease and renews it; if it dies, the lease expires and another takes over. Raft/ZAB implementations (etcd, Zookeeper, Consul) give you this with a session, automatic renewal, and a revision number you can use directly as a fencing token. Kubernetes does exactly this for controller leases. Two things to handle: the leader must stop acting the moment it loses the lease (not when it notices), and every follower must be ready to take over cold.

Designs that avoid the lock entirely

  • Partition ownership. Assign key ranges to workers (Kafka consumer groups do this). Only one worker owns a key, so no lock is needed for it.
  • Optimistic concurrency. UPDATE … WHERE version = 7; zero rows updated means someone beat you — reload and retry. Excellent under low contention and requires no lock service at all.
  • Database constraints. A unique index does the mutual exclusion for you, transactionally.
  • Single-writer per entity. Route all commands for an entity to one process (actor model, per-key queue). Serialisation by routing instead of by locking.
  • Idempotent workers. If running twice is harmless, you never needed exclusion (topic 14).
Say this

"First I'd ask whether this is an efficiency lock or a correctness lock. For 'only one instance runs the nightly report', a Redis lease with a TTL is fine — a rare double-run wastes CPU. For 'only one process may write this balance', a lease is not enough: a GC pause can outlive the TTL and give you two writers. There I'd use etcd for leader election and pass its revision number as a fencing token that the storage layer checks. Better still, I'd shard by account so each account has exactly one owning writer, and skip the lock entirely."