Interview Prep/Spark & Distributed Processing
Spark Performance Tuning and Data Skew
The practical playbook for making a slow Spark job fast — spotting skew, choosing the right join strategy, fixing small files, and reading the Spark UI like an engineer, not a tourist.
This is where senior candidates are separated from mid-level ones. Anyone can say 'add more executors.' You should be able to say which metric you looked at, what it told you, and why your fix addressed the actual bottleneck.
Step 1 — Diagnose before you tune
| Symptom in the Spark UI | Likely cause | Fix |
|---|---|---|
| One task runs 30× longer than the median in a stage | Data skew — one key holds most of the rows | Salting, AQE skew join, or broadcast the small side |
| High 'Shuffle Spill (Disk)' | Partitions too big for executor memory | More shuffle partitions, or more memory per executor |
| Thousands of tiny tasks, low CPU usage | Too many partitions / small files | coalesce, compaction, or fewer shuffle partitions |
| Long GC time on executors | Over-caching or too-large partitions | Unpersist, reduce partition size, tune memory fraction |
| Stage reads far more data than expected | No predicate/partition pushdown | Filter on the partition column; avoid UDFs before the filter |
Step 2 — Kill the skew
Skew is the number-one cause of 'my job hangs at 199/200 tasks.' In telecom data it is everywhere: one busy cell site, one default unknown device id, one null subscriber key holding millions of rows.
# 1. CONFIRM the skew — never guess
(df.groupBy("cell_site_id")
.count()
.orderBy(F.col("count").desc())
.show(20))
# 2a. Easiest fix: let AQE handle it (Spark 3+)
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
# 2b. Broadcast the small side so there is no shuffle at all
result = big_df.join(F.broadcast(small_dim), "cell_site_id")
# 2c. Salting — when one key is genuinely enormous
SALT = 16
left = big_df.withColumn("salt", (F.rand() * SALT).cast("int"))
right = (small_df
.withColumn("salt_arr", F.array([F.lit(i) for i in range(SALT)]))
.withColumn("salt", F.explode("salt_arr"))
.drop("salt_arr"))
joined = left.join(right, ["cell_site_id", "salt"]).drop("salt")Tip — Explain salting in one breath
'I add a random number 0–15 to the hot key on the big side, and replicate the small side 16 times, one copy per salt value. Now that one hot key becomes 16 medium keys spread across 16 tasks instead of crushing one.'
Step 3 — Pick the right join strategy
| Strategy | How it works | Best when |
|---|---|---|
| Broadcast hash join | Small table is copied to every executor; no shuffle on the big side | One side fits in memory (default threshold 10 MB; often raised to 100–500 MB) |
| Sort-merge join | Both sides shuffled by key, sorted, then merged | Two large tables — the default for big-to-big |
| Shuffle hash join | Both shuffled; one side built into a hash map | Medium tables where sorting is wasteful |
# Raise the broadcast threshold when your dimension is bigger than 10 MB
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", 200 * 1024 * 1024)
# Verify what Spark actually chose — do this, do not assume
joined.explain(mode="formatted") # look for BroadcastHashJoin vs SortMergeJoinStep 4 — Fix the small-file problem
Streaming jobs and over-partitioned batch writes create millions of tiny files. Every file costs a listing call and a task, so reads get slower and slower over time.
- Target file sizes of roughly 128 MB–1 GB
coalesce()before writing, or setspark.sql.adaptive.coalescePartitions.enabled- Run a compaction job that rewrites yesterday's small files into big ones
- Do not over-partition on disk — partitioning by
date/hour/sitewhen a site has 10 rows an hour is the cause, not the cure
Step 5 — Memory and resource sizing
# A sane starting point for a large batch job
--executor-memory 16g # heap per executor
--executor-cores 4 # 3-5 is the sweet spot for HDFS/GCS throughput
--num-executors 40
--conf spark.memory.fraction=0.6 # execution + storage share
--conf spark.sql.shuffle.partitions=800 # ~= 2-3x total cores
--conf spark.sql.adaptive.enabled=trueWatch out — Why not 1 executor with 64 cores?
More than about 5 cores per executor causes HDFS/GCS throughput to collapse from I/O contention, and one huge JVM means brutal garbage-collection pauses. Several medium executors beat one giant one — a classic follow-up question.
Practice questions
QYour daily Spark job suddenly takes 4 hours instead of 40 minutes. Nothing in the code changed. What do you check?show
Something in the data or the cluster changed. Compare a good run to a bad run in the Spark History Server rather than tweaking configs blindly.
- Input volume — did the source double? Did a backfill land in the same partition?
- Skew — check task duration distribution in the slow stage; a new hot key is the most common cause
- Small files — did an upstream streaming job start emitting millions of tiny files?
- Cluster contention — did another team's job take the queue/slots?
- Data layout — did someone drop the partitioning, so you now scan the full history?
Likely follow-up: Which Spark UI page tells you it is skew and not just volume?
QWhat is Adaptive Query Execution and what does it actually change?show
AQE (Spark 3+) re-optimises the physical plan at runtime using real statistics from completed stages, instead of relying only on estimates made before the job started.
- Coalesces shuffle partitions — fixes the 'default 200 partitions' problem automatically
- Switches sort-merge join to broadcast when a side turns out smaller than estimated
- Splits skewed partitions into smaller sub-partitions when
skewJoinis enabled - Honest caveat: it helps a lot but does not fix badly modelled data or missing partition filters
QHow do you decide between Spark and BigQuery for a transformation?show
If the work is expressible in SQL over data already in BigQuery, do it in BigQuery — no cluster to manage and the optimiser is excellent. Reach for Spark when you need something SQL cannot do well.
- BigQuery — SQL aggregations, joins, dbt models, anything already warehouse-resident
- Spark — complex procedural logic, ML feature pipelines, custom file-format parsing, heavy non-SQL transforms
- Cost angle — BigQuery charges per byte scanned (or per slot); Spark charges for cluster uptime whether busy or idle
- In a migration, moving Hive/Spark SQL batch jobs to BigQuery-native SQL is usually the biggest win
QWhat causes an OutOfMemoryError on the driver versus on an executor?show
They have different causes and different fixes — knowing which is which is a strong signal.
- Driver OOM — usually
collect(),toPandas(), a huge broadcast, or too many tasks' metadata. Fix: do not pull data to the driver - Executor OOM — partitions too large, heavy skew, too much cached data, or an expensive UDF. Fix: more/smaller partitions, unpersist, fix skew