all_lessons / data_intensive_systems / 18 · batch processing lesson 19 / 35 · ~15 min

Part 8 · Derived history

Batch Processing: MapReduce, Joins, Materialization, and Recompute

The failure part (lesson 17) closed the question of keeping a single mutable dataset correct under partial failure, unreliable clocks, consensus, and verification — the source of truth, defended. But almost nothing a product shows you is the raw source of truth. A search index, a recommendation table, a feature store, a daily metrics dashboard, a model's training set — these are all derived from the source data by a computation, and they go stale the moment the source changes. This part is about derived history: recompute a whole view from the log of facts, deterministically, whenever the source moves. This first lesson takes the simplest case: the input is bounded — a finite snapshot you can scan end to end — and you transform all of it at once into a new output. That is batch processing, and its defining superpower is that you can throw the output away and recompute it from the input deterministically.

DDIA source
Arc drawn from Martin Kleppmann, Designing Data-Intensive Applications, Chapter 10 (Batch Processing) — the Unix philosophy, MapReduce, the sort-merge vs broadcast join distinction, skew, and the deterministic-recompute argument. Original synthesis; the training-set / feature-precompute framings are ours.
Linear position
Prerequisite: Lesson 11 (partitioning by key, hot keys) — the shuffle is a repartition-and-sort, so we reuse it rather than re-deriving it. A hand-wave at the ordered durable log (lesson 03) helps too, since batch reads an immutable input snapshot.
New capability: Take a bounded dataset and derive an output from it — a join, an aggregate, a training table, an index — at cluster scale, choosing the join strategy by the size of the inputs, handling a skewed key, and reasoning about why a failed task can simply retry.
The plan
Five moves. (1) The model: bounded input → deterministic derived output, and the Unix-pipeline idea that scales out to MapReduce's map → shuffle → reduce. (2) The shuffle, tied back to partitioning (lesson 11) — it is the expensive all-to-all, and it is where the cost lives. (3) Joins in batch: why a naive per-record database lookup is catastrophic (a worked number: ~11.6 days), versus a sort-merge join or a broadcast hash join. (4) Skew — one hot key (lesson 11 again) wrecking a single reducer — and what to do about it. (5) Why deterministic recompute is the whole point: failures retry, bug fixes reprocess history, and this is exactly how you build a training set. Then the hand-off to unbounded input — streams.

1 · The model: bounded input, deterministic output

Batch processing is a computation over a bounded input: a finite dataset that already exists in full when the job starts — yesterday's event log, a snapshot of a table, a directory of files. The job reads all of it, transforms it, and writes a new output dataset. The defining contrast is with stream processing (lesson 19), where the input is unbounded — events keep arriving forever and the job never finishes. Bounded means the job has a clear start and a clear end, and crucially, the input does not change while the job runs: it is an immutable snapshot. That immutability is what makes everything else possible.

The intellectual ancestor is the Unix pipeline. A command like cat log | grep ERROR | sort | uniq -c | sort -rn is already a batch job: each tool reads input, does one transformation, and writes output. They compose freely because every tool speaks the same uniform interface — lines of text on stdin/stdout — and none of them mutates its input. Re-run the whole pipeline on the same file and you get the same answer.

MapReduce takes that exact philosophy and scales it across thousands of machines. The uniform interface becomes key-value records in files, and the computation is split into three phases:

1Map — run a function on each input record independently, emitting zero or more (key, value) pairs. This is the "extract and transform" step: pull the join key out of a record, parse a log line, tag a value. Embarrassingly parallel — every input partition maps on its own machine with no coordination.
2Shuffle — partition all the emitted pairs by key and sort them, so that every pair with the same key lands on the same machine, grouped together. This is the only step that moves data across the network, and it is the expensive one.
3Reduce — for each key, run a function over all the values that share that key (now conveniently gathered and sorted), emitting the aggregated output: a sum, a count, a joined row, a merged list.

Map is the narrow, local, parallel part; reduce is the per-key aggregation; the shuffle in the middle is the all-to-all data movement that connects them. Everything hard and expensive about batch processing lives in that middle step.

Where the input lives: a distributed filesystem and data locality

MapReduce reads its bounded input from a distributed filesystem — the canonical one is HDFS (the Hadoop Distributed File System; cloud object stores like S3 play the same role today). The idea is to spread one logical file across many machines' local disks while presenting it as a single file. A large file is split into fixed-size blocks (commonly 128 MB), and each block is replicated onto several nodes (typically 3) so the data survives a disk or machine failure — the same redundancy argument as replication in Part II, applied to a filesystem.

