Course 3 · Foundations

Distributed Systems I — Scaling Out

At some point the data outgrows the biggest machine you can buy, and you have no choice but to spread the work across many machines. This chapter builds the core mental model of that world: how data is split, how work runs in parallel, and the one operation — the shuffle — that explains why most big-data jobs are slow. Diagrams over code; intuition you'll lean on for the rest of the curriculum.

Why one machine isn't enough

For most of this course we've reasoned about a single computer: one CPU, one block of memory, one disk. That model is enough for an astonishing amount of data work — a modern laptop can sort a few hundred million rows. But eventually you hit a wall, and it's worth being precise about what the wall is.

Remember GridDP, the running example from the systems-design thread: a platform ingesting telemetry from thousands of sensors on an electrical grid. Each sensor reports several times a second, and it adds up to roughly 150 million rows per day — call it 50 GB raw, and far more once you keep months of history for analysis. Now ask two questions:

  • Can one machine store it? A year of telemetry is many terabytes. You can buy a big disk, but you can't buy an infinite one, and a single disk is a single point of failure.
  • Can one machine process it fast enough? "Average voltage per substation over the last 90 days" has to read billions of rows. One CPU reading one disk has a hard ceiling on how fast that goes.

There are two ways to get more capacity. The first instinct is to scale up (vertical scaling): buy a bigger box — more RAM, more cores, faster disks. This is wonderfully simple because your code doesn't change; it's still one machine. But it runs into two walls. There's a physical wall — the biggest server money can buy is finite, and you can't keep doubling RAM forever. And there's a cost wall — high-end hardware is priced exponentially: the top-tier machine costs far more than 10× a commodity one. Past a point you pay more and more for less and less.

So we scale out (horizontal scaling): instead of one heroic machine, use many ordinary ones — a cluster of nodes — and divide the data and the work among them. Twenty cheap machines can hold and crunch what no single machine could. This is the entire premise of Hadoop, Spark, BigQuery, Snowflake, and every "big data" system you'll meet later.

SCALE UP (vertical) SCALE OUT (horizontal) ─────────────────── ────────────────────── ┌───────────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │ │node│ │node│ │node│ │node│ │ one BIG │ vs. └────┘ └────┘ └────┘ └────┘ │ machine │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │ │node│ │node│ │node│ │node│ └───────────┘ └────┘ └────┘ └────┘ └────┘ simple, but hits a cheap commodity boxes; physical + cost ceiling grows ~linearly... if you can divide the work well

That last clause is the catch, and it's what the rest of this chapter is about. Scaling out is only a win if you can actually split the problem across the machines — and some problems split far more cleanly than others.

Partitioning & sharding

The first move in any distributed system is to decide which data lives on which machine. We don't put a random scatter of rows on each node; we split the data deliberately using a partition key (in databases this is often called a shard key; the terms overlap). You pick a column, and each row's value in that column decides which node it lands on.

A common scheme is hash partitioning: take the key, hash it, and take the result modulo the number of nodes. For GridDP we might partition by sensor_id:

150M telemetry rows │ hash(sensor_id) % 4 ┌────────────┬─────┴──────┬────────────┐ ▼ ▼ ▼ ▼ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ Node 0│ │ Node 1│ │ Node 2│ │ Node 3│ │ ~37M │ │ ~37M │ │ ~37M │ │ ~37M │ │ rows │ │ rows │ │ rows │ │ rows │ └───────┘ └───────┘ └───────┘ └───────┘ each node owns a SLICE; all 4 work on their slice in parallel

Now no single node holds everything, and — crucially — each node can do its share of a query at the same time. Storage and compute both spread across the cluster.

But partitioning has a failure mode that catches everyone eventually: skew. If your partition key is unevenly distributed, one node gets a mountain of data while the others sit nearly idle. Imagine partitioning GridDP by region when 80% of the sensors are in one dense city region — that region's node holds 80% of the rows, runs 80% of the work, and the whole job waits on it. You bought four machines and got the speed of barely one.

Partition key ideaWhat happens
sensor_id (high-cardinality, even)Rows spread evenly across nodes — the goal. ✔
region (few values, lopsided)Skew — one node overloaded while others idle. ✘
status = 'ok' / 'error' (almost all 'ok')Severe skew — nearly everything on one node. ✘
date (one partition per day)Even over time, but today's partition is a hotspot for writes. ⚠
Choosing a good key

