Chapter 35: Batch Processing

What is Batch Processing

Batch processing handles large volumes of data in discrete chunks, typically on a scheduled basis. Unlike stream processing which operates on data in real-time, batch systems process accumulated data all at once, optimizing for throughput over latency.

Common Use Cases

Batch vs Stream Processing

Aspect Batch Stream
Latency Minutes to hours Milliseconds to seconds
Data Volume Unbounded (terabytes+) Continuous flow
Processing Model Scheduled, finite datasets Continuous, infinite streams
Fault Tolerance Reprocess entire batch Checkpointing, replay
Complexity Simpler (no state mgmt) Windowing, state, watermarks
Cost Efficiency High (amortized over volume) Always-on infrastructure
Use Cases Reports, ML, ETL Fraud detection, monitoring
Interview Question: When would you choose batch over stream processing?

Answer: Choose batch when: (1) latency requirements are relaxed (hourly/daily acceptable), (2) processing logic benefits from seeing complete datasets (global aggregations, sorting), (3) cost efficiency matters (batch can use spot instances), (4) simplicity is valued (no state management complexity). Choose stream when latency is critical or when insights must be acted upon immediately.

MapReduce

MapReduce, pioneered by Google in 2004, was the foundational programming model for distributed batch processing. While largely superseded by Spark, understanding MapReduce illuminates core distributed computing concepts.

Three-Phase Model

graph TB subgraph Input I1[Input Split 1] I2[Input Split 2] I3[Input Split 3] end subgraph Map Phase M1[Mapper 1
emit key-value pairs] M2[Mapper 2
emit key-value pairs] M3[Mapper 3
emit key-value pairs] end subgraph Shuffle Phase S1[Partition & Sort
by key] S2[Group values
by key] end subgraph Reduce Phase R1[Reducer 1
aggregate values] R2[Reducer 2
aggregate values] end subgraph Output O1[Output File 1] O2[Output File 2] end I1 --> M1 I2 --> M2 I3 --> M3 M1 --> S1 M2 --> S1 M3 --> S1 S1 --> S2 S2 --> R1 S2 --> R2 R1 --> O1 R2 --> O2
  1. Map Phase: Process input splits in parallel, emit key-value pairs
  2. Shuffle Phase: Partition, sort, and group intermediate data by key across network
  3. Reduce Phase: Aggregate values for each key, write final output

Word Count Example

// Map function: emit (word, 1) for each word
map(String doc_id, String content):
    for each word w in content:
        emit(w, 1)

// Reduce function: sum counts for each word
reduce(String word, Iterator<Integer> counts):
    int sum = 0
    for each count in counts:
        sum += count
    emit(word, sum)

// Input: ["hello world", "hello hadoop"]
// Map output: [(hello, 1), (world, 1), (hello, 1), (hadoop, 1)]
// Shuffle groups: {hello: [1, 1], world: [1], hadoop: [1]}
// Reduce output: [(hello, 2), (world, 1), (hadoop, 1)]

Why MapReduce is Largely Replaced

Spark addresses these limitations with in-memory computation, flexible DAGs, and richer APIs.

Apache Spark

Apache Spark is a unified analytics engine for large-scale data processing, offering up to 100x faster performance than MapReduce through in-memory computation and optimized execution plans.

Core Abstractions

Key Concepts

Spark Architecture

graph TB subgraph Client Driver[Driver Program
SparkContext
DAG Scheduler
Task Scheduler] end subgraph Cluster Manager CM[YARN / K8s / Mesos
Resource Allocation] end subgraph Worker Node 1 E1[Executor 1
Cache
Tasks] end subgraph Worker Node 2 E2[Executor 2
Cache
Tasks] end subgraph Worker Node 3 E3[Executor 3
Cache
Tasks] end Driver -->|Request Resources| CM CM -->|Launch Executors| E1 CM -->|Launch Executors| E2 CM -->|Launch Executors| E3 Driver -->|Send Tasks| E1 Driver -->|Send Tasks| E2 Driver -->|Send Tasks| E3 E1 -->|Report Status| Driver E2 -->|Report Status| Driver E3 -->|Report Status| Driver

