MapReduce & Large-Scale Data
MapReduce and large-scale data processing: how Google, Hadoop, and Spark turned petabyte problems into embarrassingly parallel ones you can solve with a few hundred lines of code.
Overview
In 2003, Google published a paper that changed how we think about computation at scale. The insight was deceptively simple: many real-world data problems — counting words, building inverted indexes, computing PageRank — share a common structure. You can break them into independent subtasks, process each in parallel, and then aggregate the results. Google called this pattern MapReduce, and it unlocked the ability to process petabytes of data on commodity hardware.
The elegance of MapReduce lies in what it hides. A programmer writing a MapReduce job does not need to think about network partitions, machine failures, data locality, or load balancing. The framework handles all of it. You write two functions — map and reduce — and the system orchestrates thousands of machines to run them correctly and efficiently. This separation of concerns between the what (the user's logic) and the how (the distributed execution) is the central architectural idea.
MapReduce spawned an entire ecosystem. The open-source implementation, Hadoop, democratized large-scale data processing and became the foundation of the modern data stack. HDFS (Hadoop Distributed File System), modeled on Google File System (GFS), solved the problem of reliably storing massive datasets across hundreds of disks. Later, Apache Spark retained MapReduce's data-parallel philosophy but added in-memory computation, lazy evaluation, and a richer API — making it orders of magnitude faster for iterative algorithms like machine learning.
This module builds the full picture from the ground up. You will understand why distributed file systems are designed the way they are, how the MapReduce execution model handles failures, why shuffle is the bottleneck, and when to reach for batch processing versus stream processing. You will leave with enough depth to read the original papers, reason about performance, and build real systems.
Nearly every large technology company runs some form of data processing infrastructure rooted in the ideas covered here. Whether the question is "how many users clicked this ad today?", "what are the trending topics on this platform?", or "how do we train this recommendation model on billions of examples?", the answer involves distributing work across many machines in a principled way. MapReduce and its successors are not just industry tools — they are concrete implementations of fundamental ideas in parallel and distributed computing: data partitioning, fault tolerance via replication, the cost of synchronization, and the tension between latency and throughput.
Understanding this stack also sharpens your systems thinking more broadly. The design decisions in GFS — large block sizes, append-only semantics, relaxed consistency — are direct responses to the workload characteristics of large-scale batch processing. Spark's decision to keep RDDs in memory rather than materializing to disk reveals the cost structure of I/O versus CPU. Stream processing systems like Apache Flink expose the fundamental impossibility results around distributed consensus. These are not historical curiosities; they are live tensions that every systems designer must navigate.
MapReduce Execution Flow
The full MapReduce execution flow: input is split across HDFS blocks, each split is processed by a mapper, local output is sorted and partitioned, the shuffle routes each key to the correct reducer (all-to-all network communication), and reducers emit the final output files. The shuffle phase is the bottleneck because it involves network-wide data movement.
The concepts
01The Data Parallelism Insight
Before MapReduce, scaling a computation to terabytes of data meant either buying a very expensive single machine or writing deeply custom distributed code — both unsatisfying options. The breakthrough was recognizing a structural pattern in a large class of problems.
Data parallelism is the observation that many computations apply the same operation independently to many data items. If you want to count every word in the entire English-language Wikipedia, there is no reason word 1 through word 1,000,000 needs to wait for word 1,000,001 through word 2,000,000. The counts are independent. You can process each chunk simultaneously on a different machine, then add up the partial counts at the end.
This is different from task parallelism, where distinct different tasks run concurrently. Data parallelism scales with the size of your data, not the number of distinct operations you have. This is exactly what you need for big data.
The key insight is that these problems are embarrassingly parallel — a term of art meaning almost no inter-task communication is required during the parallel phase. Most of the data processing problems that actually matter at scale (log analysis, text indexing, ETL pipelines, training data preprocessing) fit this mold. MapReduce formalized this observation into a programming model.
# The embarrassingly parallel pattern (Python illustration)
# Sequential: processes 1 billion records one at a time
def word_count_sequential(records):
counts = {}
for record in records: # bottleneck: 1 machine
for word in record.split():
counts[word] = counts.get(word, 0) + 1
return counts
# Data-parallel: process N chunks simultaneously on N machines
def word_count_chunk(chunk):
counts = {}
for record in chunk:
for word in record.split():
counts[word] = counts.get(word, 0) + 1
return counts # partial result; merge later
# Each machine runs word_count_chunk on its local data;
# a final step merges the partial dicts by summing values.
02The MapReduce Programming Model
MapReduce, as described in the 2004 Google paper by Dean and Ghemawat, distills data-parallel computation into two user-defined functions inspired by their namesakes in functional programming.
Map takes a key-value pair and emits zero or more intermediate key-value pairs:
map(k1, v1) → list of (k2, v2)
Reduce takes an intermediate key and the list of all values for that key, and emits zero or more output values:
reduce(k2, list(v2)) → list(v3)
The framework handles everything in between: splitting the input, distributing map tasks to workers, shuffling (routing each (k2, v2) pair to the machine responsible for reducing k2), sorting by key so the reducer sees all values for a key together, and writing output.
Word Count — the canonical example. Input: a large text file. The mapper tokenizes each line and emits (word, 1) for every word. After the shuffle, every reducer receives one word and a list of 1s. The reducer sums them.
The model's power comes from what it constrains. Map has no side effects and sees no other record's data. Reduce sees all values for a key and produces a final answer. These constraints make it trivial for the framework to retry failed tasks, run speculative duplicate tasks, and guarantee correct output even when machines die.
# MapReduce word count in pseudo-Python
# (In real Hadoop Streaming this would be actual stdin/stdout)
def mapper(line):
for word in line.strip().lower().split():
yield (word, 1) # emit (key, value)
def reducer(word, values):
yield (word, sum(values)) # aggregate values for one key
# Framework orchestration (conceptual):
# 1. Split input into M chunks, one per mapper
# 2. Run mapper on each chunk in parallel
# 3. SHUFFLE: sort all (word, 1) pairs; route word→reducer R
# 4. Run reducer on each unique word in parallel
# 5. Write R output files
# Example flow for the string "the cat sat on the mat":
# Map: (the,1),(cat,1),(sat,1),(on,1),(the,1),(mat,1)
# Shuffle: [(cat,[1]),(mat,[1]),(on,[1]),(sat,[1]),(the,[1,1])]
# Reduce: (cat,1),(mat,1),(on,1),(sat,1),(the,2)
03Fault Tolerance via Deterministic Re-execution
A cluster of 1,000 commodity machines has a daily hardware failure rate of roughly 1%. Run a job that takes an hour on 1,000 machines and you expect a failure. Any viable distributed execution model must handle this gracefully.
MapReduce's answer is elegant: make tasks stateless and deterministic, so failed tasks can simply be re-run.
The master node (called the JobTracker in Hadoop 1) monitors all worker nodes via heartbeat. If a worker does not respond within a timeout window, the master assumes it is dead and reschedules all in-progress or completed map tasks on that worker to another machine. (Map tasks are rescheduled even when complete, because their output on the dead machine's local disk is now inaccessible. Reduce tasks that have already fetched their map output do not need to be rerun.)
Speculative execution is a related mechanism: if a task is running significantly slower than similar tasks (a straggler), the framework launches a duplicate copy of the same task on a different machine, and whichever finishes first is used. This is safe precisely because map and reduce functions are required to be deterministic and have no side effects — two identical executions produce identical output, so it does not matter which one wins.
The master's own fault tolerance is handled by checkpointing its state (the map of task IDs to worker assignments and statuses) to durable storage periodically. A master failure requires re-running from the last checkpoint.
This design trades off redundant computation (re-running tasks) for simplicity in the programming model. The programmer does not write recovery logic. The constraint that functions be pure (deterministic, no side effects) is the price, and it is almost always a fair one.
# Pseudocode: Master's task scheduler loop
class Master:
def __init__(self, input_splits, num_reducers):
self.map_tasks = {i: IDLE for i in input_splits}
self.reduce_tasks = {r: IDLE for r in range(num_reducers)}
self.worker_heartbeat = {} # worker_id → last seen time
def assign_task(self, worker_id):
self.worker_heartbeat[worker_id] = now()
# Find any IDLE map task; assign it
for task, status in self.map_tasks.items():
if status == IDLE:
self.map_tasks[task] = IN_PROGRESS(worker_id)
return MapTask(task)
# All maps done? Assign reduce tasks.
...
def detect_failures(self):
for worker, last_seen in self.worker_heartbeat.items():
if now() - last_seen > TIMEOUT:
# Reset all tasks assigned to this worker back to IDLE
for task, status in self.map_tasks.items():
if status == IN_PROGRESS(worker):
self.map_tasks[task] = IDLE # will be retried
04Google File System (GFS) and HDFS
MapReduce processes data. But where does the data live? You cannot fit a petabyte on a single disk. The answer is a distributed file system — a filesystem whose storage is spread across hundreds or thousands of machines, but that presents a single namespace to clients.
Google's GFS (2003 paper by Ghemawat, Gobioff, and Leung) and its open-source sibling HDFS (Hadoop Distributed File System) share the same key design decisions, each driven by workload reality:
Large block sizes (64 MB–256 MB). GFS uses 64 MB blocks; HDFS defaults to 128 MB. This is 100x larger than a typical filesystem block. Why? Because reading a 100 GB file sequentially has negligible metadata overhead with large blocks, and metadata costs dominate with small blocks at petabyte scale. The trade-off is wasted space for small files — HDFS is a poor fit for millions of tiny files.
Replication factor of 3. Every block is stored on 3 DataNodes (replica placement: one on the local rack, one on a different node in the same rack, one on a different rack). This tolerates two simultaneous node failures and allows reads to be served from the nearest replica.
Single master (NameNode in HDFS). The master stores all metadata (file namespace, file-to-block mapping, block-to-DataNode mapping) in memory for fast access. DataNodes store actual block data. This master bottleneck is a known limitation; HDFS HA uses a standby NameNode to mitigate it.
Write-once, append-only semantics. Files are written once and read many times (or appended to). Random writes are not supported. This dramatically simplifies consistency: there is no need to handle concurrent writers modifying the same byte range. The GFS paper is explicit: 'We have relaxed GFS's consistency model to vastly simplify the file system without imposing an onerous burden on the applications.'
Data locality. The MapReduce scheduler knows which blocks are on which machines. Whenever possible, it schedules a map task on the same machine (or at least the same rack) that holds the input block. This data locality eliminates most network I/O for the map phase — the dominant optimization in MapReduce performance.
# HDFS block placement illustration (pseudocode)
class HDFSNameNode:
def write_file(self, filename, data):
blocks = self.split_into_blocks(data, block_size=128*MB)
block_locations = []
for block in blocks:
# Choose 3 DataNodes for replication
# Rule: 1 local rack, 1 same rack, 1 remote rack
dn1 = self.pick_local_node() # writer's rack
dn2 = self.pick_same_rack_node(dn1) # same rack, different node
dn3 = self.pick_remote_rack_node(dn1) # different rack entirely
pipeline = [dn1, dn2, dn3]
self.write_block_pipeline(block, pipeline)
block_locations.append((block.id, pipeline))
self.namespace[filename] = block_locations
# The scheduler exploits this metadata:
# for each map task whose input is block B,
# prefer to run the task on dn1, dn2, or dn3 (data-local).
05The Shuffle Phase: The Hidden Bottleneck
The shuffle is the part of MapReduce that programmers often underestimate, and it is almost always the bottleneck. After all mappers finish, every (k2, v2) pair must be routed to the correct reducer. The reducer responsible for key k2 is determined by a partitioning function, typically hash(k2) mod R where R is the number of reducers.
This means every mapper must potentially send data to every reducer — an all-to-all communication pattern over the network. If you have M mappers and R reducers, and each map output has size S, then up to M × S bytes must cross the network during the shuffle. At petabyte scale, this is hundreds of gigabytes of network traffic, and the network is the slowest component in the cluster.
Combiner optimization. One powerful mitigation is the combiner (also called a local reducer). After the mapper produces its output but before the shuffle, the combiner runs on the local machine and partially aggregates values for the same key. For word count, the combiner sums up all (the, 1) pairs on one machine into a single (the, 47) before sending it over the network. This can reduce shuffle traffic by orders of magnitude for count-like operations.
Sort-merge. The shuffle also sorts all intermediate (key, value) pairs by key before handing them to reducers. Reducers do not receive a random stream of values; they receive a sorted iterator. This is why the standard Hadoop reduce signature receives key, Iterable[values] — you can process billions of values without materializing them all in memory, as long as they arrive sorted.
Skew is the enemy. If your key distribution is skewed (e.g., a 'hot' key like the word 'the' appears a billion times), one reducer will receive far more data than others. This is a data skew problem and it will stall your job waiting for the slow reducer. Solutions include custom partitioners, salting keys (adding a random prefix to distribute hot keys across multiple reducers and merging in a second pass), and pre-aggregation with combiners.
# Combiner: partial aggregation before shuffle
# Reducer function and combiner function are identical for
# associative, commutative operations like sum/max/count.
def mapper_with_combiner(line, combine_buffer):
for word in line.strip().split():
combine_buffer[word] = combine_buffer.get(word, 0) + 1
# Emit combine_buffer periodically to limit memory usage:
if memory_pressure():
for word, count in combine_buffer.items():
yield (word, count) # goes into local sort buffer
combine_buffer.clear()
# Without combiner: 1 billion (the, 1) records cross the network
# With combiner: 1 record (the, 47000) per mapper crosses network
# Speedup factor: proportional to the frequency of the hot key
06Apache Hadoop Ecosystem
Apache Hadoop (2006, by Doug Cutting and Mike Cafarella at Yahoo, open-sourcing the ideas from Google's papers) made MapReduce and the GFS-style distributed filesystem available to everyone. At its core, Hadoop 1.x had two components: HDFS (the distributed file system) and MapReduce (the compute layer with its own resource manager called JobTracker/TaskTracker).
Hadoop 2.x introduced YARN (Yet Another Resource Negotiator), which separated cluster resource management from the MapReduce programming model. YARN is a general-purpose cluster scheduler: it manages CPU and memory across the cluster and allows multiple execution engines (MapReduce, Spark, Tez, Flink) to run simultaneously on the same cluster. This was a significant architectural improvement — before YARN, the entire cluster was locked into the MapReduce execution model.
The Hadoop ecosystem grew into a rich stack:
- Hive: SQL-like query language (HiveQL) that compiles to MapReduce or Tez jobs. Lets analysts query HDFS data without writing Java.
- Pig: Dataflow scripting language that compiles to MapReduce.
- HBase: Column-family store (modeled on Google Bigtable) for random read/write access on top of HDFS.
- ZooKeeper: Distributed coordination service (leader election, distributed locks).
- Oozie: Workflow scheduler for chaining Hadoop jobs.
Hadoop's limitations drove the next generation. MapReduce writes intermediate results to disk between every stage (map output → shuffle → reduce input). For jobs with many stages (like machine learning algorithms that iterate over the data many times), this causes enormous I/O overhead. Each iteration touches the disk. Spark was built specifically to address this.
# Hive query (HiveQL) that compiles to a MapReduce job:
# Count unique users per country from a billion-row log table.
SELECT country,
COUNT(DISTINCT user_id) AS unique_users
FROM access_logs
WHERE dt = '2024-01-15'
GROUP BY country
ORDER BY unique_users DESC
LIMIT 20;
-- Hive translates this to:
-- MAP: emit (country, user_id) for each row
-- SHUFFLE: group by country
-- REDUCE: COUNT(DISTINCT user_id) via HyperLogLog sketch
-- A second MR stage handles ORDER BY + LIMIT globally
07Apache Spark and the RDD Model
Apache Spark (2009, by Matei Zaharia at UC Berkeley AMPLab; 2014 Apache top-level project) is the most important successor to Hadoop MapReduce. Its core abstraction is the Resilient Distributed Dataset (RDD).
An RDD is an immutable, partitioned, lazily evaluated collection of records that can be reconstructed from its lineage if a partition is lost. The three key ideas:
-
In-memory computation. Instead of materializing intermediate results to HDFS after every stage, Spark keeps data in memory across stages. For iterative algorithms (K-means, PageRank, gradient descent), each iteration reads from memory rather than disk — speedups of 10-100x are common.
-
Lazy evaluation. Spark operations are divided into transformations (map, filter, join, groupByKey) and actions (collect, count, saveAsTextFile). Transformations are lazy — they build a logical plan (a DAG of operations) but do not execute. Only an action triggers execution. This allows Spark's optimizer to see the full computation graph and perform optimizations like pipelining (fusing multiple map operations into a single pass) and stage planning (deciding where to insert shuffle boundaries).
-
Fault tolerance via lineage. Instead of checkpointing data (expensive), Spark remembers how each RDD was derived from its parent RDDs. If a partition is lost (because a machine died), Spark re-executes only the transformations needed to recompute that specific partition from the last checkpoint or original data source. This is elegant and cheap for narrow dependencies (one parent partition → one child partition), but expensive for wide dependencies (a shuffle where one child partition depends on all parent partitions).
Spark SQL and DataFrames (added in Spark 1.3) added a relational layer on top of RDDs with a query optimizer called Catalyst. This is now the dominant Spark API because the optimizer can push predicates down, reorder joins, and apply columnar storage tricks — often generating faster code than hand-written RDD operations.
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("WordCount").getOrCreate()
sc = spark.sparkContext
# RDD API: each line is a lazy transformation
lines = sc.textFile("hdfs:///data/shakespeare/*.txt") # no execution yet
words = lines.flatMap(lambda line: line.lower().split()) # still lazy
pairs = words.map(lambda w: (w, 1)) # still lazy
counts = pairs.reduceByKey(lambda a, b: a + b) # still lazy
# THIS triggers execution — Spark builds the DAG and runs it:
top_20 = counts.sortBy(lambda x: -x[1]).take(20)
print(top_20)
# Iterative algorithm benefit: cache the dataset in memory
graph = sc.textFile("hdfs:///data/web_graph")\
.map(parse_edge)\
.cache() # <-- tell Spark: keep this in RAM
# Now 50 PageRank iterations each read from RAM, not disk
for iteration in range(50):
ranks = graph.join(ranks).map(compute_contribution).reduceByKey(sum)
08DAG Execution and the Spark Scheduler
When you trigger an action, Spark's DAG Scheduler analyzes the lineage graph of RDD transformations and divides it into stages. A stage boundary is created whenever there is a wide dependency — a transformation like groupByKey, reduceByKey, or join where the output partition depends on multiple input partitions (i.e., a shuffle is required).
Within a stage, operations are pipelined: multiple narrow transformations (filter, map, flatMap) on the same partition are fused into a single pass over the data without writing intermediate results to memory. This is the key performance advantage over Hadoop's one-disk-write-per-stage model.
The scheduler submits stages as sets of tasks (one task per partition). The Task Scheduler then assigns tasks to workers via the Cluster Manager (YARN, Kubernetes, or Spark's own standalone manager), preferring data-local execution.
Shuffle implementation in Spark. Spark's shuffle is similar to Hadoop's but improved. Map-side sort-and-spill writes sorted partition files to local disk. Reduce-side tasks fetch their partition files over the network. Later versions (Spark 3.x) introduced Adaptive Query Execution (AQE), which can dynamically merge small shuffle partitions, switch join strategies mid-query based on runtime statistics, and handle data skew automatically — problems that required manual tuning in earlier versions.
Understanding the DAG is crucial for debugging performance. The Spark UI's Stage DAG view lets you see exactly where shuffles occur, how many tasks each stage has, and which tasks are slow (stragglers). This is your primary tool for diagnosing slow Spark jobs.
# Visualizing Spark's stage boundaries
# Transformations marked [narrow] vs [wide] (shuffle boundary)
rdd1 = sc.textFile("logs/") # Stage 1 starts here
rdd2 = rdd1.filter(is_error) # [narrow] — pipelined
rdd3 = rdd2.map(parse_log_entry) # [narrow] — pipelined
rdd4 = rdd3.map(lambda x: (x.host, 1)) # [narrow] — pipelined
# ---- SHUFFLE BOUNDARY (Stage 1 ends, Stage 2 begins) ----
rdd5 = rdd4.reduceByKey(lambda a,b: a+b) # [wide] — triggers shuffle
rdd6 = rdd5.filter(lambda x: x[1] > 100) # [narrow] — pipelined
# ---- SHUFFLE BOUNDARY (Stage 2 ends, Stage 3 begins) ----
rdd7 = rdd6.sortByKey() # [wide] — another shuffle
# Spark will create exactly 3 stages:
# Stage 1: textFile → filter → map → map → (shuffle write)
# Stage 2: (shuffle read) → reduceByKey → filter → (shuffle write)
# Stage 3: (shuffle read) → sortByKey → action
09Batch vs Stream Processing
MapReduce and Spark (in its basic form) are batch processing systems: they operate on a bounded, finite dataset stored at rest. You accumulate data for a day (or an hour, or a week), then run a job over it. The result is available after the job completes — which might be minutes or hours after the data was generated.
Stream processing operates on unbounded, continuously-arriving data. Instead of waiting for a batch to accumulate, you process each event as it arrives (or in small micro-batches). The goal is low latency: ideally, results are updated within milliseconds to seconds of new data arriving.
The trade-off is not just latency vs. throughput:
- Batch has simple semantics. Results are exact because you have the complete dataset. Re-running a batch job gives the same answer. State management is trivial — you just read the dataset.
- Stream must handle late data (events that arrive after their logical time), out-of-order events, and the impossibility of ever seeing the "complete" picture. You must make decisions about watermarks (how long to wait for late events before closing a time window) and exactly-once semantics (hard to achieve without careful engineering of idempotent operations and transactional sinks).
Lambda architecture (Nathan Marz, 2011) is an influential pattern for having both: a batch layer for accurate historical results and a speed layer (stream processing) for fresh approximate results. The two are merged at query time. In practice, the complexity of maintaining two code paths for the same logic led to the Kappa architecture (Jay Kreps, 2014), which argues for a single stream processing system with enough reprocessing capability to handle the historical use case.
Apache Flink is the leading stream-first system, treating batch as a special case of streaming over a finite dataset. Spark Structured Streaming brings streaming to Spark using the same DataFrame API, treating a stream as an unbounded table.
# Spark Structured Streaming: word count over a live stream
# The API looks almost identical to batch Spark SQL
from pyspark.sql import SparkSession
from pyspark.sql.functions import explode, split
spark = SparkSession.builder.appName("StreamingWordCount").getOrCreate()
# Read from a Kafka topic (unbounded stream)
raw = spark.readStream \
.format("kafka") \
.option("kafka.bootstrap.servers", "localhost:9092") \
.option("subscribe", "log-events") \
.load()
words = raw.selectExpr("CAST(value AS STRING)") \
.select(explode(split("value", " ")).alias("word"))
counts = words.groupBy("word").count()
# Write running counts to console every trigger interval
query = counts.writeStream \
.outputMode("complete") \
.format("console") \
.trigger(processingTime="10 seconds") \
.start()
query.awaitTermination()
10Columnar Storage and the Parquet Format
The file format you use for large-scale data matters enormously — sometimes as much as the compute engine itself. The dominant format in modern data systems is Apache Parquet, a columnar storage format.
Row-oriented storage (like CSV or a traditional row-store database) stores each row contiguously: all fields of record 1, then all fields of record 2, etc. This is optimal for workloads that need to read or write complete records (OLTP — order processing, user profile lookup).
Column-oriented storage stores each column contiguously: all values of column 1, then all values of column 2, etc. This is optimal for analytical queries that read a few columns from millions of rows — exactly the MapReduce/Spark workload. If your query is SELECT AVG(revenue) FROM sales WHERE year = 2023, columnar storage means you read only the revenue and year columns. With 100 columns, you read 2% of the data.
Columnar storage also enables dramatically better compression. Values in the same column tend to be similar (all revenues, all country codes), and compression algorithms exploit repeated patterns. Parquet uses a combination of dictionary encoding, run-length encoding, and delta encoding, often achieving 5-10x compression over row formats. Smaller files mean less network I/O in the shuffle and less storage cost.
Parquet also stores column statistics (min, max, null count per row group) in its footer. Spark and other engines exploit this for predicate pushdown: if your filter is year = 2023 and a row group's min/max year is 2020-2022, the entire row group can be skipped without reading it. This can reduce I/O by orders of magnitude on filtered scans.
The lesson is that storage format is a first-class performance concern in distributed systems, not an afterthought.
# Writing and reading Parquet with Spark (DataFrame API)
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
# Read a CSV (row format) and write as Parquet (columnar)
df = spark.read.csv("hdfs:///raw/sales.csv", header=True, inferSchema=True)
df.write \
.partitionBy("year", "country") # partition pruning on directory
.parquet("hdfs:///processed/sales/")
# This query reads ONLY the 'revenue' and 'year' columns
# AND skips row groups where year != 2023 (predicate pushdown)
result = spark.read.parquet("hdfs:///processed/sales/") \
.filter("year = 2023") \
.agg({"revenue": "avg"})
result.explain() # shows 'PushedFilters: [IsNotNull(year), EqualTo(year,2023)]'
# and 'PartitionFilters: [isnotnull(year), (year = 2023)]'
11Partitioning, Skew, and Performance Tuning
The number of partitions (the degree of parallelism) is the single most impactful tuning knob in a Spark or MapReduce job.
Too few partitions: you underutilize the cluster. If you have 200 executors but only 10 partitions, 190 executors sit idle.
Too many partitions: scheduling overhead dominates. Each task incurs overhead (JVM spinup, scheduler latency, shuffle file creation). Creating 100,000 tiny 1KB tasks is slower than 1,000 tasks of 100KB each.
The rule of thumb: aim for 2-4 tasks per CPU core, with each task processing roughly 100-200MB of data. For a 1TB shuffle, you would target around 2,000 partitions.
Data skew is the hardest performance problem in distributed processing. If 1% of your keys account for 50% of your data, the reducer (or executor) handling those keys will take 50x longer than others. Your entire job waits for that one slow task. Diagnosis: in the Spark UI, look for a stage where most tasks finish in 1 second but one task takes 10 minutes.
Solutions:
- Salting. Append a random integer 0-N to hot keys before the shuffle, forcing them to split across N reducers. After aggregating, remove the salt and merge results.
- Broadcast join. If one side of a join is small (< a few GB), broadcast it to all executors so the join happens locally without any shuffle at all.
- Adaptive Query Execution (AQE, Spark 3.0+). Automatically detects skew and splits oversized partitions at runtime.
- Custom partitioner. Pre-partition data by a business key (e.g., date) so joins on that key require no shuffle.
# Salting technique for skewed joins
import pyspark.sql.functions as F
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
# Problem: user_id 42 appears in 40% of events
events = spark.table("events")
profiles = spark.table("user_profiles")
SALT_FACTOR = 20
# Replicate the small table (profiles) N times with all salt values
profiles_salted = profiles.withColumn(
"salt", F.explode(F.array([F.lit(i) for i in range(SALT_FACTOR)]))
)
profiles_salted = profiles_salted.withColumn(
"salted_key", F.concat(F.col("user_id"), F.lit("_"), F.col("salt"))
)
# Assign a random salt to each event row
events_salted = events.withColumn(
"salt", (F.rand() * SALT_FACTOR).cast("int")
)
events_salted = events_salted.withColumn(
"salted_key", F.concat(F.col("user_id"), F.lit("_"), F.col("salt"))
)
# Join on salted key — hot user_id is now spread across 20 partitions
result = events_salted.join(profiles_salted, on="salted_key").drop("salt", "salted_key")
12Beyond MapReduce: Dataflow Systems and the Future
MapReduce was a breakthrough, but its two-stage model (one map phase, one reduce phase, repeat) is inexpressive for complex pipelines. A system like PageRank requires dozens of MapReduce stages; a machine learning training loop requires hundreds. Each stage boundary means a shuffle and a disk write.
The generalization is the dataflow model: represent the computation as a directed acyclic graph (DAG) of operators, where data flows along edges and is processed by operators. Stages are fused wherever possible. This is exactly what Spark's DAG scheduler implements, and it is also the model behind Google's Dataflow (2015, formerly FlumeJava), Apache Beam, and Apache Flink.
Google Dataflow / Apache Beam introduced the Beam model for unified batch and stream processing. The key abstractions are PCollections (distributed datasets), PTransforms (operations), Windows (for grouping stream elements by time), and Triggers (for deciding when to emit windowed results). The same pipeline can run on the Dataflow managed service, on Spark, or on Flink, because Beam is an API layer above multiple runner engines.
Pregel (Google, 2010) is the graph processing model for problems like PageRank and connected components. It models computation as a series of supersteps, where in each superstep every vertex computes a new value based on messages from its neighbors. This vertex-centric model maps naturally to iterative graph algorithms that are awkward to express in MapReduce.
The key lesson of this evolution: the correct abstraction depends on the workload. Batch jobs on bounded data → Spark or Hive. Low-latency stream processing → Flink or Kafka Streams. Large-scale graph algorithms → Pregel or GraphX. SQL analytics on columnar data → BigQuery, Snowflake, or Spark SQL with Parquet. Real systems are built by choosing the right tool for the access pattern.
# Apache Beam unified batch/stream pipeline (Python SDK)
import apache_beam as beam
from apache_beam.options.pipeline_options import PipelineOptions
# This SAME pipeline runs on Dataflow (cloud), Spark, or Flink
# just by changing the runner option.
options = PipelineOptions(runner='DataflowRunner') # or 'SparkRunner'
with beam.Pipeline(options=options) as p:
(
p
| 'ReadLogs' >> beam.io.ReadFromText('gs://bucket/logs/*.gz')
| 'ParseEvent' >> beam.Map(parse_log_line) # map
| 'FilterErrors' >> beam.Filter(lambda e: e['level'] == 'ERROR')
| 'ExtractHost' >> beam.Map(lambda e: (e['host'], 1))
| 'CountPerHost' >> beam.CombinePerKey(sum) # reduce
| 'FormatOutput' >> beam.Map(lambda kv: f"{kv[0]}: {kv[1]}")
| 'WriteResults' >> beam.io.WriteToText('gs://bucket/output/')
)
# Beam handles DAG optimization, shuffle, retries, and progress.
Key insights
- ✦The power of MapReduce is not in the map and reduce functions themselves — it is in what the framework hides: fault tolerance, data locality, shuffle, and load balancing. The programmer's job is to fit their problem into a pure, stateless two-function shape.
- ✦Data locality is the single most important performance optimization in distributed data processing. Moving computation to data is almost always cheaper than moving data to computation — network is 10-100x slower than local disk, which is 10-100x slower than memory.
- ✦The shuffle is always the bottleneck. Any operation requiring all values for a key to be co-located (groupByKey, join, sort) forces a full all-to-all communication across the cluster. Minimizing shuffle size (with combiners, filters before joins, broadcast joins for small tables) is the primary Spark optimization skill.
- ✦Fault tolerance via deterministic re-execution requires that map and reduce functions be pure (no side effects, deterministic output). This constraint is both the price of the model and the source of its scalability — the framework can retry anything without asking permission.
- ✦Columnar storage formats (Parquet, ORC) are often a larger performance win than switching compute engines. Reading only the columns you need, combined with predicate pushdown to skip entire row groups, can reduce I/O by 10-100x on typical analytical queries.
- ✦Lazy evaluation in Spark lets the optimizer see the entire computation graph before executing anything, enabling optimizations (stage fusion, predicate pushdown, join reordering) that are impossible when operations execute eagerly one at a time.
- ✦Batch processing gives exact results on bounded data; stream processing gives timely results on unbounded data. The Lambda and Kappa architectures are engineering responses to the need for both, and the choice between them is a real systems design decision with genuine trade-offs.
Common pitfalls
- ⚠Using groupByKey instead of reduceByKey in Spark. groupByKey shuffles all values across the network before aggregating; reduceByKey applies the combiner locally first (like a map-side combiner), sending far less data. For large groups, groupByKey can cause out-of-memory errors on the reduce side.
- ⚠Ignoring the number of partitions. Leaving the default 200 shuffle partitions for a 10TB job (too few, each partition is 50GB) or for a 1MB job (too many, 200 tasks of 5KB each with massive scheduling overhead) both cause severe performance degradation.
- ⚠Calling collect() on a large RDD/DataFrame. collect() brings the entire dataset to the driver's memory. On a billion-row dataset this causes an out-of-memory error on the driver. Use take(N) or write to storage instead.
- ⚠Not caching an RDD/DataFrame that is used multiple times. If a DataFrame is referenced in two downstream operations, Spark recomputes it from scratch twice (re-reading from disk, re-applying all transformations). Add .cache() or .persist() before the fork.
- ⚠Assuming MapReduce/Spark is always the right tool. For datasets under a few gigabytes, a single-machine solution (pandas, DuckDB, or even SQL on a local Postgres) is simpler, faster, and cheaper. Distributed systems add enormous complexity that is only justified at scale.
- ⚠Writing many small files to HDFS. HDFS is optimized for large sequential reads. Thousands of small files (< 1 MB each) cause NameNode memory pressure and poor read performance. Coalesce or repartition output to a small number of large files.
- ⚠Confusing processing time with event time in stream processing. An event that occurred at 3:00 PM may arrive at the processing system at 3:05 PM. Windowing by processing time gives wrong results for late data; windowing by event time with proper watermarks gives correct results.
Practice
- 01medium
Implement a two-stage MapReduce job (in Python using the mapper/reducer pattern, or in PySpark) to compute the top-10 most frequent bigrams (consecutive word pairs) in a large text corpus. The first stage counts bigrams; the second stage finds the global top-10 from the partial counts. Explain why this requires two stages and cannot be done in one.
- 02medium
Explain why using groupByKey() instead of reduceByKey() in Spark leads to out-of-memory errors at scale, even though both produce the same result for a sum. Then write a benchmark: generate a synthetic skewed dataset (one key has 90% of the values) and measure the execution time and shuffle read bytes for both approaches in the Spark UI.
- 03medium
Implement a simplified in-memory MapReduce framework in Python (no distributed system needed). It should: (1) take a list of (key, value) pairs as input and a user-provided map function and reduce function, (2) simulate the shuffle step by grouping intermediate pairs by key, (3) run the reduce function on each group, and (4) support an optional combiner. Test it on word count and on a non-commutative operation (e.g., string concatenation) to show when a combiner is not safe to use.
- 04hard
Design a system using HDFS and MapReduce (or Spark) to build an inverted index over 10 million web pages stored in HDFS. Define: the input format, the map function, the reduce function, the output format, and how you would handle the fact that a single term like 'the' may appear in millions of documents, creating a hot key.
- 05hard
Prove that MapReduce can compute any function computable by an algorithm with O(n) auxiliary space (where n is the input size), given enough machines. Then give a counter-argument: identify a class of problems that is fundamentally hard to express in MapReduce (requiring many sequential rounds), and describe how Spark's iterative model helps.
- 06hard
Using Spark Structured Streaming, implement a sliding-window word count that reports the top-5 words in every 5-minute window, sliding every 1 minute, reading from a Kafka topic. Describe how Spark handles late-arriving events (events with an event-time timestamp older than the current watermark) and what the trade-off is between watermark lag and result correctness.
Resources
The original Google paper. Short (13 pages), beautifully written, and accessible. Read this first.
The companion to the MapReduce paper. Explains the storage layer assumptions that make MapReduce's design choices sensible.
The Spark RDD paper by Matei Zaharia. Explains the design decisions and the lineage-based fault tolerance model.
Chapters 10 (Batch Processing) and 11 (Stream Processing) are the best textbook treatment of these topics. Highly recommended alongside the original papers.
Lab 1 is a MapReduce implementation in Go. This is the definitive hands-on course for this material.
The programming guides for RDDs, DataFrames, Structured Streaming, and performance tuning are authoritative and well-written.
Foundational paper for the Apache Beam model and modern stream processing. Defines windows, triggers, and watermarks rigorously.
HDFS architecture guide and MapReduce tutorial are good references for the Hadoop ecosystem.
Checkpoints — you can now…
- ✓You can explain the MapReduce programming model — map, shuffle, reduce — and implement word count from scratch, including the combiner optimization, without reference material.
- ✓You can describe GFS/HDFS's key design decisions (large blocks, 3x replication, rack-aware placement, write-once semantics) and explain WHY each decision was made given the workload characteristics of batch processing.
- ✓You can explain Spark's RDD lineage-based fault tolerance, the distinction between narrow and wide dependencies, and why wide dependencies (shuffles) create stage boundaries.
- ✓You can read a Spark UI stage DAG and identify where shuffles occur, diagnose data skew from task time distributions, and prescribe the correct fix (salting, broadcast join, repartition, or AQE).
- ✓You can explain the difference between batch and stream processing, define watermarks and event-time vs processing-time windows, and articulate the trade-off in the Lambda vs Kappa architectures.
- ✓You can explain why columnar storage (Parquet) accelerates analytical queries and how predicate pushdown and partition pruning reduce I/O.
- ✓You can reason about the performance of a MapReduce or Spark job from first principles — estimating shuffle volume, identifying the bottleneck stage, and proposing optimizations grounded in the cost model.