A good partition key has high cardinality (many distinct values), even distribution (no value dominates), and ideally matches how you'll query the data. Those last two can conflict — and resolving that tension is the recurring puzzle of distributed data, as you'll see in the exercise.

Parallel processing: the map-reduce idea

Once the data is partitioned, how do we compute over it? The foundational pattern — old enough to predate Hadoop, durable enough to underlie Spark — is map-reduce. It has two phases:

  • Map — each node runs the same work on its own partition, independently and in parallel. No node needs to talk to any other.
  • Reduce — the partial results from each node are combined into a final answer.

Say we want the total number of telemetry rows across GridDP. Each node counts its own slice (map); then we add the four partial counts together (reduce):

MAP (parallel, no coordination) REDUCE (combine) Node 0 ── count its rows ──▶ 37,012,883 ┐ Node 1 ── count its rows ──▶ 36,998,140 │ sum the Node 2 ── count its rows ──▶ 37,105,902 ├──▶ partials ──▶ 150,001,... Node 3 ── count its rows ──▶ 38,884,... ┘ (tiny data) heavy work happens here, cheap: a few numbers on each node at once move over the network

The reason this is fast: the expensive part — reading and counting billions of rows — happens on all nodes simultaneously, and only a tiny result (four numbers) has to move afterward. With four nodes you get roughly 4× the throughput.

Work like this — where each partition can be processed entirely on its own — is called embarrassingly parallel. Counting rows, filtering (WHERE voltage > 250), applying a transformation to each row, computing a per-row derived column: none of these need data from any other partition, so they scale almost perfectly. Add machines, go faster, nearly linearly.

The dividing line

Some operations only need a node's own data (filter, map, per-row math) — embarrassingly parallel, cheap to distribute. Others need data that lives on other nodes — joins across the key, group-bys on a different column, global sorts. The moment an operation crosses that line, the cluster has to start moving data between machines. That movement is the next section, and it's where distributed processing gets expensive.

The shuffle — where the cost lives

Here's the problem. Suppose GridDP is partitioned by sensor_id, and you ask for average voltage per region. The rows for a single region are scattered across all four nodes — because they were placed by sensor, not by region. To group by region, every region's rows must end up together on one node. The cluster has no choice but to move data between machines over the network. That movement is called the shuffle.

Partitioned by sensor_id, but we GROUP BY region: Node 0 Node 1 Node 2 Node 3 [A,B,A] [C,A,B] [B,C,C] [A,A,C] ← rows tagged by region │ │ │ │ └─────── re-partition by region (over the NETWORK) ───────┘ │ │ │ ▼ ▼ ▼ Node 0 Node 1 Node 2 all A's all B's all C's │ │ │ avg(A) avg(B) avg(C) ← now reduce locally ════════ this cross-network step is THE SHUFFLE ════════

Why is the shuffle the slow part? Because of everything you learned in the networking chapter: the network is orders of magnitude slower than reading from local memory or even local disk. During a shuffle, potentially the entire dataset gets serialized, sent across the wire to other nodes, and deserialized again. Map phases read local data and barely touch the network; a shuffle floods it. In real Spark jobs, the shuffle is overwhelmingly the most common reason a job is slow or runs out of memory.

OperationNeeds a shuffle?Why
Filter / map / per-row transformNoEach node uses only its own rows.
GROUP BY on the partition keyNoEach group already lives on one node.
GROUP BY on a different columnYesGroup members are scattered; must regather.
Join on the partition keyNo (or cheap)Matching rows are co-located.
Join on a different keyYesMatching rows live on different nodes.
Global ORDER BYYesA total order needs all data compared together.
Minimizing shuffles — the senior move

Much of distributed-data performance tuning is really shuffle reduction: partition data by the key you join and group on most, filter early so less data moves, and pre-aggregate on each node before the shuffle (a "combiner" — count locally first, then sum the small counts). When we reach Spark in a later course, "is this operation triggering a shuffle?" will be the first question you ask of any slow job. File this away now — it's the single highest-leverage idea in the chapter.

Replication — copies for safety and speed

Partitioning answers "where does each piece of data live?" — but it leaves a glaring problem. If Node 2 holds the only copy of its slice and Node 2's disk dies, that data is gone. With more machines, the chance that some machine fails on any given day goes up, not down. So distributed systems keep replicas: multiple copies of each partition on different nodes.

Replication buys two distinct things:

  • Durability / availability — if one node dies, a copy on another node takes over and no data is lost. The system survives hardware failure, which at cluster scale is a when, not an if.
  • Read scaling — many copies means many nodes can serve reads of the same data at once, multiplying read throughput.