Example: DataFrame API

from pyspark.sql import SparkSession
from pyspark.sql.functions import col, sum

spark = SparkSession.builder.appName("Sales").getOrCreate()

# Read Parquet files
sales = spark.read.parquet("s3://bucket/sales/")

# Lazy transformations (build execution plan)
result = sales \
    .filter(col("date") >= "2024-01-01") \
    .groupBy("product_id") \
    .agg(sum("revenue").alias("total_revenue")) \
    .orderBy(col("total_revenue").desc())

# Action triggers execution
result.show(10)  # Display top 10
result.write.parquet("s3://bucket/results/")  # Persist
Interview Question: Why is Spark faster than MapReduce?

Answer: (1) In-memory processing avoids disk I/O between stages. (2) DAG execution allows multi-stage pipelines without intermediate writes. (3) Catalyst optimizer rewrites logical plans (predicate pushdown, projection pruning). (4) Tungsten execution engine uses code generation and off-heap memory management. (5) Lazy evaluation enables end-to-end optimization before execution.

Spark Execution Model

Understanding how Spark translates high-level code into distributed execution is critical for optimization.

DAG of Stages

Spark compiles transformations into a Directed Acyclic Graph (DAG) of stages. Stage boundaries occur at shuffle operations (wide transformations).

graph LR subgraph Stage 1 A[Read Parquet
Partition 1-4] B[Filter
Map] end subgraph Shuffle S[Shuffle Exchange
Repartition by key] end subgraph Stage 2 C[Reduce
Aggregate] D[Sort] end subgraph Stage 3 E[Write Output] end A --> B B --> S S --> C C --> D D --> E style S fill:#ff9999

Narrow vs Wide Transformations

Type Description Examples Shuffle
Narrow Each input partition contributes to at most one output partition map(), filter(), union(), coalesce() No
Wide Input partitions contribute to multiple output partitions groupBy(), join(), repartition(), sortBy() Yes

Narrow transformations can be pipelined in a single stage. Wide transformations require shuffle, creating stage boundaries.

Partitioning

Data is divided into partitions, the unit of parallelism. Each partition is processed by one task on one executor core.

Execution Flow

  1. Logical Plan: High-level DataFrame/SQL operations
  2. Optimized Plan: Catalyst optimizer applies rules (predicate pushdown, constant folding)
  3. Physical Plan: Concrete execution strategy (broadcast join vs shuffle join)
  4. DAG of Stages: Split at shuffle boundaries
  5. Task Scheduling: Driver assigns tasks to executors (data locality preferred)
  6. Execution: Tasks process partitions, shuffle if needed, return results
// Example: view execution plan
df.explain()  // Shows logical and physical plans
df.explain(mode="extended")  // Detailed optimization steps

Shuffle Optimization

Shuffle is the most expensive operation in Spark, involving disk writes, network transfer, and sorting. Optimizing shuffle is key to performance.

Why Shuffle is Expensive

Optimization Strategies

1. Partition Pruning

Reduce shuffle volume by filtering before wide transformations.

// Bad: shuffle entire dataset, then filter
df.groupBy("user_id").agg(sum("amount")).filter(col("date") == "2024-01-15")

// Good: filter first, shuffle less data
df.filter(col("date") == "2024-01-15").groupBy("user_id").agg(sum("amount"))

2. Broadcast Joins

When joining a large table with a small table (< 10MB default), broadcast the small table to all executors. Avoids shuffling the large table.

from pyspark.sql.functions import broadcast

# Broadcast small dimension table to avoid shuffle
large_df.join(broadcast(small_df), "key")

3. Bucketing

Pre-partition and sort tables on join keys during write. Future joins on same key avoid shuffle.

// Write bucketed table
df.write \
  .bucketBy(100, "user_id") \
  .sortBy("user_id") \
  .saveAsTable("users_bucketed")

