Apache Spark for Data Engineers: RDDs, DataFrames, and Structured Streaming
When Pandas Hits the Wall: The 500GB Problem
Apache Spark distributes Python DataFrame jobs across a cluster of executors, using lazy evaluation and the Catalyst query optimizer to process terabytes with the same code that works on gigabytes.
π― System Scenario & Requirements
System Goal
Apache Spark distributes Python DataFrame jobs across a cluster of executors, using lazy evaluation and the Catalyst query optimizer to process terabytes with the same code that works on gigabytes.
Actors & Components
- Edge Traffic Ingress
- Stateless Microservices
- Datastore Infrastructure
- API Gateway
- Domain Service
- Redis Cache
- Transactional DB
Functional Requirements
- TLDR: Apache Spark distributes Python DataFrame jobs across a cluster of executors, using lazy evaluation and the Catalyst query optimizer to process terabytes with the same code that works on gigabytes.
- Master partitioning, shuffle-awareness, and Structured Streaming and you have a single engine for both batch and real-time pipelines.
- <!-- Picture this: a data engineer writes a clean pandas script that reads 500MB of e-commerce event data, filters it, joins it to a product catalog, and produces a category rollup.
Constraints & Assumptions
- Operational Complexity Spark delivers horizontal scale but introduces non-trivial operational overhead: cluster sizing, executor memory tuning, shuffle partition selection, and network-intensive shuffle operations all rβ¦
- Pandas on a large machine is often faster and simpler for datasets below 50 GB.
β‘ Execution Stages & Decision Trace
Stage 1 β RDDs: Spark's Original Distributed Dataset Abstraction
Before DataFrames, Spark exposed data as Resilient Distributed Datasets (RDDs) .
An RDD is an immutable, fault-tolerant, distributed collection of records partitioned across a cluster.
Deep Dive
Scenario context
Apache Spark distributes Python DataFrame jobs across a cluster of executors, using lazy evaluation and the Catalyst query optimizer to process terabytes with the same code that works on gigabytes. Master partitioning, shuffle-awareness, and Structured Streaming and you have a single engine for both batch and real-time pipelines.
<!--
Picture this: a data engineer writes a clean pandas script that reads 500MB of eβ¦
This walkthrough focuses on When Pandas Hits the Wall: The 500GB Problem.
Before making a design choice, clarify:
- Picture this: a data engineer writes a clean pandas script that reads 500MB of e-commerce event data, filters it, joins it to a product catalog, and produces a category rollup.
- It runs in 40 seconds on a laptop.
- The team ships it to production.
Concepts in play: spark-data-engineering, jvm-internals-and-garbage-collection, database-sharding, data-warehousing-lakehouse
Stage 1 β RDDs: Spark's Original Distributed Dataset Abstraction
- Before DataFrames, Spark exposed data as Resilient Distributed Datasets (RDDs) .
- An RDD is an immutable, fault-tolerant, distributed collection of records partitioned across a cluster.
- Three properties define it: - Immutable : you never modify an RDD in place; every transformation produces a new RDD.
- Distributed : partitions live on different executor nodes and are processed in parallel.
# RDD-style wordcount β verbose and unoptimized
lines = sc.textFile("s3://bucket/logs/*.txt")
words = lines.flatMap(lambda line: line.split())
pairs = words.map(lambda w: (w, 1))
counts = pairs.reduceByKey(lambda a, b: a + b)
counts.saveAsTextFile("s3://bucket/output/wordcount") # β action triggers execution
Observable outcome: - Distributed : partitions live on different executor nodes and are processed in parallel.
Stage 2 β Driver, Executors, and the DAG: How Spark Distributes Work
- Understanding where your code runs is the most important mental model for a Spark engineer.
- Driver : the process running your Python script.
- It hosts the SparkSession , parses your DataFrame transformations, builds a logical plan, hands it to the Catalyst optimizer, and ultimately sends tasks to executors.
- The driver is the brain β it does no data crunching.
# DataFrame-style β shorter, and Catalyst-optimized
from pyspark.sql import SparkSession
from pyspark.sql.functions import explode, split, col
spark = SparkSession.builder.appName("wordcount").getOrCreate()
df = spark.read.text("s3://bucket/logs/*.txt")
counts = (
df.select(explode(split(col("value"), " ")).alias("word"))
.groupBy("word")
.count()
)
counts.write.parquet("s3://bucket/output/wordcount")
Observable outcome: The driver is the brain β it does no data crunching.
Stage 3 β Visualizing Spark's Execution: From Python Call to JVM Task
- The journey from a Python groupBy().count() call to bytes on disk passes through five layers: Key insight : stages are separated by shuffles β the most expensive operation in Spark, because shuffle requires moving dataβ¦
- Every groupBy , join (non-broadcast), and repartition triggers a shuffle.
- Minimizing shuffles is the single most impactful Spark performance tuning lever.
flowchart TD
A[" Python DataFrame API (filter join groupBy)"] --> B["Logical Plan (unresolved operators)"]
B --> C["Catalyst Optimizer (predicate pushdown column pruning join reordering)"]
C --> D["Optimized Physical Plan (scan hash-aggregate sort-merge-join)"]
D --> E["DAG of Stages (split at shuffle boundaries)"]
E --> F["Tasks (one per partition)"]
F --> G["Executor Cores (JVM Tungsten UnsafeRow)"]
G --> H[" Output (S3 / HDFS / Delta Lake)"]
Observable outcome: Minimizing shuffles is the single most impactful Spark performance tuning lever.
Stage 4 β Deep Dive: Catalyst, Tungsten, and How Python DataFrames Become JVM Bytecode
- Spark's Internals: DAGScheduler, BlockManager, and Shuffle When an action fires, the DAGScheduler takes the optimized physical plan and divides it into stages.
- A new stage begins whenever a shuffle is required.
- Within a stage, tasks are independent β they can run fully in parallel without exchanging data.
- The TaskScheduler assigns tasks to available executor slots (cores Γ threads).
Observable outcome: The TaskScheduler assigns tasks to available executor slots (cores Γ threads).
Stage 5 β Processing a 1-Billion-Row E-Commerce Clickstream in PySpark
- Example: Category-Level Add-to-Cart Funnel The dataset: {user id, product id, event type, timestamp} stored as Parquet on S3, partitioned by date .
- Goal: count add to cart events per product category over the last 30 days.
- What's happening under the hood: - filter(col("date") = ...) triggers partition pruning β Spark only lists and reads the last 30 date-partitioned directories from S3, scanning perhaps 30 TB of a 365-day archive down toβ¦
- The products table is small enough for a broadcast join : the driver fetches a copy and pushes it to every executor, eliminating the shuffle entirely.
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, to_date, current_date, date_sub
spark = (
SparkSession.builder
.appName("clickstream-category-funnel")
.config("spark.sql.shuffle.partitions", "400") # tune for ~128 MB per partition
.config("spark.sql.autoBroadcastJoinThreshold", "50mb") # broadcast the small products table
.getOrCreate()
)
# ββ 1. Read partitioned Parquet β Spark only scans the last-30-days date partitions
events = (
spark.read
.parquet("s3://data-lake/clickstream/")
.filter(col("event_type") == "add_to_cart")
.filter(col("date") >= date_sub(current_date(), 30)) # partition pruning
)
# ββ 2. Load the products dimension table (small β fits in broadcast memory)
products = spark.read.parquet("s3://data-lake/products/") # ~200 MB β triggers broadcast join
# ββ 3. Join to get category β broadcast join avoids a shuffle on the products side
enriched = events.join(products, on="product_id", how="inner")
# ββ 4. Aggregate: count add_to_cart events per category
result = (
enriched
.groupBy("category")
.count()
.withColumnRenamed("count", "add_to_cart_events")
.orderBy(col("add_to_cart_events").desc())
)
# ββ 5. Write as partitioned Parquet β downstream jobs can prune by run_date
result.write.mode("overwrite").parquet("s3://data-lake/reports/category_funnel/")
spark.stop()
Observable outcome: - The products table is small enough for a broadcast join : the driver fetches a copy and pushes it to every executor, eliminating the shuffle entirely.
Stage 6 β Real-World Applications: Spark SQL and Structured Streaming at Scale
- Spark SQL: Running SQL on DataFrames Any DataFrame can be registered as a temporary view and queried with standard SQL.
- This is not a conversion β it is the same Catalyst optimizer, the same physical plan.
- SQL and DataFrame API are interchangeable: Spark SQL integrates with the Hive Metastore: you can define tables with schemas, partitioning, and SerDes, then query them with spark.sql("SELECT ...") without ever specifyingβ¦
- This is how most warehouse-style ETL jobs are structured in practice.
# Register the enriched DataFrame as a SQL-queryable view
enriched.createOrReplaceTempView("clickstream_enriched")
# Run SQL β Catalyst compiles it identically to the DataFrame API
top_categories = spark.sql("""
SELECT category,
COUNT(*) AS add_to_cart_events
FROM clickstream_enriched
WHERE event_type = 'add_to_cart'
GROUP BY category
ORDER BY add_to_cart_events DESC
LIMIT 20
""")
top_categories.show()
# Streaming word count from Kafka β same DataFrame API, continuous execution
words = (
spark.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "broker:9092")
.option("subscribe", "logs-topic")
.load()
.selectExpr("CAST(value AS STRING) AS line")
.selectExpr("explode(split(line, ' ')) AS word")
)
word_counts = words.groupBy("word").count()
query = (
word_counts.writeStream
.format("delta") # Delta Lake for ACID output
.outputMode("update") # emit only changed rows each micro-batch
.option("checkpointLocation", "s3://checkpoints/wordcount/")
.start("s3://data-lake/wordcount/")
)
query.awaitTermination()
Observable outcome: This is how most warehouse-style ETL jobs are structured in practice.
End-to-end outcome
After working through the stages, you should be able to explain:
- Don't use the default shuffle partition count ( spark.sql.shuffle.partitions=200 ).
- For a 10 GB dataset that's 50 MB per partition β fine.
Alternatives considered
Partitioning Strategy: The 128MB Rule, repartition vs coalesce, and Column Partitioning
Partition count controls parallelism.
Decision Guide: When to Use Spark vs Alternatives
Decision Guide: When to Use Spark vs Alternatives
Failure and degraded modes
Performance vs. Operational Complexity
- Detection: Spark delivers horizontal scale but introduces non-trivial operational overhead: cluster sizing, executor memory tuning, shuffle partition selection, and network-intensive shuffle operations all requβ¦
- User or operator impact: Spark delivers horizontal scale but introduces non-trivial operational overhead: cluster sizing, executor memory tuning, shuffle partition selection, and network-intensive shuffle operations all requβ¦
Failure Modes
- Detection: Data skew β one partition is 10Γ larger than the rest because the join key is highly concentrated (e.g., a single user id generated 40% of events).
- User or operator impact: Data skew β one partition is 10Γ larger than the rest because the join key is highly concentrated (e.g., a single user id generated 40% of events).
Mitigation Patterns
- Detection: - Profile with Spark UI (port 4040): examine stage timelines, task duration distributions, and shuffle read/write sizes.
- User or operator impact: - Profile with Spark UI (port 4040): examine stage timelines, task duration distributions, and shuffle read/write sizes.
