Apache Spark for system design interviews
Open in the interactive app: practice, quiz, mock interview →In one minute
- Apache Spark is a distributed batch engine (with a micro-batch streaming mode) that runs a driver process planning work and many executor processes running it in parallel over partitions of data.
- Your code builds a lazy plan. An action triggers a job, which the scheduler splits into stages at every shuffle (a repartition by key), and each stage into one task per partition.
- The shuffle is the expensive part: map tasks write sorted, partitioned files to local disk and reduce tasks fetch them over the network. Most Spark tuning is about shuffling less or shuffling better.
- Fault tolerance comes from lineage: a lost partition is recomputed from its inputs rather than restored from a replica.
- Choose Spark for large ETL, joins and aggregations over terabytes in object storage, and batch ML. Avoid it for low-latency serving, data that fits on one machine, or tight-latency streaming (use Flink).
How it works
Driver, executors and the cluster manager
The driver runs your main, holds the SparkSession, and plans and schedules work. A cluster manager (YARN, Kubernetes or Spark standalone) launches executors, JVMs with a set number of cores and memory. Each core runs one task at a time, so 100 executors with 4 cores each run 400 tasks concurrently. With Spark Connect (since 3.4) the application is a thin client that sends unresolved logical plans to the driver over gRPC and gets Arrow batches back, so a misbehaving notebook no longer shares the driver's JVM; it gives up the RDD and SparkContext APIs (Spark Connect overview).
From DataFrame to physical plan
DataFrame and SQL code is parsed into a logical plan, which the Catalyst optimizer rewrites: predicate pushdown into Parquet scans, column pruning, constant folding, join reordering. The planner picks physical operators such as sort-merge join or broadcast hash join. Whole-stage code generation then fuses chains of operators into a single generated Java method per stage, avoiding virtual calls per row. Adaptive Query Execution (on by default since 3.2) re-plans at shuffle boundaries using the actual sizes of map outputs (SQL performance tuning). Since 4.0, Spark SQL also runs in ANSI mode by default (spark.sql.ansi.enabled=true): an overflow or invalid cast raises an error at runtime instead of quietly returning null (Spark 4.0 release notes, configuration).
Narrow and wide dependencies
A narrow dependency means each output partition depends on a bounded set of input partitions on the same task: map, filter, union, reading a file. These pipeline inside one task with no data movement. A wide (shuffle) dependency means an output partition needs data from many input partitions: groupByKey, reduceByKey, join on differently partitioned inputs, repartition, distinct. The DAGScheduler walks the lineage backwards from the action and starts a new stage at each shuffle dependency.
Caching and storage
cache() or persist() keeps computed partitions in executor memory or on local disk via the BlockManager, so later jobs skip recomputing them. Output normally goes to object storage in columnar formats (Parquet, ORC) or table formats (Delta Lake, Iceberg, Hudi) that add transactions on top.
Down the stack
Layer 1The contract
Spark promises that an action produces the same result as running the transformation sequentially, as long as your functions are deterministic, regardless of executor failures: lost tasks are retried (spark.task.maxFailures, default 4 consecutive failures of one task) (configuration) and lost shuffle output is recomputed by rerunning parent stages. It does not promise exactly-once side effects from inside tasks, because retried or speculative tasks run your code more than once; writes are made safe by output committers and table formats that commit atomically at the end. Latency is batch-shaped: a job with several shuffles over terabytes takes minutes. Structured Streaming micro-batches can get as low as about 100 ms end to end with exactly-once guarantees, and usually run at seconds (Structured Streaming guide). Spark 4.1 added a real-time mode for continuous, sub-second processing, initially for stateless Scala queries (Spark 4.1 release notes).
Layer 2The mechanism
Stage construction. For the job's final RDD the DAGScheduler creates a ResultStage and walks dependencies: narrow ones join the current stage, and each ShuffleDependency creates or reuses (by shuffle ID) a ShuffleMapStage. A stage is submitted once its parents finish, as a TaskSet with one task per partition. The TaskScheduler prefers executors holding the input (PROCESS_LOCAL, NODE_LOCAL, then ANY), waiting up to spark.locality.wait (3 seconds per locality level) for a better slot.
Sort-based shuffle. Each map task partitions records by the reducer's partitioner (hash of key modulo N), buffering in memory and spilling sorted runs to disk when full. It then merges spills into one data file plus an index of byte offsets per reduce partition: shuffle_<shuffleId>_<mapId>_0.data and .index. The driver's MapOutputTracker records each map output's location and block sizes. Reducers fetch blocks over Netty from remote BlockManagers or the external shuffle service, capped by spark.reducer.maxSizeInFlight (48 MiB). A failed fetch raises FetchFailed, and the parent map stage is resubmitted for the missing outputs. That partial rerun is only correct if the map stage reproduces the same output; Spark 4.1 added a checksum-based full retry of a shuffle stage to catch the cases where it does not (Spark 4.1 release notes).
AQE. After a map stage, AQE reads real partition sizes, coalesces small shuffle partitions (so the default spark.sql.shuffle.partitions=200 matters less), splits skewed join partitions, and switches to a broadcast hash join when a side is under spark.sql.autoBroadcastJoinThreshold (10 MB) (SQL performance tuning).
Memory. spark.memory.fraction (0.6) of heap minus 300 MB is shared by execution and storage; execution can evict cached blocks down to spark.memory.storageFraction (0.5) (Tuning Spark). Tungsten keeps rows in compact binary form, on or off heap.
Layer 3The machine
A Parquet scan reads columns in large sequential ranges, decompresses (Snappy or Zstd) and decodes into vectorised batches; one core can scan several hundred MB/s of compressed data for simple filters (hedge: depends heavily on schema and codec). Object storage adds 20 to 100 ms of first-byte latency per request, which is why files under about 32 MB hurt and 128 MB to 1 GB files work well; spark.sql.files.maxPartitionBytes (128 MB) sets the split size.
With M map tasks and R reduce partitions a shuffle has M x R blocks: 2,000 x 2,000 is 4 million small random reads. On HDDs at 100 to 200 IOPS that is catastrophic; on NVMe it is tolerable. A 2 TB shuffle on 100 nodes with 25 Gbit/s NICs moves 20 GB per node in about 7 seconds of raw transfer, so serialisation, compression and spills usually dominate.
Very large heaps (over about 64 GB) risk multi-second GC pauses, so executors are commonly 4 to 5 cores and 16 to 40 GB with G1 GC.
Why it's built this way
- MapReduce wrote every intermediate step to replicated HDFS, which was slow for multi-step jobs, so Spark keeps intermediate data in memory and pipelines narrow transformations.
- Keeping data only in memory means it can be lost, so Spark records lineage and recomputes lost partitions instead of paying for replication on every step (RDD paper).
- Some operations need all values for a key in one place, which requires moving data across the network; shuffle boundaries are therefore natural checkpoints, and map outputs are written to local disk so a failed reducer does not rerun the whole job.
- Static plans guess wrong about data sizes, so AQE re-plans at each shuffle boundary, where exact sizes are finally known.
- JVM objects waste memory and stress the GC, so Tungsten stores binary rows and generates code that operates on them directly.
Diagram
flowchart LR
subgraph S0["Stage 0 (ShuffleMapStage)"]
R["read Parquet"] --> F["filter"] --> M["map to (key, 1)"]
end
M --> SW["shuffle write: .data + .index per map task"]
SW --> MOT["MapOutputTracker (driver)"]
subgraph S1["Stage 1 (ResultStage)"]
FE["shuffle fetch blocks"] --> AGG["reduceByKey"] --> W["write output"]
end
MOT --> FE
SW --> FEWorked example
A nightly job joins 3 TB of clickstream events (Parquet, 30 days) with a 40 GB user table and a 200 MB country table, then aggregates sessions per user per day.
Scanning 3 TB at 128 MB per split is about 24,000 tasks; on 250 cores at about 10 seconds per task, that is 96 waves, roughly 16 minutes. Pushing the date filter into Parquet and reading 8 of 60 columns cuts bytes read to about a quarter.
The country table is broadcast (200 MB to 50 executors, 10 GB of network), so its join needs no shuffle. The user join is a sort-merge join with both sides shuffled by user_id; the events side shuffles about 600 GB. With the default 200 partitions that is 3 GB per reduce task, which spills heavily, so the job starts at 3,000 partitions (200 MB each) and lets AQE coalesce. A bot user with 2 percent of events creates a 12 GB partition; AQE skew handling splits it, or bots are filtered first. The final groupBy(user_id, date) reuses the user_id partitioning and does not shuffle again.
In the wild
- Apache Spark, 2018: a retry that changed the answer. SPARK-23207, a blocker labelled "correctness":
repartitionuses round-robin partitioning, whose output depends on row order. When an executor died in a job with several shuffles, retried tasks could see rows in a different order and produce different data; the reporter's job returned 931,532 rows instead of 1,000,000. Fixed in 2.1.4, 2.2.3 and 2.3.0, it is the lineage contract failing on non-deterministic input, the same reason 4.1 added checksum-based stage retries (Apache JIRA). - Uber, 2018: rewriting 100 TB to add 100 GB. Uber's Spark ingestion rebuilt whole tables from snapshots: one run took over 20 hours on more than 1,000 executors, rewrote a table of over 100 TB for about 100 GB of new daily data, and made data visible only every 24 hours. Moving to Hudi upserts and incremental pulls cut latency to under an hour. The cost of a batch job is the bytes it rewrites, not the bytes that changed (Uber Engineering).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Spark | Mature, SQL plus code, scales to petabytes, rich ecosystem | Shuffle and JVM tuning, seconds of overhead per job | Large batch ETL, joins, ML feature pipelines |
| Flink | True streaming, event time, large state | Smaller batch ecosystem | Low-latency stateful streaming |
| Warehouse SQL (BigQuery, Snowflake, Redshift) | No cluster tuning, fast interactive SQL | Cost at very large scale, less control over custom code | SQL analytics and BI |
| Trino or Presto | Fast interactive federated queries | Not built for long fault-tolerant ETL | Ad hoc queries over a lake |
| Single node (DuckDB, Polars) | No distribution overhead, very fast up to hundreds of GB | One machine's limits | Data fits on one large box |
Pitfalls in production
- Small files. Thousands of tiny files mean tiny tasks and slow listing. Compact to 128 MB to 1 GB files.
- Skewed keys. One task runs for an hour while 999 finish in a minute. Enable AQE skew handling or salt hot keys.
collect()on big data. The driver runs out of memory. Write results out instead.- Wrong shuffle partition count. Too few spill, too many add scheduling overhead. Aim for 100 to 200 MB per partition.
- Python UDFs. Row-at-a-time UDFs serialise every row to Python. Prefer built-ins or Arrow-based pandas UDFs.
- Non-deterministic tasks. Random IDs or
now()change on recomputation. Derive them deterministically. - Upgrading to 4.x without testing SQL. ANSI mode turns silent nulls into job failures, and 4.0 requires Java 17 and Scala 2.13 (Spark 4.0 release notes). Run the old and new versions on the same input and diff the outputs before switching.
Leadership lens
- In review, ask for the physical plan (
explain) and the Spark UI stage view of a representative run; the number of shuffles and their sizes predict cost better than code reading. - Decide on a table format (Delta, Iceberg) early; it determines how writes commit atomically, how small files get compacted and how schemas evolve.
- Cluster cost is mostly executor-hours, so right-sizing, spot instances for stateless executors and autoscaling matter more than micro-optimising code.
- Ask whether the data really needs a cluster; many "big data" jobs fit in memory on one large machine.
Check yourself
Why does
reduceByKeyusually shuffle less data thangroupByKeyfollowed by a sum?Show answer
reduceByKeycombines values per key on the map side before the shuffle, so each map task sends one partial sum per key instead of every value.A reduce task fails with
FetchFailed. What does Spark do?Show answer
It marks that location's map outputs lost, reruns the parent map stage for just those outputs, then retries the reduce stage.
Why do two joins on the same key not always need two shuffles?
Show answer
If both inputs of the second join are already hash-partitioned on that key with the same partition count, the dependency is narrow and Spark reuses the existing partitioning.
Why is a single giant executor with 64 cores and 512 GB heap usually worse than many smaller ones?
Show answer
Large heaps cause long GC pauses, and one failure loses much more work and cached data.
Sources and further study
Primary
- MapReduce: Simplified Data Processing on Large Clusters (OSDI 2004): the map, shuffle, reduce model and re-execution on failure that Spark generalises.
- Spark SQL: Relational Data Processing in Spark (SIGMOD 2015): the DataFrame API and the Catalyst optimizer.
- Structured Streaming (SIGMOD 2018): streaming as incremental execution of a batch query, and its micro-batch design.
Systems docs
- Spark 4.0.0 release notes: ANSI mode by default, JDK 17 and Scala 2.13, Spark Connect changes.
- Spark 4.1.0 release notes: real-time mode, declarative pipelines and checksum-based shuffle retries.
- Spark configuration reference: current defaults for scheduling, shuffle, memory and SQL.
Books
- Designing Data-Intensive Applications, 2nd ed., Kleppmann and Riccomini, Ch 11 "Batch Processing" (Batch Processing in Distributed Systems; Batch Processing Models).
- Designing Data-Intensive Applications, 1st ed., Kleppmann, Ch 10 "Batch Processing" (Beyond MapReduce: dataflow engines, materialisation and fault tolerance).
Other curricula
- Azure Architecture Center: choose a batch processing technology: frames Spark-based services by managed versus self-run, bursty workloads and auto-termination.
- roadmap.sh: Software Architect: lists Apache Spark under big data tools an architect should be able to place, next to Hadoop MapReduce.
Watch
- A Deeper Understanding of Spark Internals (Spark Summit): Aaron Davidson on the execution model, stages, the shuffle and common performance problems.
Free resources
- RDD programming guide: official explanation of transformations, actions, shuffles and persistence.
- Performance tuning for Spark SQL: AQE, join strategies, skew handling and partition settings.
- Tuning Spark: memory management, serialisation and GC guidance.
- Job scheduling: how applications and jobs share a cluster, including dynamic allocation.
- Resilient Distributed Datasets (NSDI 2012): the original paper on RDDs, lineage and narrow versus wide dependencies.