// Join bucketed tables - no shuffle needed
users_bucketed.join(orders_bucketed, "user_id")

4. Salting for Skew

When keys are heavily skewed (e.g., 80% of data has same key), add random salt to distribute load.

from pyspark.sql.functions import rand, concat, lit

# Add salt to skewed key
df_salted = df.withColumn("salt", (rand() * 10).cast("int")) \
              .withColumn("salted_key", concat(col("skewed_key"), lit("_"), col("salt")))

# Aggregate with salted key
result = df_salted.groupBy("salted_key").agg(sum("value"))

# Remove salt and aggregate again
final = result.withColumn("original_key",
    split(col("salted_key"), "_")[0]).groupBy("original_key").agg(sum("value"))

5. Coalesce vs Repartition

6. Tune Shuffle Partitions

Default shuffle partitions (200) may be too small for large data or too large for small data.

spark.conf.set("spark.sql.shuffle.partitions", "1000")  # Increase for large data
spark.conf.set("spark.sql.adaptive.enabled", "true")  # Auto-tune partitions (Spark 3.0+)
Interview Question: You have a 1TB table joining with a 50MB table. Both are shuffling. How do you optimize?

Answer: Use broadcast join. Wrap the 50MB table with broadcast() hint to replicate it to all executors, avoiding shuffle of the 1TB table. If 50MB exceeds broadcast threshold, increase spark.sql.autoBroadcastJoinThreshold. Alternatively, if the small table is slightly larger, consider bucketing the large table on join key in advance.

Data Formats

Choosing the right storage format significantly impacts batch processing performance. Columnar formats (Parquet, ORC) are preferred for analytics.

Format Comparison

Format Type Compression Schema Evolution Splittable Best For
Parquet Columnar Excellent (10:1) Yes (add/remove columns) Yes Analytics, read-heavy
ORC Columnar Excellent (10:1) Yes Yes Hive, analytics
Avro Row-based Good (5:1) Excellent (forward/backward) Yes Write-heavy, streaming
Delta Lake Parquet + ACID Excellent Yes Yes ACID transactions, upserts
JSON Row-based Poor (2:1) Flexible Yes (line-delimited) Semi-structured, debug
CSV Row-based Poor No Yes Human-readable, export

Parquet (Recommended for Analytics)

Delta Lake (ACID on Data Lake)

Delta Lake adds transactional guarantees to Parquet files, enabling reliable upserts and time travel.

from delta.tables import DeltaTable

# Write Delta table
df.write.format("delta").save("/data/events")

# Upsert (merge)
deltaTable = DeltaTable.forPath(spark, "/data/events")
deltaTable.alias("target").merge(
    updates.alias("source"),
    "target.id = source.id"
).whenMatchedUpdate(set = {"status": "updated"}) \
 .whenNotMatchedInsert(values = {...}) \
 .execute()

# Time travel
spark.read.format("delta").option("versionAsOf", 5).load("/data/events")

Orchestration

Batch jobs rarely run in isolation. Orchestration systems manage dependencies, scheduling, retries, and monitoring across complex pipelines.

Apache Airflow

Airflow defines workflows as Python code (DAGs), with rich operators for common tasks and strong community support.

from airflow import DAG
from airflow.providers.apache.spark.operators.spark_submit import SparkSubmitOperator
from airflow.operators.python import PythonOperator
from datetime import datetime, timedelta

default_args = {
    'owner': 'data-eng',
    'retries': 3,
    'retry_delay': timedelta(minutes=5)
}

with DAG('sales_etl',
         default_args=default_args,
         schedule_interval='@daily',
         start_date=datetime(2024, 1, 1),
         catchup=False) as dag:

    extract = SparkSubmitOperator(
        task_id='extract_sales',
        application='/jobs/extract.py',
        conf={'spark.executor.memory': '4g'}
    )

    transform = SparkSubmitOperator(
        task_id='transform_sales',
        application='/jobs/transform.py'
    )

    load = SparkSubmitOperator(
        task_id='load_warehouse',
        application='/jobs/load.py'
    )

    notify = PythonOperator(
        task_id='send_notification',
        python_callable=send_slack_message
    )

    extract >> transform >> load >> notify  # Define dependencies

