The previous lesson ended with a forty-line coordinator that split chunks of orders.jsonl among processes, added up partials and requeued a dead worker's task. It worked on a laptop; on a thousand-node cluster you would have to solve, for every job, data locality, the shuffle between nodes, failure detection, speculative execution and atomic output. MapReduce is the programming model with which Google (Dean and Ghemawat, 2004) solved all of that once and for all, so that the programmer writes just two functions, map and reduce, and the system takes care of the rest. Hadoop is its open-source implementation, together with HDFS (04-02) and the YARN resource manager, and for a decade it was synonymous with "big data". Hardly anyone writes MapReduce jobs by hand any more, but everything that came afterwards (Spark, Flink, distributed SQL engines) uses its vocabulary and its phases, and its design decisions (always write to disk, independent tasks, a master that reassigns) are still the baseline against which improvements are explained. This lesson presents the model using Kilometre Zero's sales by producer and by market, walks through the execution of a job inside YARN, and implements it three times: in Python with Hadoop Streaming, in Java as the canonical example, and as a local simulation of the shuffle so you can get your hands on what the framework hides.
Contents
- The programming model: map, shuffle & sort, reduce
- Combiner and partitioner
- Execution flow and fault tolerance
- Hadoop: HDFS, YARN and MapReduce v2
- Anatomy of a job: from
submitto_SUCCESS - Input and output formats
- Why MapReduce is slow and where it stands today
- Hands-on: sales by producer and market in Streaming, in Java and in a simulation
- Common Mistakes and Tips
- Exercises
- Conclusion
- The programming model: map, shuffle & sort, reduce
MapReduce borrows two names from functional programming and gives them a precise meaning for distributed data. Every job processes key/value pairs and goes through three phases:
- Map. The system divides the input into chunks (splits) and, for every record in every chunk, runs the function
map(k1, v1) → list of (k2, v2). The programmer decides what the intermediate keyk2is: it is the key they want to group by. For "sales by producer",mapreceives a line oforders.jsonland emits, for each line of the order,(producer, quantity × price). - Shuffle & sort. The system collects all the
(k2, v2)pairs from all the mappers, sends them to the reducer responsible for eachk2, and delivers them grouped by key and sorted:(k2, [v2, v2, v2, ...]). This is the phase the programmer does not write and the one that costs the most (05-01, section 5). - Reduce. For each key,
reduce(k2, list of v2) → list of (k3, v3). For sales, it adds up the list and emits(producer, total).
flowchart LR
subgraph Input[HDFS: orders.jsonl]
B1[block 1]
B2[block 2]
B3[block 3]
end
B1 --> M1[map 1<br/>montblanc 12.50<br/>la-vega 7.80<br/>roble-alto 58.80]
B2 --> M2[map 2<br/>montblanc 12.60<br/>la-vega 6.40]
B3 --> M3[map 3<br/>roble-alto 29.40<br/>montblanc 25.00]
M1 --> SH[[shuffle & sort<br/>group by key]]
M2 --> SH
M3 --> SH
SH --> R1[reduce A<br/>la-vega: 7.80, 6.40 → 14.20<br/>montblanc: 12.50, 12.60, 25.00 → 50.10]
SH --> R2[reduce B<br/>roble-alto: 58.80, 29.40 → 88.20]
R1 --> O1[part-r-00000]
R2 --> O2[part-r-00001]
The example MapReduce is always introduced with is the word count: map emits (word, 1) for every word in a line and reduce adds up the ones. It is the same skeleton as ours with "word" swapped for "producer" and "1" for "amount", which is why MapReduce is said to be a generalised word count: any computation that can be expressed as "extract a key from each record and aggregate by key" fits directly; those that need several chained groupings (sales by producer and then the top-selling producer per market) are expressed as several jobs in a chain, each one reading the previous one's output from HDFS, which is the source of the slowness in section 7.
Three properties of the model explain its success:
- The functions are local.
mapsees one record;reducesees one key and its values. Neither needs to know how many nodes there are or where the data is. The programmer writes business logic, not distribution. - The tasks are independent. Each
mapover a split and eachreduceover a partition of keys can run on any node, in any order, and be repeated: this is the deterministic re-execution of 05-01. - The shuffle is generic. A single mechanism (partition by key, sort, transfer, merge) serves every job, and the system can optimise it for everyone.
- Combiner and partitioner
The basic model has two hooks the programmer can replace:
Combiner. A mapper processing a 128 MB block of orders.jsonl emits about 400,000 pairs, but there are only three distinct producers: 400,000 pairs will travel over the network so that the reducer can add up 133,000 values per key. The combiner is a reduce function local to the mapper that runs over each map's output before the shuffle: it groups the mapper's pairs by key and reduces them to one per key. With it, the mapper sends 3 pairs instead of 400,000. This is the "reduce before you move" of 05-01. It is only valid when the reduction is associative and commutative, and has the same input and output type: a sum qualifies; an average does not, unless you carry (sum, count). Hadoop does not guarantee that the combiner will run (it may run zero, one or several times over the same data), so the result must be identical with or without it.
Partitioner. It decides which reducer each key goes to: partition(k2) = hash(k2) mod number_of_reducers by default. You replace it when you want to control the distribution: sending all the keys in a range to the same reducer (to obtain globally sorted output), or separating two kinds of key into two output files. In the hands-on part of section 8 we emit two families of keys in the same job (p:<producer> and m:<market>) and a partitioner sends them to different reducers, so that part-r-00000 holds sales by producer and part-r-00001 sales by market. The partitioner is also the place where skew is tackled: a partitioner that knows the hot keys can spread them over several reducers, with a second pass to combine.
| Component | Who writes it | Where it runs | What for |
|---|---|---|---|
map |
Programmer | On the split's node (locality) | Extracting the key and value from each record |
| Combiner | Programmer (optional; often the same reduce) |
On the mapper's node, over its output | Reducing the volume of the shuffle |
| Partitioner | Programmer (optional; hash by default) | In the mapper, when writing the output | Deciding which reducer receives each key |
| Shuffle & sort | Framework | Mappers (sort and serve) and reducers (fetch and merge) | Grouping by key |
reduce |
Programmer | On any node with a free container | Aggregating the values of each key |
- Execution flow and fault tolerance
The original paper describes an architecture with one master and many workers, which Hadoop keeps under other names (section 4):
- The client submits the job: the code (a
jaror scripts), the configuration and the input and output paths. - The master asks HDFS for the input's blocks and creates one map task per split, noting which nodes each block lives on. It also creates R reduce tasks, with R configured by the user.
- It assigns map tasks to free workers, preferring the one that has the block on its disk; failing that, one in the same rack; failing that, any.
- Each map writes its output, partitioned and sorted, to the worker's local disk, and tells the master where it is.
- When all the maps have finished, the master assigns the reduce tasks; each reducer fetches its partition of every map's output over the network, merges it while preserving the order, and runs
reducekey by key. - Each reducer writes its output file to HDFS. When they have all finished, the job is complete.
Fault tolerance rests on what we already know:
- Worker failure. The master detects it through missed heartbeats. The map tasks completed on that node are re-run even though they had finished, because their output was on the local disk of the dead node and the reducers that had not yet fetched it need it. Completed reduce tasks are not re-run: their output is in HDFS. Tasks in progress go back to the queue. Exactly what
work_queue.pydid, with the added subtlety of the local intermediate output. - Task failure (an exception in the code, a corrupt record). It is retried up to four times (
mapreduce.map.maxattempts); if it keeps failing, the job fails (or, if so configured, a percentage of failed tasks is tolerated in order to skip poison records, the batch version of the DLQ from 02-05). - Stragglers. Speculative execution (05-01): when the phase is close to finishing, a copy of the slow tasks is launched.
- Atomic output. Each task writes to a temporary directory (
_temporary/attempt_.../) and only when the task finishes does the framework do the commit: it renames the task's file topart-r-00001in the output directory. If two attempts of the same task (re-execution, speculation) finish, only the first one commits. When the job ends, an empty_SUCCESSfile is created as a signal to whoever consumes the output (the sensor in 05-05 will wait for it). It is theos.replaceof 05-01, institutionalised. - The master as a single point. In the original design, if the master went down, the whole job was aborted and the client relaunched it: this was accepted as a reasonable trade-off because a master is one machine among thousands and a job can be relaunched. Hadoop 2 improved on it by creating one master per job (the ApplicationMaster of the next section) that YARN can restart, and the ResourceManager has high availability with ZooKeeper (03-03).
- Hadoop: HDFS, YARN and MapReduce v2
Since version 2, Hadoop has been three stacked projects:
| Layer | Project | What it does | Lesson |
|---|---|---|---|
| Storage | HDFS | Files in replicated 128 MB blocks; a NameNode with the metadata, DataNodes with the blocks; exposes the locality of every block | 04-02 |
| Resource management | YARN (Yet Another Resource Negotiator) | Shares out the cluster's CPU and memory among applications in the form of containers; independent of MapReduce | This lesson |
| Compute | MapReduce v2 | A YARN application that implements the model of section 1. Spark, Flink and Tez are other YARN applications | This lesson, 05-03 |
In Hadoop 1, the MapReduce master (the JobTracker) did two things at once: managing the cluster's resources and coordinating every job. That made it a bottleneck (a limit of about 4,000 nodes) and tied the cluster to MapReduce: nothing else could run on it. YARN separated the two roles:
- ResourceManager (RM). One per cluster (with high availability). It knows each node's resources and arbitrates between applications with a scheduler (the Capacity or Fair Scheduler, with per-team queues:
analyticshas its own queue with 40% of the cluster guaranteed). It knows nothing about maps or reduces. - NodeManager (NM). One per node. It reports its available CPU and memory to the RM, launches and supervises containers (a process with a CPU and memory quota, implemented today with cgroups) and serves the maps' intermediate output to the reducers (the shuffle service).
- ApplicationMaster (AM). One per application (per MapReduce job), running in an ordinary container. It is the master of section 3: it negotiates containers with the RM, asks the NMs to launch tasks in them, tracks their progress and re-runs the failed ones. If the AM dies, the RM restarts it (up to
yarn.resourcemanager.am.max-attemptstimes) and the new AM recovers the progress from the log of completed tasks. - Container. The unit of allocation: "1 core and 2 GB on node
dn-07". Each map or reduce task runs as a JVM inside a container.
sequenceDiagram
participant C as Client (hadoop jar)
participant RM as ResourceManager
participant NM1 as NodeManager dn-01
participant AM as ApplicationMaster
participant NM2 as NodeManager dn-07
C->>RM: submitApplication(jar, conf, splits)
RM->>NM1: launch container for the AM
NM1->>AM: start MRAppMaster
AM->>RM: register; request 3 map containers (preference: nodes holding the blocks)
RM-->>AM: containers allocated (dn-07, dn-12, dn-03)
AM->>NM2: launch map task over split 1
NM2-->>AM: progress, map finished (output on local disk)
AM->>RM: request 2 reduce containers
RM-->>AM: containers
AM->>NM2: launch reduce; fetch outputs via shuffle service
NM2-->>AM: reduce completed, commit to HDFS
AM->>RM: application finished; releasing containers
RM-->>C: state FINISHED / SUCCEEDED
The architectural consequence of YARN is that the cluster is a shared resource where different applications live side by side (a MapReduce job from analytics, a Spark job from the recommendations team, a long-running service), and that the compute model is interchangeable: when we launch Spark on YARN in 05-03, the Spark driver will be the ApplicationMaster and the executors will run in containers, with the same RM arbitrating. Kubernetes plays that same role of generic resource manager today (07-05).
- Anatomy of a job: from
submit to _SUCCESS
submit to _SUCCESSIt is worth following closely what happens inside a task, because the names reappear in Hadoop's web UI, in the job counters and in explanations of why a job is slow.
Map side.
- The
InputFormat(section 6) computes the splits: by default, one per HDFS block, adjusted to the end of a line just aschunks_by_bytesdid in 05-01. With a 150 MBorders.jsonl, that is two splits, two maps. - The
RecordReaderhands records tomap: for text,(byte offset, line). mapemits pairs into a circular in-memory buffer (100 MB by default,mapreduce.task.io.sort.mb). When it is 80% full, a thread spills it to disk: it partitions by reducer, sorts by key within each partition, applies the combiner if there is one, and writes a spill file.- When the map finishes, the spill files are merged into a single one, partitioned and sorted, with an index that says where each partition begins. If there were several spills, the combiner is applied again during the merge.
Reduce side.
- Copy (fetch). As soon as a map finishes, each reducer asks the shuffle service of that map's NodeManager for its partition, over HTTP, with several threads in parallel. It does not wait for all the maps to finish before it starts copying (but it does before it starts reducing).
- Merge and sort. The fragments received, each one already sorted, are merged (in memory if they fit, on disk in rounds if they do not) into a single sequence sorted by key.
- Reduce. The sequence is scanned; every time the key changes,
reduce(key, iterator of values)is called. That is why the reducer receives an iterator, not a list: the values for one key may not fit in memory (Montblanc Dairy's 133,000 amounts without a combiner). - Commit. The output goes to
_temporary/, and on completion it is renamed topart-r-0000N. When the AM confirms that all the reduces have committed, it writes_SUCCESS.
The job counters summarise all of this. For a real job over one campaign day (250,000 events, 150 MB, 2 maps, 2 reduces), with and without a combiner:
| Counter | Meaning | Without combiner | With combiner |
|---|---|---|---|
Map input records |
Lines read by the maps | 250,000 | 250,000 |
Map output records |
Pairs emitted by map |
810,000 (two keys per order line) | 810,000 |
Map output bytes |
Size of those pairs | 19.4 MB | 19.4 MB |
Combine input records |
Pairs that went into the combiner | 0 | 810,000 |
Combine output records |
Pairs that came out | 0 | 14 (7 keys × 2 maps) |
Reduce shuffle bytes |
Bytes copied over the network to the reducers | 21.1 MB | 612 B |
Reduce input groups |
Distinct keys that reached reduce |
7 | 7 |
Reduce input records |
Values reduce iterated over |
810,000 | 14 |
Spilled records |
Pairs written to disk in spills (map + reduce) | 1,620,000 | 810,014 |
GC time elapsed (ms) |
Time spent in JVM garbage collection | 4,100 | 900 |
CPU time spent (ms) |
Total CPU across all tasks | 38,000 | 29,000 |
| Job duration | 71 s | 52 s |
Two things to read from this: the shuffle bytes drop by four orders of magnitude with the combiner, and even so the job takes 52 s for what sales_scatter_gather.py did in 1 s. That minute is the framework's fixed cost: starting the AM, requesting containers, launching four JVMs, writing spills and merging, committing to HDFS. A MapReduce job makes no sense below the gigabyte range, and that is the point Spark goes after.
- Input and output formats
The InputFormat decides two things: how the input is divided into splits and how the records in each split are read. The OutputFormat decides how the results are written.
| Format | Split | Record | Use |
|---|---|---|---|
TextInputFormat (default) |
Per block, adjusted to a line | (offset, line) |
JSONL, CSV, logs: our orders.jsonl |
KeyValueTextInputFormat |
Per block | (text up to the tab, the rest) |
Output of another Streaming job |
NLineInputFormat |
Every N lines | (offset, line) |
When each line is expensive (a URL to download) |
SequenceFileInputFormat |
Per block (sync markers) | Binary (key, value) |
Intermediate output between chained jobs |
| Avro, Parquet, ORC (libraries) | Per file block | Records with a schema; Parquet and ORC are columnar | Modern data lakes; Spark prefers them (05-03) |
CombineFileInputFormat |
Groups many small files into one split | Depends on the inner format | The hourly files in /km0/clicks/ |
Two practical warnings. First: a format is splittable only if you can start reading from the middle, and that also depends on the compression: gzip is not splittable (a 1 GB gzip file is a single split and a single map, however big it is), whereas bzip2, indexed LZO and the container formats (Avro, Parquet, ORC, SequenceFile) are. Second: HDFS and MapReduce suffer with small files (04-02): 10,000 files of 50 KB are 10,000 maps lasting an instant each, and the scheduling cost dominates; they have to be consolidated (CombineFileInputFormat, or better, a prior compaction step).
The output follows the same logic: TextOutputFormat writes key<TAB>value per line into one part-r-NNNNN per reducer; with LazyOutputFormat no empty files are created; MultipleOutputs lets a reducer write to several named files (sales by producer in producers-r-00000, by market in markets-r-00000). The number of output files is always the number of reducers, and that is the reason to choose it carefully: few reducers make for large files and long tasks; many, for small files that will be a problem for the next job.
- Why MapReduce is slow and where it stands today
The decisions that made MapReduce robust are the ones that make it slow:
- Everything goes through disk. Each map's output is written to local disk; the reducer copies it and writes it to disk again when merging; the reduce output goes to HDFS with three replicas. A job with one shuffle means at least four writes of the intermediate data volume. It was done this way so that any task could be re-run by reading its input from disk without depending on the memory of a node that may die.
- Jobs are chained through HDFS. A computation with several groupings (sales by producer, then a ranking per market, then a join with the catalogue) is three jobs, and between one and the next the complete output goes to HDFS with replication and the next job reads it back. Iterative algorithms (recommendations by gradient descent, PageRank, the BSP of 05-01) are ten or a hundred chained jobs, each one re-reading the entire dataset.
- Fixed cost per job and per task. Starting a JVM per task, negotiating containers, the commit. Tens of seconds that do not matter in a three-hour job and are everything in an interactive query.
- Rigid model. Only map and reduce; a join between two datasets has to be expressed by emitting both sides with the same key and telling them apart in the reducer (a reduce-side join), or by loading the small one into each mapper's memory (a map-side join, the forerunner of the broadcast join in 05-03). The framework does none of that for you.
Its place today is that of a historical foundation and mental model: the vocabulary (map, shuffle, reduce, combiner, partitioner, split, counters) is the one Spark and Flink use; YARN and HDFS are still in production at many companies as the resource and storage layers, with Spark on top; and Hive, the SQL data warehouse on Hadoop that Facebook created to avoid writing MapReduce by hand, still exists but runs its queries on Tez or Spark rather than on MapReduce, keeping its table catalogue (the metastore) as a central piece of many data lakes. Seeing a new MapReduce job in 2026 is rare; understanding how it worked is what lets you read a Spark plan or the Flink dashboard without surprises.
- Hands-on: sales by producer and market in Streaming, in Java and in a simulation
The job is the same in all three versions: read /km0/events/2026-09-14/orders.jsonl (the file that upload_events_hdfs.py left in HDFS in 04-02, or the one generated by sales_scatter_gather.py in 05-01), and produce total sales by producer and by market. To do it in a single job, the mapper emits two keys per order line, with a prefix: p:montblanc-dairy and m:girona. A partitioner sends the p: keys to reducer 0 and the m: keys to reducer 1, so the output ends up in two clean files.
8.1 Hadoop Streaming in Python
Hadoop Streaming lets you write the mapper and the reducer in any language that reads from standard input and writes to standard output. Hadoop launches the process, feeds it the records line by line and collects what it emits; the key and the value are separated by a tab.
#!/usr/bin/env python3
# km0/services/analytics/mapreduce/mapper.py
"""Hadoop Streaming mapper: for each order line it emits (p:<producer>, amount) and (m:<market>, amount)."""
import json, sys
for line in sys.stdin: # Hadoop delivers the split line by line
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
sys.stderr.write("reporter:counter:km0,corrupt_lines,1\n") # custom counter, visible in the UI
continue
if ev.get("type") != "order.created":
continue
market = ev["data"]["market"]
for ln in ev["data"]["lines"]:
amount = ln["quantity"] * ln["price"]
print(f"p:{ln['producer']}\t{amount:.2f}") # key <TAB> value
print(f"m:{market}\t{amount:.2f}")#!/usr/bin/env python3
# km0/services/analytics/mapreduce/reducer.py
"""Hadoop Streaming reducer (and combiner): adds up the values of each key.
Hadoop delivers the lines SORTED by key, so it is enough to detect the change of key.
"""
import sys
current_key, total = None, 0.0
for line in sys.stdin:
key, value = line.rstrip("\n").split("\t", 1)
if key != current_key: # change of key: emit the previous one
if current_key is not None:
print(f"{current_key}\t{total:.2f}")
current_key, total = key, 0.0
total += float(value)
if current_key is not None: # don't forget the last key
print(f"{current_key}\t{total:.2f}")The Streaming reducer does not receive (key, list of values) as in Java, but the sorted sequence of pairs: it is the script itself that detects the change of key. That is the reason the shuffle sorts rather than just groups: with sorted input, grouping means comparing with the previous line, with no memory needed. And since the reducer adds up, it also works as a combiner unchanged.
Before touching the cluster, a Unix pipeline reproduces the whole job, with sort playing the part of the shuffle:
$ cat events/2026-09-14/orders.jsonl | python3 mapper.py | sort -k1,1 | python3 reducer.py
m:girona 20.30
m:lleida 58.80
m:valencia 19.00
p:la-vega-farm 14.20
p:montblanc-dairy 25.10
p:roble-alto-winery 58.80(Over the three sample lines from 05-01: Anna in Girona bought 2 × 3.90 + 12.50 = 20.30; Mark in Lleida 6 × 9.80 = 58.80; Lucy in Valencia 3 × 4.20 + 4 × 1.60 = 19.00; and by producer, La Vega Farm 7.80 + 6.40 = 14.20, Montblanc Dairy 12.50 + 12.60 = 25.10, Roble Alto Winery 58.80.) This local test is worth its weight in gold: most errors in a Streaming job (a split that fails, a non-numeric value) show up here in a second rather than in a minute of failed job.
Launching it on the cluster from the docker-compose.yml of 04-02 (with YARN added: a resourcemanager and a nodemanager from the apache/hadoop image):
docker compose exec resourcemanager hadoop jar \
$HADOOP_HOME/share/hadoop/tools/lib/hadoop-streaming-*.jar \
-D mapreduce.job.name="km0 sales 2026-09-14" \
-D mapreduce.job.reduces=2 \
-D stream.map.output.field.separator='\t' \
-files mapper.py,reducer.py \
-mapper "python3 mapper.py" \
-combiner "python3 reducer.py" \
-reducer "python3 reducer.py" \
-partitioner org.apache.hadoop.mapred.lib.KeyFieldBasedPartitioner \
-D mapreduce.partition.keypartitioner.options=-k1.1,1.1 \
-input /km0/events/2026-09-14/orders.jsonl \
-output /km0/aggregates/2026-09-14/salesLine by line: -files copies the scripts to every container (the distributed cache: the code travels to the data); -combiner reuses the reducer locally in each map; -partitioner with KeyFieldBasedPartitioner and the option -k1.1,1.1 partitions by the first character of the key (p or m), so that with two reducers each family goes to one (with the hash of p and m modulo 2 they land on different reducers; if they did not, a custom partitioner would do the job, which in Streaming can only be written in Java). The output directory must not exist; MapReduce refuses to overwrite, precisely to protect the atomic output. The result:
$ docker compose exec namenode hdfs dfs -ls /km0/aggregates/2026-09-14/sales
-rw-r--r-- 2 hadoop supergroup 0 _SUCCESS
-rw-r--r-- 2 hadoop supergroup 71 part-00000
-rw-r--r-- 2 hadoop supergroup 54 part-00001
$ docker compose exec namenode hdfs dfs -cat /km0/aggregates/2026-09-14/sales/part-00000
p:la-vega-farm 14.20
p:montblanc-dairy 25.10
p:roble-alto-winery 58.80And the state of the job, with its counters, can be checked with yarn application -list -appStates ALL, mapred job -status <job_id> or in the ResourceManager's web UI (http://localhost:8088).
8.2 The same job in Java: SalesByProducer.java
Java is Hadoop's native language, and a job in Java avoids the cost of launching an interpreter per task and of serialising everything as text. This is the canonical example, line by line:
// km0/services/analytics/mapreduce/SalesByProducer.java
package km0.analytics;
import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.DoubleWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Partitioner;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class SalesByProducer {
/** Mapper<input key, input value, output key, output value>.
* TextInputFormat delivers (offset in bytes: LongWritable, line: Text). */
public static class SalesMapper extends Mapper<LongWritable, Text, Text, DoubleWritable> {
private static final ObjectMapper JSON = new ObjectMapper();
private final Text key = new Text(); // reused: they avoid creating
private final DoubleWritable amount = new DoubleWritable(); // millions of objects
@Override
protected void map(LongWritable offset, Text line, Context ctx)
throws IOException, InterruptedException {
JsonNode ev;
try {
ev = JSON.readTree(line.toString());
} catch (IOException e) {
ctx.getCounter("km0", "corrupt_lines").increment(1);
return;
}
if (!"order.created".equals(ev.path("type").asText())) return;
JsonNode data = ev.get("data");
String market = data.get("market").asText();
for (JsonNode ln : data.get("lines")) {
amount.set(ln.get("quantity").asDouble() * ln.get("price").asDouble());
key.set("p:" + ln.get("producer").asText());
ctx.write(key, amount); // (p:<producer>, amount)
key.set("m:" + market);
ctx.write(key, amount); // (m:<market>, amount)
}
}
}
/** Reducer<input key, input value, output key, output value>.
* Receives each key with an Iterable of ALL its values, already grouped by the shuffle. */
public static class SumReducer extends Reducer<Text, DoubleWritable, Text, DoubleWritable> {
private final DoubleWritable total = new DoubleWritable();
@Override
protected void reduce(Text key, Iterable<DoubleWritable> values, Context ctx)
throws IOException, InterruptedException {
double sum = 0.0;
for (DoubleWritable v : values) sum += v.get(); // iterator: the values may not fit in memory
total.set(Math.round(sum * 100.0) / 100.0);
ctx.write(key, total);
}
}
/** Partitioner: "p:" keys go to reducer 0 and "m:" keys to reducer 1. */
public static class PrefixPartitioner extends Partitioner<Text, DoubleWritable> {
@Override
public int getPartition(Text key, DoubleWritable value, int numReducers) {
if (numReducers == 1) return 0;
return key.charAt(0) == 'p' ? 0 : 1;
}
}
public static void main(String[] args) throws Exception {
Configuration conf = new Configuration(); // reads core-site.xml, yarn-site.xml, etc.
Job job = Job.getInstance(conf, "km0 sales by producer and market");
job.setJarByClass(SalesByProducer.class); // which jar to ship to the containers
job.setMapperClass(SalesMapper.class);
job.setCombinerClass(SumReducer.class); // addition is associative: the reducer doubles as combiner
job.setPartitionerClass(PrefixPartitioner.class);
job.setReducerClass(SumReducer.class);
job.setNumReduceTasks(2);
job.setMapOutputKeyClass(Text.class); // intermediate types (k2, v2)
job.setMapOutputValueClass(DoubleWritable.class);
job.setOutputKeyClass(Text.class); // final types (k3, v3)
job.setOutputValueClass(DoubleWritable.class);
FileInputFormat.addInputPath(job, new Path(args[0])); // /km0/events/2026-09-14/orders.jsonl
FileOutputFormat.setOutputPath(job, new Path(args[1])); // /km0/aggregates/2026-09-14/sales-java (must not exist)
System.exit(job.waitForCompletion(true) ? 0 : 1); // true: prints progress and counters
}
}What you need to understand about each block:
- The
Writabletypes. Hadoop does not useStringordoublein its interfaces, butText,DoubleWritable,LongWritable: types that serialise compactly and compare byte by byte, which the shuffle can sort without deserialising. Reusing the instances (key.set(...)instead ofnew Text(...)) is the most frequently cited MapReduce optimisation: a mapper emits millions of pairs and creating objects would send garbage collection through the roof. Mapper<K1, V1, K2, V2>andReducer<K2, V2, K3, V3>. The generics document the contract of section 1.Contextis the channel through which the task emits pairs (ctx.write) and increments counters.- The reducer's
Iterablecan be traversed only once: Hadoop feeds it from the sorted sequence on disk, and reuses theDoubleWritableobject on every iteration (keeping references to the values is a classic mistake: they all point to the same object). Jobis the declarative description: which classes, how many reducers, which types, which paths.waitForCompletionsubmits it to the ResourceManager and blocks until it finishes.setJarByClasstells Hadoop which jar contains the code, which YARN will copy to every container.setCombinerClass(SumReducer.class)is valid because the reducer's input and output are of the same type(Text, DoubleWritable)and addition is associative. If the reducer emitted something else (a ranking, an average) you would need a different combiner or none at all.
Building and launching:
mvn package -q # produces target/km0-analytics.jar (Hadoop as a 'provided' dependency)
docker compose cp target/km0-analytics.jar resourcemanager:/tmp/
docker compose exec resourcemanager hadoop jar /tmp/km0-analytics.jar km0.analytics.SalesByProducer \
/km0/events/2026-09-14/orders.jsonl /km0/aggregates/2026-09-14/sales-javaThe output is identical to the Streaming one (with part-r-00000 and part-r-00001, the r indicating that a reducer wrote it) and it takes a few seconds less thanks to the absence of Python interpreters and intermediate text. In a job over terabytes, that difference is tens of minutes.
8.3 Local simulation of the shuffle
To see what the framework hides, simulations/mapreduce_local.py implements the three phases in a single process, with the shuffle made explicit: partition, sort by key, group.
# km0/simulations/mapreduce_local.py
"""MapReduce in one process: makes the shuffle (partition, sort, group) between map and reduce visible."""
import json, sys
from itertools import groupby
from collections import defaultdict
def map_fn(line: str):
"""map(k1, v1) -> [(k2, v2)]. Same logic as mapper.py."""
ev = json.loads(line)
if ev["type"] != "order.created":
return
for ln in ev["data"]["lines"]:
amount = round(ln["quantity"] * ln["price"], 2)
yield f"p:{ln['producer']}", amount
yield f"m:{ev['data']['market']}", amount
def partition(key: str, n_reducers: int) -> int:
"""Partitioner: prefix 'p' to reducer 0, 'm' to reducer 1 (with n=2)."""
return 0 if key[0] == "p" else n_reducers - 1
def reduce_fn(key: str, values):
"""reduce(k2, [v2]) -> (k3, v3)."""
return key, round(sum(values), 2)
def run(path: str, n_maps: int = 3, n_reducers: int = 2, with_combiner: bool = True):
lines = open(path, encoding="utf-8").read().splitlines()
splits = [lines[i::n_maps] for i in range(n_maps)] # spreading the lines among the mappers
# --- Map phase: each mapper produces its output partitioned and sorted (the 'spills') ---
map_outputs = [] # [ mapper ][ partition ] -> sorted list
for i, split in enumerate(splits):
buffer = defaultdict(list)
for line in split:
for k, v in map_fn(line):
buffer[partition(k, n_reducers)].append((k, v))
by_partition = []
for p in range(n_reducers):
pairs = sorted(buffer[p], key=lambda kv: kv[0]) # sort by key WITHIN the partition
if with_combiner: # combiner: local reduce by key
pairs = [reduce_fn(k, (v for _, v in group)) for k, group in groupby(pairs, key=lambda kv: kv[0])]
by_partition.append(pairs)
map_outputs.append(by_partition)
print(f"map {i}: {len(split)} lines -> " + ", ".join(f"partition {p}: {len(by_partition[p])} pairs" for p in range(n_reducers)))
# --- Shuffle phase: each reducer fetches ITS partition from ALL the mappers and merges them in order ---
results = {}
for r in range(n_reducers):
fragments = [map_outputs[i][r] for i in range(n_maps)]
reducer_input = sorted((kv for frag in fragments for kv in frag), key=lambda kv: kv[0]) # merge (here, a sort)
print(f"reduce {r}: receives {sum(len(f) for f in fragments)} pairs from {n_maps} mappers")
# --- Reduce phase: iterate in order, grouping by change of key ---
results[r] = [reduce_fn(k, (v for _, v in group)) for k, group in groupby(reducer_input, key=lambda kv: kv[0])]
return results
if __name__ == "__main__":
for r, rows in run(sys.argv[1], with_combiner="--no-combiner" not in sys.argv).items():
print(f"--- part-r-0000{r} ---")
for k, v in rows:
print(f"{k}\t{v}")$ python mapreduce_local.py events/2026-09-14/orders.jsonl --no-combiner map 0: 1 lines -> partition 0: 2 pairs, partition 1: 2 pairs map 1: 1 lines -> partition 0: 1 pairs, partition 1: 1 pairs map 2: 1 lines -> partition 0: 2 pairs, partition 1: 2 pairs reduce 0: receives 5 pairs from 3 mappers reduce 1: receives 5 pairs from 3 mappers --- part-r-00000 --- p:la-vega-farm 14.2 p:montblanc-dairy 25.1 p:roble-alto-winery 58.8 --- part-r-00001 --- m:girona 20.3 m:lleida 58.8 m:valencia 19.0 $ python mapreduce_local.py events/2026-09-14/orders.jsonl | head -5 map 0: 1 lines -> partition 0: 2 pairs, partition 1: 1 pairs ...
With the 400,000-order file from 05-01 and --no-combiner, each reducer receives hundreds of thousands of pairs; with the combiner, it receives 3 × n_maps for the producers and 4 × n_maps for the markets. The groupby from itertools over the sorted sequence is literally what the Streaming reducer does when it detects the change of key, and sorted over the fragments is the merge of section 5 (in Hadoop, a merge of already-sorted sequences, cheaper than a full sort).
Common Mistakes and Tips
- Testing directly on the cluster. A Streaming job is tested with
cat | mapper | sort | reducerin a second; on the cluster, every failed attempt costs a minute and a YARN log you have to go and dig out withyarn logs -applicationId. - A combiner that is not associative. Computing averages, or "the first one", or emitting a different type in the combiner, produces results that change depending on how many times it has run. Rule: the combiner must be a function such that running it 0, 1 or N times gives the same final result.
- Keeping references to the values of the
Iterable. Hadoop reuses the object; if the reducer doeslist.add(v)in order to sort later, the list ends up with N copies of the last value. You have to copy (new DoubleWritable(v.get())). - Existing output directory. The job fails before it starts. This is intentional: atomic output demands a clean directory. Deleting it is part of the pipeline (05-05), not of the job.
gzipon the input. A 2 GBorders.jsonl.gzis one split and one twenty-minute map. Usebzip2, indexed LZO, or better still Parquet.- Too many or too few reducers. A single one makes the reduce sequential (Amdahl); a thousand over 20 MB of data create a thousand tiny files. As a guide: have each reducer process between 1 and 5 GB of shuffle, and never more reducers than useful distinct keys.
- Ignoring the counters.
Reduce shuffle bytesandSpilled recordsare the job's thermometer. If the shuffle is the size of the input, the combiner is missing; if the spills are several times the map output, the sort buffer is short of memory. - Mixing job chaining with logic. Three jobs chained by hand with intermediate paths in HDFS turn into a fragile pipeline. That is the job of the scheduler in 05-05, and a compelling reason to move to Spark, where the three phases are a single program.
Exercises
Exercise 1: Top-selling producer per market
Design a MapReduce job (or chain of jobs) that produces, for each market, the producer with the highest sales and its amount. State the intermediate keys and values of each phase, whether you can use a combiner, and how many jobs are needed. Then write the Streaming mapper.py and reducer.py for the first job.
Exercise 2: Failures in the middle of the job
The job in section 8.1 has 2 maps and 2 reduces. Describe what the ApplicationMaster does in each of these cases and which tasks are re-run: (a) the NodeManager dn-07 dies when map 1, which was running there, had already finished and reduce 0 had copied its partition but reduce 1 had not yet done so; (b) reduce 1 throws an exception because of a non-numeric value on its third line; (c) reduce 0 takes five times the median and the AM launches a speculative copy that finishes first. Which files are there in /km0/aggregates/2026-09-14/sales/ during and after each case?
Exercise 3: Skew with a partitioner
During Artisan Cheese Week, p:montblanc-dairy accounts for 50% of the pairs. With the combiner enabled, is that a problem? And without the combiner, or if the reduce were "list of the 100 largest orders for each producer" (where the combiner does not cut the volume as much)? Propose a Java partitioner and the logic of a second job to spread the hot key over four reducers and combine afterwards, following the salting of 05-01.
Solutions
Exercise 1.
Two jobs are needed, because there are two chained groupings: first add up by (market, producer) and then, per market, pick the maximum.
- Job 1.
map: for each order line,(market|producer, amount). Combiner: sum (associative).reduce: sum. Output:girona|montblanc-dairy<TAB>18240.50. - Job 2.
map: reads the output of job 1 and emits(market, producer|total). Combiner: yes, "keep the maximum" is associative and commutative and does not change the type.reduce: go through each market's values and emit the producer with the highest total.
Job 1 in Streaming:
# mapper1.py
import json, sys
for line in sys.stdin:
ev = json.loads(line)
if ev.get("type") != "order.created": continue
m = ev["data"]["market"]
for ln in ev["data"]["lines"]:
print(f"{m}|{ln['producer']}\t{ln['quantity'] * ln['price']:.2f}")
# reducer1.py: identical to reducer.py in section 8.1 (sum by key).A single job would be possible with a composite key market and values producer|amount, adding up by producer inside the reducer with an in-memory dictionary: it works if one market's producers fit in memory (here they do, there are three), but it loses the combiner and loads the whole volume onto the reduce. With Spark it will be a groupBy followed by a window, in a single program (05-03).
Exercise 2.
(a) The AM stops receiving heartbeats from dn-07 and marks its tasks as lost. Map 1 was completed, but its output lived on the local disk of dn-07, and reduce 1 had not yet copied it, so the AM re-runs map 1 on another node (requesting a container from the RM, preferably on a node with a replica of the block). Reduce 0 keeps its copy and is not affected; reduce 1 waits and copies from the new location. During that time, the output directory contains only _temporary/ with the attempts in progress.
(b) The reduce 1 task fails with an exception; the AM retries it in another container (up to 4 attempts by default). Since the error is deterministic (the same corrupt value), it will fail all four times and the whole job fails; _SUCCESS is not written, and _temporary/ is cleaned up. Solution: have the reducer tolerate the value (try/except with a km0,non_numeric_values counter) or configure mapreduce.reduce.failures.maxpercent. Reduce 0, which had finished and committed, leaves its part-00000 in the directory, but without _SUCCESS no consumer should read it.
(c) The two copies of reduce 0 write to _temporary/attempt_..._r_000000_0/ and _temporary/attempt_..._r_000000_1/. The speculative copy finishes first and requests the commit; the AM grants it and renames its file to part-00000; it then kills the original attempt and discards its temporary directory. In the end: _SUCCESS, part-00000 (from attempt 1) and part-00001. There are never two part-00000 files because the commit is exclusive per task.
Exercise 3.
With a combiner and a sum, it is not a problem: each mapper reduces its 200,000 Montblanc pairs to one, and the reducer receives one per mapper. The skew is in the mappers' input, which is already split by blocks, not by key. Without a combiner, reducer 0 receives half of the job's pairs and takes twice as long as the markets reducer; with a "top 100 per producer", the combiner only reduces each mapper to 100 pairs per producer, which is still not much, so that is not serious either; skew really matters when the reducer needs all the values (the complete list of a producer's orders, an exact median).
Salting partitioner:
public static class SaltPartitioner extends Partitioner<Text, DoubleWritable> {
public int getPartition(Text key, DoubleWritable v, int n) {
String k = key.toString();
if (k.startsWith("p:montblanc-dairy#")) // the mapper emits p:montblanc-dairy#0..#3
return Integer.parseInt(k.substring(k.indexOf('#') + 1)) % n;
return (k.hashCode() & Integer.MAX_VALUE) % n;
}
}The mapper adds the suffix #(hash(order_id) % 4) only to the hot key (deterministic: re-runnable). Job 1 produces four partials p:montblanc-dairy#0..3; a job 2 (or a lightweight final step outside MapReduce, because it is four numbers) strips the suffix and adds up. For the "top 100", job 2 merges four lists of 100 and keeps the best 100: correct because the global top-K is contained in the union of the partial top-Ks.
Conclusion
MapReduce turned the patterns of 05-01 into a two-function contract: the programmer writes map, which extracts a key from each record, and reduce, which aggregates the values of a key, and the system supplies locality (one map per block, run where the block lives), the shuffle & sort that groups by key, the re-execution of failed tasks, speculative execution and atomic output with _temporary and _SUCCESS. The combiner is the pre-reduction that saves four orders of magnitude of shuffle in the sales job, and the partitioner is the lever for separating families of keys or breaking up a hot key. Hadoop implements it on top of HDFS with YARN as a generic resource manager, with a ResourceManager that arbitrates, NodeManagers that launch containers and one ApplicationMaster per job acting as a restartable master. We have run it three times: in Python with Hadoop Streaming, tested first with cat | mapper | sort | reducer; in Java, the canonical example with its Writable types, its Job and its single-use iterator; and in a simulation that makes the partitions, the sort and the groupby visible.
We have also seen the price: every phase writes to disk, every additional grouping is another job that goes through HDFS, every task starts a JVM, and a computation that a Python process solves in a second takes a minute on the cluster. For a nightly batch of terabytes that is an acceptable price; for chaining the sum by producer with the catalogue join and the ranking per market, or for training the recommendations in a hundred iterations over the clicks, it is not. The next lesson introduces Spark, which keeps the model (partitions, shuffle, re-runnable tasks) but expresses it as a DAG of operators that runs in memory, with an optimiser that decides the stages: the daily_sales.py of analytics will go from two scripts and a hadoop jar to a thirty-line program with DataFrames.
Distributed Architectures Course
Module 1: Introduction to Distributed Systems
- Basic Concepts of Distributed Systems
- Distributed System Models
- Advantages and Challenges of Distributed Systems
- The Fallacies of Distributed Computing
- Time, Clocks and Event Ordering
- From Monolith to Distributed Platform: the Kilometre Zero Case
Module 2: Communication in Distributed Systems
- Communication Protocols
- RPC and RMI
- gRPC and Data Serialization
- Messaging and Message Queues
- Asynchronous Communication Patterns
Module 3: Consistency and Replication
- Consistency Models
- The CAP Theorem and PACELC
- Consensus Algorithms
- Data Replication
- Distributed Transactions and Sagas
Module 4: Distributed Storage
- Data Partitioning and Consistent Hashing
- Distributed File Systems
- Object Storage
- Distributed Databases
- Distributed Caches
Module 5: Distributed Computing
- Distributed Computing Models
- MapReduce and Hadoop
- Spark and In-Memory Computing
- Stream Processing
- Job Scheduling and Data Pipelines
Module 6: Security in Distributed Systems
- Authentication and Authorization
- Encryption and Data Protection
- Identity Management
- Service-to-Service Security: mTLS and Secrets Management
- API Gateways, Rate Limiting and Auditing
Module 7: Monitoring and Maintenance
- Monitoring Distributed Systems
- Centralized Logs and Distributed Tracing
- Failure Management and Recovery
- Resilience Patterns: Timeouts, Retries and Circuit Breakers
- Automation and Orchestration
- Testing Distributed Systems and Chaos Engineering