That layout enables the principle that makes MapReduce scale: data locality, or “bring the compute to the data.” Rather than ship gigabytes of input across the network to wherever a worker happens to be free, the scheduler does the reverse — it places each map task on a machine that already holds a replica of that block, so the map reads from a local disk and nothing crosses the network. Network bandwidth is the scarce resource in a cluster; moving the (small) code to the (large) data instead of the data to the code is what keeps the read phase cheap. Only the shuffle, which genuinely must move data, pays the network cost — which is exactly why the next section is about shrinking it.

Bring compute to the data — scheduler co-locates map task with a block replica file = block A | block B | block C | ... (split into 128 MB blocks, each replicated x3 across nodes) node 1 node 2 node 3 [block A] [block B] [block A] <- replicas on local disks [block C] [block A] [block B] | | | run map(A) here run map(B) here run map(C) here <- task scheduled where (local read, (local read) (local read) its block already lives no network) = data locality

2 · The shuffle is a repartition, and it is the tax

You have already met the shuffle under another name. In lesson 11 we partitioned a dataset by a key so that each node owns a slice; reads and writes for a key route to the node that owns that key's partition. The shuffle is exactly that operation, performed on the fly: the mappers' output is repartitioned by the reduce key, so all records for a given key end up co-located on one reducer. Map-side filtering and projection (dropping rows and columns you don't need) are "narrow" — each output partition depends on one input partition. The shuffle is "wide" — every reducer pulls from every mapper. That all-to-all pattern is why it dominates the cost.

MapReduce dataflow — map (local) -> shuffle (all-to-all) -> reduce (per key) input split 1 input split 2 input split 3 | | | [map] [map] [map] <- parallel, no coordination emits (k,v) emits (k,v) emits (k,v) | | | +--------+--------+--------+--------+ | SHUFFLE | <- partition by key + sort: partition by hash(key), sort each partition the only cross-network step +--------+--------+--------+--------+ | | | all k in P0 all k in P1 all k in P2 <- one partition's keys, grouped [reduce] [reduce] [reduce] | | | output 0 output 1 output 2 <- written durably to files Cost of the shuffle = bytes moved over the network + disk spill to sort + serialization + the slowest reducer (straggler/skew).

This is why every batch optimization you have ever heard of is really "shrink the shuffle": filter early (drop rows before they cross the network), project early (carry only the columns you need), pre-aggregate on the map side (a "combiner" that sums partial counts locally so each key ships one value instead of thousands), and pick the join strategy that avoids the shuffle entirely when you can — which is the next section. When a Spark job is slow, the diagnostic question is always: is it scan-bound (reading input), shuffle-bound (moving data), skew-bound (one reducer doing all the work), or output-bound (writing results)? The answer picks the fix.

3 · Joins in batch: never do a per-record lookup

A join combines two datasets on a shared key: attach each event to the user who generated it, attach each click to the ad that was shown, attach a training label to the feature row it belongs to. The naive way — the way that feels obvious if you come from request-serving code — is: scan the big dataset, and for each record, look up the matching row in a database. This is a disaster at batch scale, and seeing exactly why is the heart of this lesson.

Worked number — why the naive per-record lookup is catastrophic
You have an activity log of 1,000,000,000 (one billion) events, and you want to join each one to its user profile by doing a remote lookup against the user database. A remote key-value lookup that misses cache and hits disk costs on the order of 1 ms (random I/O: seek, fetch, return over the network). Done one at a time:

1,000,000,000 lookups × 1 ms = 1,000,000,000 ms = 1,000,000 s ≈ 11.6 days.

And that is the happy path. Each lookup is a random access — the disk head (or SSD page) jumps to an unrelated location every time, defeating sequential read-ahead. Worse, the job has no fault tolerance: it is hammering a live production database that also serves real users, so you cannot retry the whole thing freely, and if the job dies at hour 200 you have no clean way to resume. Batch jobs must not depend on a remote mutable service per record.

The batch answer is to bring both datasets into the job and join them with sequential access instead of random lookups. The strategies fall into two families, split by where the join happens. A reduce-side join does the matching after the shuffle, in the reducer, and works for any input sizes. A map-side join does the matching inside the mapper and skips the shuffle of the big side entirely — but only when the inputs have a shape that allows it. Which one wins depends on the relative sizes.

Reduce-side: the sort-merge join (the general case, both sides shuffled)

The workhorse reduce-side strategy is the sort-merge join, and it uses the shuffle itself. Map over both inputs — the billion events and the user profiles — and emit each record keyed by the join key (the user id). The shuffle partitions and sorts both sides by that key, so when a reducer receives a partition, it sees all the events for a given user and that user's profile record gathered and sorted right next to each other. The reducer walks the sorted run once, merging matching keys. No random lookups, no live database — just two big sequential scans, a sort, and a linear merge. The cost is one full shuffle of both datasets, but it scales: a billion events is a sequential sort over data laid out on disk, not a billion seeks. Where the naive version took ~11.6 days, a sort-merge over the same billion rows is a handful of sequential passes — hours, not days, and infinitely retryable because it touches only immutable input files.

One subtlety makes the merge clean: secondary sort. Within a single key the reducer wants the records to arrive in a controlled order — typically the one profile record first, then the user's many events — so it can read the profile into a variable and then attach it to each event in a single forward pass, never buffering the whole group. The framework arranges this by sorting not just on the join key but on a composite key (join key, then a small table tag that marks “profile” as sorting before “event”), while still partitioning on the join key alone so the group stays together. That extra ordering within a key is the secondary sort.

Map-side: broadcast and partitioned hash joins (skip the shuffle)

Map-side joins avoid the shuffle when the input shape allows it. The common case is the broadcast hash join: if one side is small enough to fit in memory — say the billion events join against a 2-million-row user table that is a few hundred MB — you do not shuffle the big side at all. Load the small side into an in-memory hash table and ship a copy of that hash table to every mapper; each mapper then scans its slice of the big input and probes the local hash table for each record — an in-memory hash lookup, nanoseconds, no network. The big side is never shuffled. This is dramatically faster than a sort-merge join when it applies; the constraint is purely that the broadcast side must fit in memory on every worker.

The other map-side case is the partitioned hash join (a map-side merge join): if both inputs are already partitioned the same way — same number of partitions, partitioned on the join key, e.g. because an earlier job laid them out that way — then partition i of one side can only match partition i of the other. A mapper handling partition i reads both sides' partition i locally and joins them with no shuffle and no broadcast, because the matching keys are already co-located. The win is real but it depends on the inputs being co-partitioned in advance; otherwise you are back to paying for the shuffle with a sort-merge join.

Join strategyBuysCosts
Naive per-record DB lookupTrivial to writeRandom I/O, ~11.6 days for 1e9 rows, no fault tolerance, hammers prod
Sort-merge join (reduce-side)General; works at any size; sequential I/O; retryableA full shuffle of both sides (sort + network); sensitive to skew
Broadcast hash join (map-side)No shuffle of the big side; in-memory probes; fastestOnly works when one side fits in memory on every worker
Partitioned hash join (map-side)No shuffle and no broadcast; joins partition-to-partition locallyRequires both sides co-partitioned (same key, same partition count) in advance

4 · Skew: one hot key wrecks one reducer

Lesson 11 warned about the hot key — a single partition key with vastly more data than the others, turning one node into a bottleneck. The shuffle inherits this problem exactly, because the shuffle is a partitioning. If you join events to users by user_id and one "user" is actually a shared service account that generated 30% of all events, then the reducer responsible for that key's partition has to process 30% of the data alone. Every other reducer finishes in minutes; that one runs for hours. The whole job's wall-clock time is the slowest reducer — a straggler — so a single hot key can make a 1,000-machine cluster as slow as one machine.

Worked number — the skewed reducer
1,000,000,000 records, 100 reducers. If keys were uniform, each reducer handles 10,000,000 records — call it 10 minutes. Now suppose one hot key holds 300,000,000 records (30%). That reducer must process 300M records — 30 × the average — so it runs ~5 hours while the other 99 reducers sit idle after 10 minutes. Adding machines does nothing: the hot key cannot be split across reducers by the default hash partitioning. Effective parallelism collapsed from 100-way to roughly 1-way for the tail.

The fixes mirror lesson 11's hot-key remedies, and for joins they have specific names. The general technique is the skewed join (also called a sharded or replicated join): you spread the hot key's records across many reducers and replicate the other side of the join so each shard can still find its matches. Concretely — salt the hot key on the big side: append a random suffix (user_id#0user_id#9) so its records split across, say, 10 reducers instead of crashing into one. But now those 10 reducers each hold only a slice of the events for that user, and the single matching profile record must reach all of them — so you replicate the small/other side of the hot key to every shard (emit the profile as user_id#0 through user_id#9). Each shard then joins its slice of events against the replicated profile; a second pass combines the partial results if the output must be regrouped. You trade an extra stage and some duplication of the small side for real parallelism on the hot key. Map-side pre-aggregation (a combiner) is the cheaper fix when the join is followed by an aggregation: it reduces the volume before the shuffle so the hot reducer receives summaries, not raw rows. And if the hot side is small, a broadcast join sidesteps skew entirely, since there is no shuffle of the big side to concentrate. Modern engines also detect skew at runtime and split the offending partition automatically.

5 · Materialization, and why deterministic recompute is the whole point

A batch job writes its result down — to files, a table, an index. Materialization means storing a computed result so future reads are cheap, rather than recomputing it on every read. The output of one MapReduce stage is materialized to durable storage before the next stage reads it. This is the conservative choice MapReduce makes, and it has a famous downside (next), but it buys something precious: because every intermediate result sits on disk, a failed task can simply re-run.

Here is the crown jewel of batch processing. The input is an immutable snapshot, and the computation is a pure function of that input. Therefore the output is a deterministic recompute: re-run the job on the same input and you get bit-for-bit the same output. Three enormous consequences follow:

The bill for all this is latency. You wait for all the input to exist (you cannot start until the snapshot is complete) and you wait for the whole job to finish before any output is usable. A nightly job's output is up to a day stale. Batch trades freshness for the simplicity and correctness of recompute — and lesson 19 is, in large part, the story of clawing back that latency.

Dataflow engines: keep MapReduce's recompute, cut its disk writes

Classic MapReduce materializes every stage's output to the distributed filesystem (often replicated 3×) before the next stage starts. For a multi-stage job that is a lot of slow, redundant disk I/O — and the next stage cannot begin until the previous one fully lands. Modern dataflow engines (Spark, Flink in batch mode, Tez) keep the same model — a directed graph of map/shuffle/reduce-like operators over immutable input — but optimize the execution: hold intermediate results in memory and pipeline them between stages instead of round-tripping every stage through disk, and plan the whole operator graph at once so they can fuse narrow operations and pick join strategies globally. They keep deterministic recompute for fault tolerance — Spark, for instance, tracks each partition's lineage (the operations that produced it) so a lost partition is rebuilt by replaying just those operations from the immutable input — while avoiding MapReduce's habit of writing every stage to disk.

Iterative graph processing: where plain MapReduce is the wrong tool

Some batch computations are not one pass but many rounds over the same data: PageRank, shortest paths, connected components, label propagation — algorithms that walk a graph, updating each vertex from its neighbors, and repeat until the values stop changing (converge). Plain MapReduce is poor at these. Each round is a separate MapReduce job, and a MapReduce job's only way to pass state to the next round is to write the entire dataset back to the distributed filesystem and read all of it again. So a 30-iteration PageRank re-reads and re-writes the whole graph 30 times, even though most vertices changed little between rounds — the I/O dwarfs the actual computation.

The purpose-built model is Pregel, an instance of the bulk synchronous parallel (BSP) model (Google's Pregel; open-source Apache Giraph; also the GraphX and GraphFrames APIs). The graph's vertices are partitioned across machines and kept in memory between rounds. Computation proceeds in supersteps: in each superstep every vertex runs the same small function — it reads the messages its neighbors sent it last round, updates its own value, and sends messages to its neighbors for the next round. A synchronization barrier separates supersteps (every message from round n is delivered before round n+1 begins — that is the “bulk synchronous” part), and a vertex that has nothing left to do votes to halt; when all vertices have halted, the job is done. Because the graph stays resident and only the (small) messages move, BSP avoids MapReduce's per-round full re-read — the difference between shipping a few updates and rewriting the whole graph every iteration. The same fault-tolerance idea still applies: periodic checkpoints of vertex state let a failed superstep replay from the last checkpoint rather than from scratch.

ML-infra tie: this is how you build a training set

Batch is the engine room of offline ML. Building a training set is a batch join: scan the event log (the labels — clicks, conversions, ratings), join each label to the feature values as of the time the event happened (a join by entity id and time), and materialize the result as a flat training table. Recompute it when the feature definition changes — fix how "7-day click rate" is computed, re-run over the archived event log, get a corrected training set, no production data touched. Precomputing features for a feature store, rebuilding an embedding index, generating offline evaluation metrics, backfilling a new feature across all history — every one of these is a bounded-input batch job whose superpower is that you can re-derive it. The "derived data" idea sketched here is exactly what lesson 20 turns into a discipline.

Failure modes

  • Per-record remote lookup. Joining by querying a live DB per row: random I/O, days of runtime, no retryability, and it overloads the production store. Bring both sides into the job instead.
  • Skewed join key (straggler). One hot key sends 30% of rows to one reducer; the job's runtime is that reducer's. Symptom: 99 tasks done, one running for hours. Salt the key, pre-aggregate, or broadcast.
  • Broadcast side too big. The "small" table outgrew memory; the broadcast join OOMs every worker. Re-check sizes; fall back to sort-merge above the memory threshold.
  • Non-deterministic job. The map function reads wall-clock time, a random seed, or a mutable external service — so a retried task produces a different answer than the one it replaced, silently corrupting output and breaking the recompute guarantee.
  • Mutating the input. Writing back into the dataset the job is reading destroys immutability; now a retry sees different input and the output is no longer reproducible.

Decision checklist

  • Is the input truly bounded and immutable for the job's duration? If it keeps changing, you want a stream (lesson 19), not a batch.
  • For a join: does one side fit in memory on every worker? If yes, broadcast hash join; if no, sort-merge join. Never a per-record DB lookup.
  • Could any join key be hot (lesson 11)? Plan salting / pre-aggregation before the straggler bites.
  • Are map and reduce functions pure — no clock, no randomness without a fixed seed, no live external state — so a retry reproduces the result?
  • Did you filter and project before the shuffle to shrink the bytes that cross the network?
  • Do you record the input snapshot id and code version, so the output is reproducible and you can recompute after a bug fix?
  • Is the latency (wait for all input + whole job) acceptable for the freshness this output needs?

Checkpoint exercise

Try it
You must build a daily training table for a click-prediction model: join 2,000,000,000 impression events (each with a user_id and a timestamp) to a 5-million-row user-features table (a few hundred MB), labeling each impression with whether it was clicked. (a) Why is "for each impression, query the feature DB for that user" a non-starter — give the order-of-magnitude runtime and two correctness/operational reasons. (b) Pick a join strategy and justify it from the input sizes; say which side gets loaded where. (c) The user_id for an internal test account appears on 15% of impressions — name the failure it causes and one concrete fix. (d) Three weeks later you discover the label logic was wrong. Explain, in one sentence, why batch lets you correct every past training table without touching the impression log.

Where this points next

Batch's great strength — bounded, immutable input you can scan whole and recompute — is also its hard limit. Real systems produce data continuously: clicks, sensor readings, log lines, profile edits stream in forever, and waiting for "all the input" before you can produce any output means your derived data is always hours stale. The next lesson lifts the boundedness assumption. Stream processing (lesson 19) treats the input as an unbounded, ordered log of events, processes each as it arrives, and reframes batch's concepts for an endless world: the immutable input log becomes a change-data-capture (CDC) feed, the materialized output becomes a continuously-updated view, the shuffle becomes a stateful partitioned operator, and "wait for all the input" becomes the much subtler question of when is a window complete when events can arrive late. Many of the joins and aggregations you just learned reappear — now they must run forever.

Where is truth?
System of record: the bounded, immutable input snapshot — yesterday's event log or table dump on the distributed filesystem; the job never owns truth, only reads it. Copies / derived views: the materialized output — a join, an aggregate, a training table, an index — plus every intermediate stage written between map and reduce. Freshness budget: up to one full job period stale (a nightly job's output can be a day old); you wait for all input to exist and the whole job to finish. Owner: the pipeline that produces the output, pinned to an input snapshot id and a code version. Deletion path: remove or filter the record in the source, then re-derive — the output carries no independent truth to delete. Reconciliation / repair: deterministic recompute — re-run the pure function over the same immutable snapshot; a failed task simply retries, a bug fix reprocesses history. Evidence it is correct: same input snapshot + same code → bit-for-bit identical output, so a recompute reproduces (or audits) any prior run.
Takeaway
Batch processing turns a bounded, immutable input into a derived output by a deterministic computation — the Unix-pipeline philosophy (small composable tools, uniform interface) scaled out to MapReduce's map → shuffle → reduce. The shuffle is a repartition-and-sort by key (lesson 11) and is the expensive all-to-all step, so you filter, project, and pre-aggregate to shrink it. Joins must never be per-record remote lookups — one billion lookups at ~1 ms is ~11.6 days of random I/O against a live DB with no fault tolerance — so you bring both sides into the job: a sort-merge join (shuffle both sides, sorted by key, merge) for the general case, or a broadcast hash join (ship the small side to every mapper, probe in memory, no shuffle) when one side fits in memory. A single hot key (lesson 11) skews one reducer into a straggler; salt it, pre-aggregate, or broadcast. The whole payoff is deterministic recompute: because the job is a pure function of an immutable snapshot, a failed task just retries, a bug fix can reprocess all of history, and a derived training set / feature table / index is reproducible — at the cost of latency, since you wait for all the input and the whole job. Dataflow engines (Spark, Flink-batch) keep this model but hold intermediates in memory instead of writing every stage to disk. This is the foundation of the derived-data discipline lesson 20 generalizes.

Interview prompts