The most common arrangement is leader/follower (also called primary/replica): one replica is the leader that accepts writes; the others are followers that copy the leader's changes and serve reads.

WRITE │ ▼ ┌─────────┐ │ LEADER │ (accepts all writes) └────┬────┘ replicate │ replicate ┌───────┴───────┐ ▼ ▼ ┌─────────┐ ┌─────────┐ │FOLLOWER │ │FOLLOWER │ (serve reads; stand by to take over) └─────────┘ └─────────┘ ▲ ▲ READ READ
⚠ Copies aren't free

The instant you have more than one copy, you have a new and genuinely hard problem: keeping the copies in sync. When a write hits the leader, the followers are momentarily behind — so a read from a follower can return stale data, and if the leader dies before a write reaches a follower, that write can be lost. This is the heart of consistency, and it's the entire subject of the next chapter. For now, just hold the tension: replication is what makes a cluster reliable, and it's also what makes a cluster confusing.

The fundamental tension

Step back and notice the shape of everything above. Adding machines gives you more storage and more throughput — but every benefit comes paired with a cost, and the cost is almost always the network. Partitioning spreads work, but querying across partitions forces a shuffle over the network. Replication protects you, but syncing copies sends traffic over the network. More machines means more coordination, and coordination is slow because the machines can only talk over that slow, unreliable link.

This is the law to carry forward: in distributed systems, the network is the bottleneck. A single machine moves data between CPU, memory, and disk at speeds the network can't touch. The art of distributed data is arranging your data and your queries so that as much work as possible stays local, and as little as possible has to cross the wire.

Scale up (one big machine)Scale out (many machines)
Capacity ceilingHard physical limit — biggest box you can buyEffectively unlimited — add nodes
Cost curveExponential — top-tier hardware priced steeplyRoughly linear — commodity boxes
Code complexitySimple — it's just one machineHard — partitioning, shuffles, replication, failures
Failure modelOne machine = one point of failureSurvives node loss (via replication)
Main bottleneckMemory / disk / CPU of that boxThe network between nodes
Best when…Data fits comfortably on one machineData or compute exceeds any single machine
The honest takeaway

Scaling out isn't "better" — it's a trade you make only when you must. You exchange the simplicity of one machine for the capacity of many, and you pay for it in coordination cost and complexity. A surprising amount of "big data" fits on one beefy machine with a tool like DuckDB; reach for a cluster when the data genuinely outgrows the box, not before.

✓ Check yourself

  • Can you explain the difference between scaling up and scaling out, and one wall each hits?
  • What makes a good partition key — and what is skew?
  • Which is embarrassingly parallel: a filter, or a group-by on a non-partition column? Why?
  • Can you say in one sentence what a shuffle is and why it's slow?
Exercise — Spot the shuffle, then fix it

GridDP's telemetry table is spread across 10 machines, partitioned by date (each day's rows hashed to a node). Your analytics team runs this every morning:

query
SELECT customer_id, AVG(voltage)
FROM telemetry
GROUP BY customer_id;

(a) Explain why this query forces a shuffle. (b) Propose a better partition key for this workload, and name the trade-off it introduces.

Try it before reading on.

(a) Why a shuffle happens. The data is partitioned by date, but the query groups by customer_id. A single customer's rows are spread across every day, so they're scattered across all 10 machines. To compute one average per customer, all of a customer's rows must end up together on one node — so the cluster has to re-partition the entire table by customer_id, sending rows across the network. That cross-machine movement is the shuffle, and on a full telemetry table it's the slow, expensive part of the job.

(b) A better key. Partition by customer_id instead. Then every customer's rows already live together on one node, the GROUP BY customer_id becomes a purely local map step on each node (no shuffle), and the cluster only combines small per-customer partials at the end — embarrassingly parallel and fast.

The trade-off. You can only partition by one key at a time, so optimizing for this query may hurt others. Partitioning by customer_id means a query that scans "all rows from yesterday" can no longer hit a single date-partition — it now has to touch all 10 nodes. And if a few large customers dominate the data, partitioning by customer_id reintroduces skew. The right choice depends on which queries matter most — which is exactly why choosing a partition key is a design decision, not a default.

Next

We saw that replication keeps a cluster alive but quietly raises the hardest question in the field — what happens when the copies disagree. → Distributed Systems II — Consistency & Delivery