How to read this guide
mechanisms first, API secondYou can write Spark code for months by copying snippets. Then one day a job that took 4 minutes takes 4 hours, or your Kafka stream is 3 million records behind, and the snippets stop helping. What helps is knowing the machine: who runs your lambda, when it runs, and what moves across the network.
So every chapter explains one mechanism and gives you a diagram you can poke. Each diagram has a 💥 button that breaks something on purpose — because you learn a system fastest by watching it fail.
The guide has levels. Chapters and blocks carry a chip — L1 essentials, L2 practitioner, L3 internals. Choose your depth with the switch in the bottom-left corner; deeper material hides until you want it. The map below jumps anywhere and ticks off what you've read.
Facts checked against the Apache Spark 4.2.0 documentation and the Spark source code (tag v4.0.0) in September 2026. Code examples are Java. DStreams (Spark Streaming) is the legacy streaming engine: the docs say it gets no more updates and recommend Structured Streaming for new pipelines. You still meet DStream jobs in production — this guide is for running and understanding them.
A DStream job reads a Kafka topic every 10 seconds. Each batch takes 12 seconds to process. Nothing crashes, no error appears in the log. What happens over the next hour — and which two numbers in the UI tell you? Keep your guess; the finale answers it.
The core engine
Driver and executors, RDDs and lineage, how an action becomes jobs, stages and tasks, the shuffle, memory, and the SQL optimizer.
Anatomy of a Spark application
one brain, many handsA Spark application is two kinds of processes. The driver runs your main() and owns the
SparkContext. It plans the work and hands it out. The executors are JVM processes on worker nodes. They run
tasks in threads and keep data in memory or on disk for your application.
Between them sits a cluster manager — Spark's own standalone manager, YARN or Kubernetes. The driver asks it for executors; after that, the driver talks to the executors directly. Each application gets its own executors, which live as long as the application does. Two applications therefore cannot share cached data without writing it to external storage.
The deploy mode decides where the driver runs: in cluster mode the framework launches it inside
the cluster; in client mode it runs where you ran spark-submit. Either way, executors must be able to connect
back to the driver for its whole life, so keep the driver close to the workers. Each driver serves a web UI, typically on port 4040.
map lambda run on the driver?collect() — which is why collect() on a huge dataset can kill the driver.- Driver = plans and schedules. Executors = run tasks and store data.
- The cluster manager only allocates executors; it does not schedule your tasks.
- A task is the unit of work sent to one executor.
RDDs: lazy recipes with a memory
nothing happens until you askAn RDD (Resilient Distributed Dataset) is an immutable collection split into partitions. One task
processes one partition. You never change an RDD; each transformation (map, filter,
reduceByKey…) returns a new RDD that remembers its parent and the function to apply.
Transformations are lazy: Spark only records them. Work starts when an action —
count(), collect(), saveAsTextFile() — needs a result. The chain of recorded steps is the
lineage, and it is the "resilient" in RDD: if an executor dies and a partition is lost, Spark recomputes just that partition
from its lineage.
cache() only the lost cached block is rebuilt.SparkConf conf = new SparkConf().setAppName("word-count");
JavaSparkContext sc = new JavaSparkContext(conf);
JavaRDD<String> lines = sc.textFile("hdfs:///logs/*.txt"); // nothing is read yet
JavaPairRDD<String, Integer> counts = lines
.flatMap(l -> Arrays.asList(l.split(" ")).iterator()) // narrow
.mapToPair(w -> new Tuple2<>(w, 1)) // narrow
.reduceByKey(Integer::sum); // wide: needs a shuffle
counts.cache(); // a marker, still nothing computed
long distinct = counts.count(); // action → job 0, fills the cache
List<Tuple2<String, Integer>> some = counts.take(10); // job 1, reads cached partitions
System.out.println(counts.toDebugString()); // prints the lineage
Without cache()/persist(), every action recomputes the whole lineage — re-reading the files
and re-running every step. Two actions on an expensive RDD = twice the work. Cache what you reuse, and
unpersist() it when done; otherwise Spark evicts old cached partitions in least-recently-used (LRU) order.
| Level | What it does | Use it when |
|---|---|---|
MEMORY_ONLY | Deserialized objects in the JVM; partitions that do not fit are recomputed when needed. The default for cache() on RDDs. | Data fits comfortably; fastest access. |
MEMORY_AND_DISK | Partitions that do not fit go to disk and are read from there. | Recomputing is more expensive than a disk read. |
MEMORY_ONLY_SER / MEMORY_AND_DISK_SER | Stored as serialized bytes: smaller, more CPU to read. | Memory is tight or GC is a problem. |
DISK_ONLY | Only on disk. | Very large, rarely re-read data. |
…_2 (e.g. MEMORY_ONLY_2) | Each partition replicated on two nodes. | You cannot wait for recomputation after a node loss. |
- Transformations build a recipe (lineage); actions run it.
- Lost partitions are recomputed from lineage — only the lost ones.
- Each action recomputes from the source unless you persist.
Jobs, stages and tasks
where the DAG gets cutEach action submits a job. The job is split into stages, and each stage into tasks — one task per partition. Where does one stage end? At a shuffle.
Dependencies come in two kinds. Narrow: each output partition needs one parent partition (map,
filter, flatMap). Spark pipelines narrow steps inside one task — a record flows through
flatMap → mapToPair without any intermediate collection. Wide (shuffle): an output partition needs data from
many parent partitions (reduceByKey, groupByKey, join, repartition).
That needs an all-to-all exchange, so the stage must finish writing before the next can read: a barrier.
reduceByKey(f, 64)) or spark.default.parallelism;
if that is unset, the largest partition count among the parent RDDs.join.
A stage only waits for its own parent stages.🔬 Go deeper: DAGScheduler vs TaskScheduler
The DAGScheduler (on the driver) implements stage-oriented scheduling. For each job it builds the DAG of
stages by cutting the RDD graph at shuffle boundaries. Stages come in two types: ShuffleMapStages write map output files
for a shuffle; the final ResultStage runs the action. It tracks which RDDs are cached and which shuffle outputs already
exist, so a later job reuses them and skips stages (the UI shows them as "skipped").
It computes a preferred location for each task (from cached blocks, shuffle outputs or input splits) and submits each stage as
a TaskSet to the TaskScheduler. The TaskScheduler launches tasks on executors, honoring locality: it waits up
to spark.locality.wait (default 3s) for a data-local slot before running the task somewhere less local.
Failure handling is split too. A task that fails for an ordinary reason is retried by the TaskScheduler; after
spark.task.maxFailures (default 4) failures of the same task, the job is aborted. But if a task fails with
FetchFailed — a shuffle file from an earlier stage is gone — the DAGScheduler resubmits the parent stage to rebuild
the missing map outputs. Re-runs of a stage are called attempts.
- Action → job → stages (cut at shuffles) → tasks (one per partition).
- Narrow steps are pipelined inside one task; wide steps need a shuffle and a barrier.
- Count the shuffles in your code and you know the stage count.
The shuffle, up close
the most expensive line of your jobA shuffle moves every record to the partition that owns its key. The Spark docs call it expensive because it costs disk I/O, serialization and network I/O at once. It runs as two sets of tasks, named after MapReduce: map tasks organize their output by target partition, reduce tasks fetch and aggregate it.
On the map side, results are held in memory until they no longer fit, then sorted by target partition and
written to a single file per map task (plus an index telling where each target partition's block starts). Each reduce task
reads its block from every map task's file. These shuffle files stay on disk until the RDDs that need them are garbage collected — so
a lost executor does not always mean a full recompute, and a long-running job can fill spark.local.dir.
hash(key) mod partitions). Counts are illustrative.Skew: one hot key sends most records to one reduce task. The stage is only done when its slowest task is done, so 199 tasks finish in seconds and one runs for an hour — and may spill or run out of memory. Look for one long bar in the stage's task timeline. Fixes: combine on the map side, salt the hot key, or (DataFrames) let AQE split skewed join partitions (chapter 6).
🔬 Go deeper: memory, spills and the bypass path
reduceByKey and aggregateByKey build in-memory tables on the map side; the …ByKey
operations build them on the reduce side. When a table does not fit, Spark spills it to disk, paying extra I/O and GC.
A spill is not an error, but many spills in the stage metrics mean the tasks are too big: raise the partition count.
The sort-based shuffle manager has a shortcut: with no map-side aggregation and at most
spark.shuffle.sort.bypassMergeThreshold (default 200) reduce partitions, it writes one temporary file per
reduce partition and concatenates them instead of merge-sorting.
Element order after a shuffle is not deterministic. Need order? Use sortBy, or
repartitionAndSortWithinPartitions to sort while repartitioning.
- Shuffle = map tasks write sorted files, reduce tasks fetch their block from every file.
reduceByKeycombines before shipping;groupByKeyships every value.- A stage is as slow as its slowest task — skew kills.
Unified memory: execution vs storage
who may evict whomExecutor memory has two main customers. Execution memory is for computation — shuffles, joins, sorts, aggregations. Storage memory is for cached data and data propagated across the cluster, like broadcasts. Since the unified memory manager, both share one region M, and each can borrow the other's free space.
The rule that matters: execution may evict storage, but only until storage falls back to a protected region R inside M. Storage never evicts execution. If execution still needs more, it spills to disk.
spark.memory.fraction = 0.6 and
spark.memory.storageFraction = 0.5. The amounts cached and sorted are illustrative; off-heap and overhead memory are not shown.| Setting | Default · what | Change it when |
|---|---|---|
spark.executor.memory | 1g · heap per executor | Always size it for your workload. |
spark.memory.fraction | 0.6 · M as a fraction of (heap − 300 MiB); the rest is for user data structures and Spark metadata | Rarely; lower it if you see OOMs from your own objects. |
spark.memory.storageFraction | 0.5 · R as a fraction of M, cached blocks immune to eviction | Rarely; raise it if a cache you depend on keeps getting evicted. |
The Spark tuning guide says most users should not need to touch the two fractions. Tune spark.executor.memory and
your partition counts first.
- M = (heap − 300 MiB) × 0.6; R = M × 0.5.
- Execution can evict cached blocks down to R; storage cannot evict execution.
- When execution runs out, it spills.
DataFrames: an optimizer between you and the RDDs
you say what, Catalyst decides howWith RDDs, Spark runs your lambdas as written — it cannot look inside them. A DataFrame query is different: it is a
tree of relational operators Spark understands. So the Catalyst optimizer can rewrite it before anything runs.
df.explain(true) prints the four phases: Parsed Logical Plan → Analyzed Logical Plan (names and types
resolved against the catalog) → Optimized Logical Plan (rule-based rewrites) → Physical Plan (concrete operators such as
a sort-merge join).
explain() output. The size of countries is illustrative.Two more layers work below the plan. Whole-stage code generation compiles a subtree of supported operators
into a single Java function, so rows are not passed through one virtual call per operator. And Adaptive Query Execution (AQE),
on by default (spark.sql.adaptive.enabled = true), re-optimizes the plan during execution using real statistics from
finished shuffle stages.
| AQE feature | What it does | Helps when |
|---|---|---|
| Coalesce post-shuffle partitions | Merges contiguous small shuffle partitions up to spark.sql.adaptive.advisoryPartitionSizeInBytes. | The default spark.sql.shuffle.partitions = 200 is too many for your data. |
| Sort-merge → broadcast hash join | If a join side turns out smaller than the adaptive broadcast threshold at runtime, switches the join. | Filters made one side tiny, but estimates did not know. |
| Skew join | Splits (and replicates if needed) skewed partitions of a sort-merge join into evenly sized tasks. | One join key is hot. |
filter after a join because they wrote it that way. I push it below the join, so the join reads less data.- Catalyst: parsed → analyzed → optimized → physical plan.
- Code generation fuses operators into one function.
- AQE fixes the plan at runtime: fewer partitions, broadcast joins, skew splits.
Kafka with DStreams
The micro-batch model, the direct Kafka stream, offsets and delivery guarantees, state and windows, and keeping up with the topic.
A DStream is a conveyor belt of RDDs
streaming = many tiny batch jobsSpark Streaming does not process records one by one. It cuts time into batch intervals (say 10 s). Everything that arrived during an interval becomes one RDD. A DStream (discretized stream) is just that series of RDDs, and every operation on a DStream is applied to each RDD in it.
So everything from Part I still holds — every batch is a normal Spark job with stages, tasks and shuffles. And like
RDDs, DStreams are lazy: only output operations (foreachRDD, print, saveAs…Files) make
Spark do the work. No output operation, no processing. By default, output operations run one at a time, in the order you defined them.
The Streaming tab of the web UI shows the two numbers that matter. Processing time is how long each batch took. Scheduling delay is how long a batch waited in the queue for earlier batches to finish. If processing time is consistently above the batch interval, or scheduling delay keeps growing, the application is falling behind.
Code in foreachRDD runs on the driver; code inside rdd.foreach… runs on executors.
Creating a DB connection at the driver level and using it inside rdd.foreach fails (the connection is not serializable).
Creating one per record works but is slow. Create one per partition — or take one from a pool:
stream.foreachRDD(rdd -> { // driver
rdd.foreachPartition(records -> { // executor, once per partition
Connection c = ConnectionPool.get(); // your own lazily created pool
while (records.hasNext()) c.send(records.next());
ConnectionPool.release(c);
});
});
🔬 Go deeper: the StreamingContext rules
Once a StreamingContext is started, you cannot add new streaming computations to it. Once stopped, it cannot be
restarted. Only one can be active per JVM. stop() also stops the SparkContext unless you pass
stopSparkContext = false. The batch interval is fixed when you create the context and should come from your latency
needs and what the cluster can sustain.
- DStream = one RDD per batch interval; each batch = ordinary Spark jobs.
- Output operations trigger the work, one at a time by default.
- Stable only if processing time < batch interval.
The direct Kafka stream
one Kafka partition = one Spark partitionThe Kafka integration for Spark Streaming is spark-streaming-kafka-0-10 (for Kafka brokers 0.10.0+).
It is a direct stream: there is no long-running receiver. At every batch the driver looks up the latest offsets for each
partition and defines the batch as a set of offset ranges — "partition 0 from 1 200 to 1 450". The resulting
KafkaRDD has exactly one Spark partition per Kafka partition, and executors read their range from Kafka when the task runs.
ordersPreferConsistent (an even spread),
not its exact algorithm.SparkConf conf = new SparkConf().setAppName("orders-stream");
JavaStreamingContext jssc = new JavaStreamingContext(conf, Durations.seconds(10));
Map<String, Object> kafkaParams = new HashMap<>();
kafkaParams.put("bootstrap.servers", "broker1:9092,broker2:9092");
kafkaParams.put("key.deserializer", StringDeserializer.class);
kafkaParams.put("value.deserializer", StringDeserializer.class);
kafkaParams.put("group.id", "orders-stream"); // one group.id per stream
kafkaParams.put("auto.offset.reset", "latest");
kafkaParams.put("enable.auto.commit", false); // we commit ourselves (next chapter)
JavaInputDStream<ConsumerRecord<String, String>> stream =
KafkaUtils.createDirectStream(
jssc,
LocationStrategies.PreferConsistent(),
ConsumerStrategies.<String, String>Subscribe(List.of("orders"), kafkaParams));
stream.mapToPair(r -> new Tuple2<>(r.key(), r.value()))
.foreachRDD(rdd -> { /* output */ });
jssc.start();
jssc.awaitTermination();
Depend only on org.apache.spark:spark-streaming-kafka-0-10_2.13; the docs warn not to add
kafka-clients yourself, because mismatched versions fail in hard-to-diagnose ways. Mark spark-core and
spark-streaming as provided.
| Option | What it does | Use it when |
|---|---|---|
LocationStrategies.PreferConsistent | Spreads partitions evenly across available executors. | Almost always. |
LocationStrategies.PreferBrokers | Prefers the executor on the host of the partition's Kafka leader. | Executors run on the same hosts as the brokers. |
LocationStrategies.PreferFixed | Your explicit partition → host map; unlisted partitions use a consistent location. | Load is heavily skewed between partitions. |
ConsumerStrategies.Subscribe | Fixed collection of topics; reacts to partitions added while running. | The normal case. |
ConsumerStrategies.SubscribePattern | Topics matching a regex. | Topic-per-tenant or similar naming schemes. |
ConsumerStrategies.Assign | Fixed collection of partitions, optionally with starting offsets. | You store offsets yourself (next chapter). |
A Kafka consumer pre-fetches messages into buffers, so creating one per batch would waste them. Executors therefore keep
cached consumers, keyed by topic-partition and group.id — which is why each createDirectStream call
should have its own group.id. The cache holds 64 consumers per executor by default
(spark.streaming.kafka.consumer.cache.maxCapacity); raise it if you have more than 64 × executors partitions.
spark.streaming.kafka.consumer.cache.enabled = false turns caching off.
🔬 Go deeper: what the driver does each batch
In DirectKafkaInputDStream.compute(), the driver's own consumer is polled (the source calls it
paranoidPoll) and asked for the latest offsets with seekToEnd; newly assigned partitions are picked up here.
The latest offsets are clamped by the rate limit (chapter 11), turned into OffsetRange(topic, partition, from, until)
objects, and wrapped in a KafkaRDD with preferred hosts from the location strategy. The ranges are reported to the UI's
input info, and currentOffsets moves to the until-offsets. Last, queued offset commits are sent (commitAll()).
Also in the source: persisting or windowing the raw stream logs an error, because a ConsumerRecord is not
serializable — map to your own fields first.
- Direct stream: the driver defines offset ranges; tasks read them from Kafka.
- 1 Kafka partition = 1 RDD partition = 1 task per batch.
- More Kafka partitions = more parallelism per batch.
Offsets and delivery guarantees
when you save the bookmark decides everythingSpark output operations are at-least-once: after a failure, a batch may be written again. What you get end to end depends on where and when you store the offsets. The docs list three options, in order of increasing reliability and code complexity — plus one trap.
| Strategy | How | Result after a crash | Catch |
|---|---|---|---|
| Checkpoint | jssc.checkpoint(dir); offsets are saved in the checkpoint | Repeated output → output must be idempotent | Cannot recover from a checkpoint after changing application code. |
Kafka commitAsync | Commit offset ranges after the output succeeded | Repeated output → idempotent | Kafka is durable across code changes, but not transactional with your sink. |
| Your own transactional store | Write results and offsets in one transaction; start with Assign from stored offsets | Equivalent of exactly-once | Most code; you must detect repeated or skipped ranges. |
enable.auto.commit = true | Consumer commits periodically by itself | Undefined: polled ≠ written | Do not use; set it to false. |
// Option 2: commit to Kafka after the output is stored
stream.foreachRDD(rdd -> {
OffsetRange[] ranges = ((HasOffsetRanges) rdd.rdd()).offsetRanges(); // first call on the stream!
rdd.foreachPartition(records -> {
OffsetRange o = ranges[TaskContext.get().partitionId()]; // this task's range
writeIdempotently(records); // e.g. upsert by key
});
((CanCommitOffsets) stream.inputDStream()).commitAsync(ranges);
});
The cast to HasOffsetRanges (and to CanCommitOffsets) only works on the stream returned by
createDirectStream, as the first method in the chain — not after map. And after anything that
shuffles or repartitions (reduceByKey, window), RDD partition i is no longer Kafka partition i.
Grab the ranges first, transform after.
🔬 Go deeper: the transactional pattern (Java sketch)
Map<TopicPartition, Long> from = db.loadOffsets("orders-stream"); // your table
JavaInputDStream<ConsumerRecord<String, String>> stream =
KafkaUtils.createDirectStream(jssc, LocationStrategies.PreferConsistent(),
ConsumerStrategies.<String, String>Assign(from.keySet(), kafkaParams, from));
stream.foreachRDD(rdd -> {
OffsetRange[] ranges = ((HasOffsetRanges) rdd.rdd()).offsetRanges();
List<Result> results = compute(rdd); // e.g. an aggregate collected to the driver
db.inTransaction(tx -> {
tx.save(results);
// update only where stored offset == range.fromOffset(); otherwise roll back
tx.advanceOffsets(ranges);
});
});
This works even for aggregated outputs, which are hard to make idempotent. The offset check inside the transaction is what detects repeated or skipped ranges.
L3 detail: commitAsync only queues the ranges (it is thread-safe). The driver sends queued
commits the next time the stream computes a batch. So a crash right after commitAsync can still replay the last batch —
one more reason to write idempotently.
- Always
enable.auto.commit = false. - Store offsets after output (idempotent sink) or with output (one transaction).
- Read offset ranges on the direct stream before any transformation.
State, windows and checkpointing
remembering across batchesWindow operations compute over the last N batches. Two parameters: window length (how much time) and
sliding interval (how often). Both must be multiples of the batch interval — the source code rejects the DStream otherwise.
reduceByKeyAndWindow(f, Durations.seconds(30), Durations.seconds(10)) gives per-key totals over 30 s, every 10 s.
With an inverse function — reduceByKeyAndWindow(f, invF, 30 s, 10 s) — Spark updates the previous
window result: add what entered, subtract what left. Cheaper for long windows, but it needs checkpointing. Windowed DStreams are
persisted in memory automatically.
State across the whole stream: updateStateByKey(func) calls your function every batch for every
key, with the new values and the previous state. It requires a checkpoint directory.
Two kinds of checkpoint
| Kind | What is saved | Needed for |
|---|---|---|
| Metadata checkpoint | Configuration, DStream operations, incomplete batches | Recovering from driver failure |
| Data (RDD) checkpoint | Generated RDDs of stateful transformations | Cutting lineage that would grow forever; mandatory with updateStateByKey or inverse windows |
String checkpointDir = "hdfs:///checkpoints/orders-stream";
JavaStreamingContext ctx = JavaStreamingContext.getOrCreate(checkpointDir, () -> {
JavaStreamingContext jssc = new JavaStreamingContext(conf, Durations.seconds(10));
// create ALL DStreams and output operations here
jssc.checkpoint(checkpointDir);
return jssc;
});
ctx.start();
ctx.awaitTermination();
If the directory exists, the context is rebuilt from it; otherwise the function creates a fresh one. The driver must
also be restarted automatically after a failure — that is the job of your deployment (cluster mode supervision, Kubernetes,
YARN attempts), not of getOrCreate.
A checkpoint holds serialized Java objects of your code. After an upgrade, deserializing them with changed classes may fail. The docs' options: run the new version in parallel and then stop the old one, or stop gracefully and start the new version with a new checkpoint directory. With Kafka offsets stored outside the checkpoint (previous chapter), the new version knows where to start.
🔬 Go deeper: checkpoint interval
Checkpointing RDDs costs a write to reliable storage, so batches that checkpoint take longer. For stateful DStreams the default interval
is a multiple of the batch interval of at least 10 s. The docs suggest trying 5–10 sliding intervals, set with
dstream.checkpoint(interval). Accumulators and broadcast variables are not recovered from a checkpoint — create
them as lazily initialized singletons so they are rebuilt after a restart.
- Window length and slide are multiples of the batch interval.
- Stateful ops and inverse windows require
jssc.checkpoint(dir). - Code changes break checkpoint recovery — plan upgrades.
Keeping up: rate limits, backpressure, operations
never bite off a 1-hour backlogA direct stream, left alone, takes everything between the last offset and the latest one in a single batch. After
a restart with an hour of backlog, that one batch is enormous. Two settings bound it.
spark.streaming.kafka.maxRatePerPartition is a hard ceiling in records per second per partition, so the batch size is at most
rate × partitions × batch seconds. spark.streaming.backpressure.enabled makes Spark estimate, from recent batch
delays and processing times, how fast it can go, and set the rate dynamically — still capped by maxRatePerPartition if set.
| Setting | Default · what | Use it when |
|---|---|---|
spark.streaming.kafka.maxRatePerPartition | not set · max records/s read from each partition | Always in production — it bounds the worst batch. |
spark.streaming.kafka.minRatePerPartition | 1 · min records/s per partition | Rarely changed. |
spark.streaming.backpressure.enabled | false · adapt the rate to processing speed | Load or processing speed varies. |
spark.streaming.backpressure.initialRate | not set · max rate for the first batch when backpressure is on | The first batch after a restart must be small. |
spark.streaming.stopGracefullyOnShutdown | false · on JVM shutdown, finish received data before stopping | Deployments that stop the app with a signal. |
spark.streaming.ui.retainedBatches | 1000 · batches kept by the Streaming UI | You need a longer history in the UI. |
- Batch interval from latency needs; confirm processing time stays well below it under peak load.
- Kafka partitions ≥ the parallelism you want per batch — each batch has one task per partition.
enable.auto.commit = false; offsets stored after (idempotent) or with (transactional) the output.maxRatePerPartitionset; backpressure on if load varies.- If the batch interval exceeds the consumer's session timeout, raise
heartbeat.interval.msandsession.timeout.ms; for batches over 5 minutes the broker'sgroup.max.session.timeout.mstoo. - Checkpoint dir on reliable storage if you use state; driver restart handled by the deployment.
- Alert on scheduling delay and consumer lag, not only on errors.
For new pipelines the docs point to Structured Streaming: DataFrame queries over an unbounded table, with the Catalyst optimizer from chapter 6 and its own Kafka source. The ideas from this guide — offsets as the source of truth, idempotent or transactional sinks, bounded batch sizes — carry over directly.
🔬 Go deeper: how the rate limit is split across partitions
In DirectKafkaInputDStream.maxMessagesPerPartition, the backpressure rate is a rate for the whole stream. It is divided
among partitions in proportion to their lag — a partition with 80% of the backlog gets 80% of the rate — then each share is capped
at maxRatePerPartition. Before the estimator has a value, spark.streaming.backpressure.initialRate is used.
The per-partition limit × batch seconds becomes the number of messages, never below minRatePerPartition records (default 1) — the source compares that floor directly with the message count.
- Without limits, the first batch after downtime takes the whole backlog.
maxRatePerPartition= hard ceiling; backpressure = adaptive rate below it.- Watch scheduling delay and lag.
Exam + cheat sheet
check what stuckCheat sheet
| Parameter / API | Default / what it does | Use when |
|---|---|---|
| rdd.cache() / persist(level) | keep computed partitions (cache() = MEMORY_ONLY) | an RDD feeds more than one action |
| rdd.toDebugString() | prints the lineage | finding shuffles in RDD code |
| df.explain(true) | parsed, analyzed, optimized, physical plans | checking joins and pushdowns |
| spark.default.parallelism | partitions after RDD shuffles if not given | RDD jobs with too few/many tasks |
| spark.sql.shuffle.partitions | 200 · DataFrame shuffle partitions | AQE coalescing is not enough |
| spark.sql.adaptive.enabled | true · runtime re-optimization | leave on |
| spark.sql.autoBroadcastJoinThreshold | 10MB · broadcast smaller join sides | small dimension tables |
| spark.executor.memory | 1g · executor heap | always size it |
| spark.memory.fraction / storageFraction | 0.6 / 0.5 | rarely |
| spark.task.maxFailures | 4 · failures of one task before the job fails | flaky environments |
| spark.locality.wait | 3s · wait for a data-local slot | tasks idle waiting for locality |
| KafkaUtils.createDirectStream | one RDD partition per Kafka partition | every Kafka DStream |
| HasOffsetRanges / CanCommitOffsets | offset ranges of a batch / queue a commit | first call on the direct stream |
| enable.auto.commit | set to false | always with DStreams |
| spark.streaming.kafka.maxRatePerPartition | not set · rec/s per partition | always in production |
| spark.streaming.backpressure.enabled | false | varying load |
| spark.streaming.kafka.consumer.cache.maxCapacity | 64 cached consumers per executor | more than 64 × executors partitions |
| JavaStreamingContext.getOrCreate | rebuild from checkpoint or create | driver recovery, stateful ops |
Back to the opening question. A 10-second batch that takes 12 seconds is never done before the next one is due. Batches queue up:
every batch waits about 2 s longer than the one before, so after an hour (360 batches) the newest one waits roughly 12 minutes
before it even starts, and the Kafka lag grows the whole time. Nothing crashes — the Streaming tab shows it: processing time
above the batch interval, and scheduling delay climbing without end. The fix is to make batches faster (more partitions and
executors, a cheaper sink, fewer shuffles) and to bound them with maxRatePerPartition and backpressure. Next step: open
the Spark UI of a real job and find its stages, shuffles and slowest task.