Key Orchestration Features

Alternatives

Interview Question: How do you handle backfills when a pipeline fails for 7 days?

Answer: (1) Fix the bug causing failures. (2) In Airflow, clear failed task instances for the date range, or trigger backfill via CLI: airflow dags backfill -s 2024-01-01 -e 2024-01-07 sales_etl. (3) Ensure idempotency: rerunning should produce same results (use upserts/overwrite mode). (4) Consider increasing parallelism if catching up is time-sensitive. (5) Monitor resource usage to avoid overwhelming cluster.

Fault Tolerance

Batch jobs can run for hours on thousands of nodes. Fault tolerance mechanisms ensure job completion despite node failures.

Spark Fault Tolerance Mechanisms

1. Lineage-Based Recovery

Spark tracks the lineage (DAG) of RDDs. If a partition is lost, Spark recomputes it from the source data using the lineage graph.

2. Checkpointing

For long lineages (e.g., iterative algorithms), recomputation can be expensive. Checkpointing saves RDD to reliable storage, truncating lineage.

rdd.checkpoint()  // Save RDD to HDFS/S3
spark.sparkContext.setCheckpointDir("s3://bucket/checkpoints/")

Use when: (1) lineage grows very long (many transformations), (2) shuffles are expensive to recompute, (3) iterative algorithms (ML training).

3. Speculative Execution

If a task is running significantly slower than others (stragglers), Spark launches duplicate task on another node. First to finish wins.

spark.conf.set("spark.speculation", "true")
spark.conf.set("spark.speculation.multiplier", "1.5")  // 1.5x median time triggers speculation

Helps with: slow nodes, data skew, noisy neighbors in cloud.

4. Task Retries

Failed tasks are automatically retried (default 4 attempts). If a task fails repeatedly, the entire stage fails, and driver can decide to retry or abort.

5. Dynamic Allocation

Spark can request more executors when tasks are pending, and release idle executors. Helps with node failures and resource efficiency.

spark.dynamicAllocation.enabled=true
spark.dynamicAllocation.minExecutors=5
spark.dynamicAllocation.maxExecutors=100

Best Practices

Resource Management

Batch processing systems must share cluster resources efficiently. Cluster managers allocate CPU, memory, and schedule tasks across nodes.

Cluster Manager Options

1. YARN (Hadoop)

YARN (Yet Another Resource Negotiator) is the resource manager for Hadoop ecosystems.

2. Kubernetes

Kubernetes (K8s) is increasingly popular for Spark, offering containerization and cloud-native benefits.

spark-submit \
  --master k8s://https://<k8s-apiserver> \
  --deploy-mode cluster \
  --conf spark.executor.instances=10 \
  --conf spark.kubernetes.container.image=spark:3.5.0 \
  --conf spark.kubernetes.namespace=data-eng \
  local:///app/job.py

3. Standalone

Spark's built-in cluster manager. Simple, but lacks advanced features (multi-tenancy, resource quotas).

4. Mesos

General-purpose cluster manager (pre-Kubernetes era). Less common now.

Dynamic Allocation

Spark can scale executors up/down based on workload, improving resource utilization.

Resource Sizing Guidelines

Resource Recommendation Reasoning
Executor Memory 4-8 GB per executor Balance parallelism vs. GC overhead. Larger = fewer executors but more GC pauses.
Executor Cores 4-6 cores per executor More cores = more parallelism, but diminishing returns beyond 5 due to HDFS throughput limits.
Num Executors Total cores / cores per executor Maximize parallelism within memory constraints.
Driver Memory 1-4 GB (more if collecting large results) Driver holds DAG, task metadata. Increase if collect() returns large data.
Overhead Memory 10% of executor memory Off-heap memory for JVM overhead, interop with Python/R.
spark-submit \
  --executor-memory 6G \
  --executor-cores 5 \
  --num-executors 20 \
  --driver-memory 2G \
  job.py

Alternatives to Spark

While Spark dominates batch processing, alternatives excel in specific scenarios.

Comparison Table

System Strengths Weaknesses Use Cases
Apache Flink True streaming + batch, low latency, event time Steeper learning curve, smaller community Stream processing, CEP, real-time analytics
Apache Beam Portable (Spark/Flink/Dataflow runners), unified API Abstraction overhead, runner-specific quirks Cloud-agnostic pipelines, Google Cloud
Dask Pure Python, Pandas/NumPy compatible, easy to learn Smaller scale (GB-TB, not PB), less mature Python data science, small-medium data
Presto/Trino Interactive SQL, query federation, low latency Not a full processing engine (no ML, graph) Ad-hoc queries, BI dashboards, data lake analytics
BigQuery Serverless, auto-scaling, SQL, fast, BI integration GCP only, opaque cost, limited custom code Cloud-native analytics, BI, ad-hoc queries
Snowflake Serverless, multi-cloud, separation of storage/compute Cost can escalate, SQL-centric Data warehouse, BI, analytics

Apache Flink

Flink treats batch as a special case of streaming (bounded stream). Excels at stateful stream processing with exactly-once semantics.

Apache Beam

Beam provides a unified API for batch and stream processing, with pluggable runners (Spark, Flink, Dataflow).

Presto/Trino

Distributed SQL query engine for interactive analytics. Not a full batch processing system, but excellent for ad-hoc queries on data lakes.

Interview Question: When would you choose Flink over Spark?

Answer: Choose Flink when: (1) low latency is critical (sub-second to seconds), (2) true streaming semantics are needed (event time, watermarks, stateful processing), (3) exactly-once guarantees are required for financial/critical data, (4) workload is primarily streaming with batch as secondary. Choose Spark when: (1) batch-first workload, (2) broader ecosystem (MLlib, GraphX), (3) larger community/tooling, (4) micro-batch latency (seconds) is acceptable.

When to Use Batch Processing

Choosing between batch, stream, and hybrid approaches depends on latency requirements, data volume, and processing complexity.

Decision Criteria

Choose Batch When:

Choose Stream When:

Hybrid (Lambda/Kappa):

Scaling Considerations

Data Volume Recommended Approach
< 1 GB Single machine (pandas, R, SQL database)
1 GB - 100 GB Vertical scaling (bigger machine) or Dask
100 GB - 10 TB Spark, Presto, or cloud warehouse (BigQuery, Snowflake)
10 TB - 1 PB Spark with tuned partitioning, Parquet, Delta Lake
> 1 PB Distributed systems (Spark, Presto), data lake architecture, archival strategies

Example Decision Tree

graph TD A[Processing Requirements] --> B{Latency?} B -->|Minutes-Hours| C[Batch Processing] B -->|Seconds| D{Data Volume?} B -->|Milliseconds| E[Stream Processing] D -->|< 1TB| F[Presto/Trino
Interactive SQL] D -->|> 1TB| C C --> G{Complexity?} G -->|Simple SQL| H[BigQuery/Snowflake
Serverless warehouse] G -->|ETL/ML| I[Apache Spark
Full processing engine] E --> J{State?} J -->|Stateless| K[Spark Streaming
Micro-batches] J -->|Stateful| L[Flink
True streaming]
Interview Question: Design a system to process 10TB of logs daily for analytics and real-time alerts.

Answer: Hybrid approach: (1) Stream processing (Flink/Spark Streaming) reads from Kafka, detects anomalies in real-time, writes alerts to alerting system. (2) Batch processing (Spark) runs hourly/daily, reads from S3 (logs archived from Kafka), performs complex aggregations, writes to data warehouse (Parquet/Delta on S3). (3) Serving layer: Presto for ad-hoc queries on S3, BI tool (Tableau) queries warehouse. (4) Orchestration: Airflow schedules batch jobs. (5) Cost optimization: stream runs continuously on small cluster, batch uses spot instances.

Key Takeaways for Interviews