Skip to content
ranjit.data
NEW deep dive October 31, 2026 22 min read

GC Pressure, Memory, and Watermarks in PySpark Structured Streaming

A production field guide to GC pressure, memory management, and watermarking in PySpark Structured Streaming at 30-second latency, with a retail dashboarding example, Spark UI diagnosis, and Dataproc GCP sizing math.

Your streaming job meets its 30-second SLA in staging, then falls behind by 40 minutes in production. You add executors. It gets worse. The dashboard goes stale. You are not looking at a compute problem โ€” you are looking at a GC, memory, and state problem wearing a compute costume.

This is the operational reality of PySpark Structured Streaming at scale: the math that works on a 5 GB test stream collapses under a 300 GB/hour retail event stream because the JVM was never sized for the object churn a micro-batch creates, the cache strategy deserialized millions of rows into the old generation, and the watermark grew unbounded while late data kept resurrecting expired state.

Here is how to diagnose it, size it, and fix it โ€” with a retail instore dashboarding example running on Dataproc GCP.


๐Ÿช The problem statement: retail instore dashboarding at country scale

You operate a nationwide retail chain with ~2,000 stores. Every store emits POS transactions, inventory movements, and footfall counts to a central streaming pipeline. The business wants a live instore dashboard: “what is selling right now, per store, per category,” refreshed within 30 seconds of the event happening.

The ingestion shape:

ParameterValueWhy it matters
Stores2,000High cardinality grouping key
Events/sec (base)8,000~28.8M events/hour
Events/sec (peak, lunch/dinner)45,0005.6ร— diurnal spike
Avg event size450 bytesJSON with store_id, sku, qty, amount, ts
Trigger interval30 secondsYour SLA and batch window
Volume per micro-batch (base)1.08 GBฮป ร— r ร— T
Volume per micro-batch (peak)6.08 GBฮป_peak ร— r ร— T
State to keep30-day rolling sales per store ร— categoryWatermark-bound state store

The stream joins transaction events against a slowly changing dimension (store master, category taxonomy), aggregates per store_id + category + 1-hour tumbling window, and writes to BigQuery for the dashboard. It also maintains a 30-day rolling state for “store velocity” comparisons.

This is not a toy example. At peak, you are processing 6 GB every 30 seconds, with thousands of keys, state updates, and joins โ€” and your GC pauses are now eating 25โ€“40% of each micro-batch window.


๐Ÿค” Questions to seek clarity on before acting

Before touching a single Spark config, answer these. If you cannot answer them with data, you are guessing, and guessing at 30-second latency means breaching the SLA.

1. What is the actual event rate, not the advertised one?

QuestionHow to measure
Base events/secKafka consumer lag, ingestion metrics, or source system logs
Peak events/sec (p99)Hourly histogram, not average
Avg event sizeSample 10,000 records; compute mean byte size
Store cardinalityDistinct store_id per hour

If your peak is 45,000 events/sec but you sized for 8,000, your micro-batch volume is 5.6ร— larger than planned and the 30-second window will never close.

2. Is the 30-second SLA a processing budget or an end-to-end budget?

T_trigger = 30 s includes:

  • Micro-batch planning and task scheduling
  • Shuffle read/write
  • State store reads/writes (RocksDB on Dataproc local SSD)
  • Sink write to BigQuery
  • Network between executors and sink

Your processing budget inside Spark must be < 25 s to leave headroom for sink and orchestration. If you budget the full 30 s for Spark processing, you have already lost.

3. What state do you actually need to keep, and for how long?

Watermarking defines how long state survives. If your watermark lags by 10 minutes because of skewed event timestamps, your state store holds 40 minutes of data instead of 30 seconds. Multiply that by 2,000 stores ร— 50 categories and you have a multi-gigabyte state store that never shrinks.

4. Are you caching anything in the streaming path?

df.persist() inside a foreachBatch or on a streaming DataFrame keeps objects in the JVM heap across micro-batches. Deserialized caching at streaming scale is a GC time bomb. You need to know what is cached, in what format, and whether it is ever evicted.

5. What is your failure mode when latency slips?

Does the dashboard show stale data? Do micro-batches queue up and never catch up? Does the job OOM and restart, losing state? The answer determines whether you tune GC, increase parallelism, or redesign the state logic.


๐Ÿง  Why GC kills streaming latency more than batch

In batch, a GC pause is a tax on a finite job. In streaming, a GC pause is a recurring tax on every micro-batch, and if the tax exceeds your trigger interval, you never catch up.

A PySpark executor is a JVM. Every micro-batch creates:

  • Row objects for every record read from Kafka
  • Hash-map entries for aggregations
  • Broadcast lookup structures
  • Shuffle buffers and serializer objects
  • State store entries and update deltas

When the trigger is 30 seconds and processing takes 22 seconds, an 8-second GC pause means the batch misses its window. The next batch arrives while the previous one is still committing. Backlog accumulates. Latency drifts from 30 seconds to 3 minutes to 40 minutes.

The formula you actually care about:

$$\eta_{\text{batch}} = 1 - \frac{t_{\text{GC}}}{T_{\text{trigger}}}$$

If t_GC is 12 seconds per 30-second trigger, your effective throughput is 60%. You are paying for 100 cores to get 60 cores of work.


๐Ÿ“Š Signal 1: Reading the Spark UI for streaming

Structured Streaming adds a Streaming tab to the Spark UI. Read it in this order:

Streaming    โ† START HERE. Batch duration, input rows, processing time
Executors    โ† GC Time vs Task Time, per executor
Stages       โ† is one task 10x the median? (skew detector)
Jobs         โ† which micro-batch stage dominates wall clock?
Storage      โ† is your cache spilling? is state store growing?
SQL          โ† exchange sizes, state store scan sizes
Environment  โ† did your -XX flags even apply?

What to look for in the Streaming tab

ColumnHealthy signalWarning signal
Batch durationConsistently < 25 sCreeping toward 30 s, then exceeding
Input rows/secondMatches ingestion ฮปSpikes without corresponding scale-up
Processing time / batchStableIncreasing over time (memory leak)
Sink duration< 5 sGrowing as downstream backpressure builds
State store sizeBounded by watermarkGrowing unbounded

The Executors tab ratio

Same as batch: compute GC Time รท Task Time per executor. But in streaming, watch it across micro-batches, not cumulatively. An executor that starts at 5% and drifts to 35% over two hours has a memory leak or unbounded state โ€” not a one-time churn event.

Executor | Task Time | GC Time | GC/Task | Trend
1        | 18.2 s    | 0.9 s   | 5%      | stable
2        | 18.1 s    | 1.1 s   | 6%      | stable
3        | 15.4 s    | 5.2 s   | 34%     | RISING โ€” leak or skew
4        | 18.3 s    | 0.8 s   | 4%      | stable

Executor 3 at 34% and rising is your incident. It is either processing a skewed store (one store with 10ร— the transactions) or accumulating state that is never evicted.


๐Ÿ” Signal 2: GC log is ground truth

Turn on executor GC logging. On Dataproc with Spark 3.x / Java 11:

--conf "spark.executor.extraJavaOptions=-Xlog:gc*:time,uptime,level,tags" \
--conf "spark.executor.defaultJavaOptions=-XX:+HeapDumpOnOutOfMemoryError"

Read a single line:

[2026-10-31T10:14:22.418+0000][7.842s][info][gc] GC(214) Pause Young (Normal) (G1 Evacuation Pause) 1841M->612M(4096M) 412.883ms

Classify each pause:

PatternMeaningFix lever
1841M -> 612M, 400 ms, young GCHealthy churn, high reclamationNone needed
1841M -> 1780M, 389 ms, young GCPromotion pressure โ€” objects survivingSerialized caching, fewer objects
3902M -> 1104M, 4812 ms, Pause FullOld generation full, stop-the-worldHeap too small, state too large, cache too long
Pause Young every 2โ€“3 secondsAllocation rate too highMore partitions, smaller batches, Kryo

Compute your rates:

$$R_{\text{alloc}} \approx \frac{\Delta\,\text{heap used}}{\Delta t} \qquad R_{\text{promotion}} = R_{\text{alloc}} \times P(\text{survives young gen})$$

In streaming, P(survives young) is driven by state that lives across micro-batches. If your state store holds deserialized rows, every state entry is a candidate for promotion. Serialized state (MEMORY_AND_DISK_SER) keeps state as byte arrays โ€” cheap for the collector to traverse.


๐Ÿ—„๏ธ Memory: caching and persisting in streaming

This is the section that separates production streaming from tutorials.

The trap: MEMORY_AND_DISK in a streaming job

# DANGEROUS in streaming at scale
df.persist(StorageLevel.MEMORY_AND_DISK)

Every cached row is a live JVM object tree: Row, InternalRow, boxed primitives, HashMap entries, Array[Byte] wrappers. At 6 GB micro-batch volume, deserialized caching can inflate to 15โ€“20 GB of live objects. The GC must walk every node on every pass.

The fix: serialized caching

from pyspark import StorageLevel

# Same data as byte arrays: a handful of large objects instead of millions of small ones
df.persist(StorageLevel.MEMORY_AND_DISK_SER)

Serialized, a cached row is bytes in one array. Deserialized, it is an object graph with headers, pointers, and boxed primitives โ€” commonly 2โ€“5ร— the raw data size, and every node is a live object the collector traverses on every GC pass.

Storage levelObject countGC walk costDisk spillUse case
MEMORY_AND_DISKMillionsHighYesNever in streaming at scale
MEMORY_AND_DISK_SERThousandsLowYesStreaming lookup tables, dimension joins
DISK_ONLYZeroNoneYesCold archives, never re-read in batch
OFF_HEAPZeroNoneYesLarge state stores, if spark.memory.offHeap sized

The harder question: do you need to cache at all?

In Structured Streaming, caching a DataFrame inside foreachBatch means the cache survives across micro-batches. If the cached DataFrame is reused once per batch, recomputing it is often cheaper than keeping it alive in the old generation for hours.

Rule: Cache only dimension lookups that change slower than your watermark. Never cache the event stream itself.


๐Ÿท๏ธ Watermarking: event time, state, and the metadata explosion

Watermarking is how Structured Streaming bounds state. But it is also where silent state leaks happen.

The retail example

You aggregate sales per store_id + category + 1-hour tumbling window with a 30-second watermark:

from pyspark.sql.functions import col, window, sum as _sum

agg = (
    events
    .withWatermark("event_time", "30 seconds")
    .groupBy(
        col("store_id"),
        col("category"),
        window(col("event_time"), "1 hour")
    )
    .agg(_sum("amount").alias("total_sales"))
)

query = agg.writeStream \
    .outputMode("update") \
    .format("bigquery") \
    .option("table", "project:dataset.sales_agg") \
    .option("checkpointLocation", "gs://your-bucket/checkpoints/") \
    .trigger(processingTime="30 seconds") \
    .start()

query.awaitTermination()

Output mode is not a detail โ€” it is the difference between a live dashboard and a broken one.

Output modeWhat it emitsEffect on your 30-second SLA
appendRows only after watermark passesArtificial 30-second delay built in โ€” dashboard shows stale data
completeFull result set every triggerState grows forever; watermark is ignored for emission
updateOnly changed rows since last triggerEmits intermediate results immediately; drops state when watermark passes

For a live low-latency dashboard, outputMode("update") is mandatory. append delays every result by at least one watermark period. complete never lets the state store shrink. update is the only mode that gives you both low latency and bounded state.

What goes wrong

  1. Skewed event timestamps: One store’s clock drifts 20 minutes behind. Its events keep arriving after the watermark has passed their window. Structured Streaming cannot drop that state โ€” it must hold it until the watermark catches up. Your state store grows by the drift duration ร— event rate.

  2. Late data beyond watermark: Events arriving after the watermark are dropped silently (for aggregations). The dashboard shows undercounted sales for that store. You either accept it or raise the watermark โ€” which grows state.

  3. State store size math:

$$\text{State entries} = \frac{\text{Watermark lag (s)}}{T_{\text{trigger}}} \times \text{distinct keys per batch} \times \text{state per key}$$

At peak:

  • Distinct keys per batch: 2,000 stores ร— 50 categories = 100,000
  • State per key: ~200 bytes (window aggregate + metadata)
  • Watermark lag: 30 seconds = 1 batch
  • State entries per batch: 100,000

But if watermark lag drifts to 10 minutes (20 batches):

  • State entries: 2,000,000
  • State size: ~400 MB per executor, growing

On Dataproc, this state lives in RocksDB on local SSD. The JVM heap holds the state cache. If the cache grows too large, GC pressure spikes and the executor stalls.

The fix

  • Set watermark based on actual event-time skew, not hope. Measure max delay from ingestion to processing.
  • Monitor state store size in the Spark UI Storage tab. If it grows unbounded, your watermark is too low or your data is skewed.
  • Use mapGroupsWithState or flatMapGroupsWithState only with explicit state timeouts. Unbounded stateful operations without timeouts are state leaks by design.

Handling late events without bloating state

Late-arriving POS data can artificially bloat the state store when skewed timestamps drag the watermark backward. The fix is to explicitly drop late events instead of letting them resurrect expired windows:

from pyspark.sql.functions import col, current_timestamp, window, sum as _sum

# Watermark bounds state; anything older is dropped at the source
agg = (
    events
    .withWatermark("event_time", "30 seconds")
    .filter(col("event_time") >= current_timestamp() - expr("INTERVAL 30 MINUTES"))
    .groupBy(
        col("store_id"),
        col("category"),
        window(col("event_time"), "1 hour")
    )
    .agg(_sum("amount").alias("total_sales"))
)

The filter is the guardrail: events older than 30 minutes from processing time are dropped before they reach the state store. Without it, one store with a drifted clock can keep resurrecting windows the watermark has already passed, and your state store grows by drift duration ร— event rate instead of shrinking.


๐Ÿชจ RocksDB off-heap memory: the OOM that GC tuning cannot fix

You cannot discuss state store memory without addressing off-heap allocation. RocksDB runs via JNI outside the JVM heap. The garbage collector you tuned so carefully has zero visibility into RocksDB’s memory. If you do not explicitly tune spark.executor.memoryOverhead and enable RocksDB’s bounded memory usage, the executor will be terminated by the Linux OOM killer regardless of how perfectly you tuned the JVM garbage collector.

The failure mode

At peak, your state store holds millions of entries across 2,000 stores ร— 50 categories. RocksDB allocates memtables, block caches, and bloom filters in off-heap memory. The JVM heap looks healthy in the Spark UI, but the container’s total RSS (heap + off-heap + overhead) exceeds the YARN/Kubernetes memory limit, and the kernel sends SIGKILL. Exit code 137. No Java stack trace. Just a dead executor and a backlog that never recovers.

The fix

--conf spark.executor.memoryOverhead=4g \
--conf spark.sql.streaming.stateStore.rocksdb.boundedMemoryUsage=true \
--conf spark.sql.streaming.stateStore.rocksdb.boundedMemoryRatio=0.3
ConfigWhat it does
spark.executor.memoryOverheadMemory outside the JVM heap for RocksDB, Py4J, netty shuffle, and container overhead. Size this for your RocksDB working set, not just “10% of heap.”
spark.sql.streaming.stateStore.rocksdb.boundedMemoryUsageCaps RocksDB’s block cache and memtable memory. Without this, RocksDB grows unbounded into off-heap memory.
spark.sql.streaming.stateStore.rocksdb.boundedMemoryRatioFraction of memoryOverhead RocksDB may use (default 0.3). Lower this if you have heavy Python worker memory needs.

Rule: memoryOverhead must cover RocksDB bounded memory + Python worker memory + netty shuffle buffers + a safety margin. If you see exit code 137 on Dataproc workers, raise memoryOverhead before you touch spark.executor.memory โ€” the heap was never the problem.

Point RocksDB at Local NVMe SSDs, not Persistent Disk

RocksDB writes state to local disk. On GCP Dataproc, if you do not explicitly point the RocksDB state store directory to the attached Local NVMe SSDs, it writes to the standard Persistent Disk (PD). At 6 GB per 30 seconds with 100,000 keys, standard PD IOPS will throttle the job to death โ€” your 30-second SLA breaches from disk I/O, not GC.

On Dataproc, Local SSDs are mounted at /mnt/disks/ssd0/ (or /mnt/1/yarn/ depending on image version). Point the streaming state store checkpoint location there:

spark.conf.set("spark.sql.streaming.stateStore.stateStoreDir", "/mnt/disks/ssd0/spark-streaming-state")

Or via the Dataproc cluster properties:

--properties=spark:spark.sql.streaming.stateStore.stateStoreDir=/mnt/disks/ssd0/spark-streaming-state

Verify the mount exists before submitting the job. Local SSDs are ephemeral โ€” if a worker is preempted or recreated, the directory must be remounted. For streaming state that must survive restarts, back up the checkpoint directory to GCS (gs://your-bucket/state-checkpoints/) and restore on worker replacement.


โš™๏ธ The micro-batch metadata problem: small chunks, big complexity

You said it yourself: too many small chunks in micro-batch create higher complexity on metadata handling than on the data itself.

At 30-second triggers with 45,000 events/sec, each micro-batch is 6 GB. If you partition poorly, you might get 1,000 tiny partitions of 6 MB each. Spark metadata overhead per partition:

  • Task scheduling
  • Shuffle write/read bookkeeping
  • State store opens/closes
  • RocksDB compaction triggers
  • Serializer context setup

If metadata overhead is 2 seconds per batch and data processing is 4 seconds, 33% of your 30-second window is bookkeeping, not computation.

The math

$$V_{\text{micro\_batch}} = \lambda \times r \times T_{\text{trigger}}$$$$\text{target partitions} = \left\lceil \frac{V_{\text{micro\_batch}}}{128\text{ MB}} \right\rceil$$

Example (base): 1.08 GB / 128 MB โ‰ˆ 9 partitions Example (peak): 6.08 GB / 128 MB โ‰ˆ 48 partitions

If you have 200 executors ร— 5 cores = 1,000 slots but only 48 partitions, 95% of your cluster sits idle while one executor chokes on a skewed store.

The fix: Repartition by store_id before aggregation to distribute keys evenly. Use spark.sql.shuffle.partitions sized for peak micro-batch volume, not the default 200.


๐Ÿ”’ Backpressure and throttling: capping micro-batch recovery

When a GC pause causes a micro-batch to miss its 30-second window, the next trigger will attempt to pull in the accumulated backlog. At 45,000 events/sec, ingesting even a 3-minute backlog (~8.1M events) will instantly crash the executors with OOMs or shuffle spills that never recover. A production guide must mandate Kafka throttling via maxOffsetsPerTrigger to cap micro-batch sizes during recovery.

The backlog death spiral

Batch N:    GC pause takes 12 s โ†’ batch misses 30 s window โ†’ backlog = 30 s of events
Batch N+1:  Processes batch N + backlog (60 s of events) โ†’ takes 90 s โ†’ misses window โ†’ backlog = 90 s
Batch N+2:  Processes batch N+1 + backlog (120 s of events) โ†’ OOM โ†’ executor dies โ†’ backlog = โˆž

Once you enter this spiral, adding executors makes it worse โ€” more executors pull more offsets, creating larger micro-batches that take longer to process, which creates more backlog.

The fix: maxOffsetsPerTrigger โ€” sized for recovery, not just peak

Cap how many Kafka offsets each micro-batch can consume:

spark.conf.set("spark.sql.streaming.kafka.maxOffsetsPerTrigger", "4050000")

Derive the value:

$$\text{maxOffsetsPerTrigger} = \lambda_{\text{peak}} \times T_{\text{trigger}} \times \text{recovery\_multiplier}$$

Example: 45,000 events/sec ร— 30 s ร— 3.0 = 4,050,000 offsets per trigger

The recovery multiplier is not optional. If you cap at exactly peak (1ร—), the system can never burn down a backlog during peak hours. If the pipeline goes down for 5 minutes at lunch, you accumulate 13.5 million events. With a 1.35M cap and 25-second processing, you only burn down 5 seconds of backlog per trigger โ€” recovery takes hours, and your 30-second dashboard SLA is breached the entire time.

Size the cluster for 2โ€“3ร— peak first. maxOffsetsPerTrigger should match what your hardware can actually process per trigger, not what Kafka is sending. Derive your executor count from the recovery target:

$$\text{cores\_for\_recovery} = \left\lceil \frac{\text{maxOffsetsPerTrigger} \times r \times t_{\text{per\_MB}}}{T_{\text{budget}}} \right\rceil$$

Then set maxExecutors in Dataproc Dynamic Allocation to match that recovery capacity, not just steady-state peak.

Pair this with monitoring on the Streaming tab’s input rows/second. If it flatlines at your maxOffsetsPerTrigger ceiling while consumer lag grows, your processing is too slow โ€” add executors or optimize the query, don’t raise the cap blindly.


๐Ÿงฎ Resource sizing math for Dataproc GCP

Dataproc on GCP: you size worker nodes (executors), not individual cores directly. A Dataproc worker node runs one executor (or multiple, depending on config). Common machine type: n2-standard-16 (16 vCPU, 64 GB RAM) or n2-standard-32 (32 vCPU, 128 GB).

Step 1: Cores needed per trigger

$$T_{\text{budget}} = T_{\text{trigger}} \times 0.8 = 24\text{ s} \quad (\text{20\% safety margin})$$$$\text{task-seconds} = \text{partitions} \times t_{\text{per\_partition}}$$

At peak, 48 partitions ร— 1.5 s/partition = 72 task-seconds.

$$\text{cores} = \left\lceil \frac{72}{24} \right\rceil = 3\text{ cores}$$

But shuffle stages need more parallelism:

$$\text{total\_cores} = \max(3,\ 48/2) = \max(3,\ 24) = \textbf{24 cores}$$

Step 2: Executors on Dataproc

--executor-cores 4               # 4 cores per executor
--executor-memory 16g            # 16 GB heap per executor
--conf spark.executor.memoryOverhead=4g  # Python + off-heap
--num-executors 6                # 6 ร— 4 = 24 cores

On Dataproc, 6 n2-standard-16 workers gives you 96 vCPU total, but you only need 24 cores for Spark. The rest is headroom for peak and DRA. Dataproc’s advantage is you can add workers without restarting the job.

Step 3: Dynamic allocation for diurnal load

Retail traffic follows store hours. Base load at night is 1/5th of peak. Static sizing for peak wastes 80% of spend off-peak.

spark.conf.set("spark.dynamicAllocation.enabled", "true")
spark.conf.set("spark.dynamicAllocation.minExecutors", "2")
spark.conf.set("spark.dynamicAllocation.maxExecutors", "12")
spark.conf.set("spark.dynamicAllocation.schedulerBacklogTimeout", "10s")
spark.conf.set("spark.dynamicAllocation.sustainedSchedulerBacklogTimeout", "20s")
spark.conf.set("spark.dynamicAllocation.executorIdleTimeout", "90s")

Derive bounds:

$$\text{minExecutors} = \left\lceil \frac{\lambda_{\text{base}} \times r \times T_{\text{trigger}}}{128\text{ MB} \times \text{cores\_per\_executor}} \right\rceil$$$$\text{maxExecutors} = \left\lceil \frac{\lambda_{\text{peak}} \times r \times T_{\text{trigger}}}{128\text{ MB} \times \text{cores\_per\_executor}} \right\rceil \times 1.2$$

Base: ceil(1.08 GB / (128 MB ร— 4)) = ceil(2.1) = 3 โ†’ set 2 (safety floor) Peak: ceil(6.08 GB / (128 MB ร— 4)) = ceil(11.9) = 12 โ†’ set 12

Step 4: Memory split inside the heap

ConfigValueReason
spark.memory.fraction0.6Default; lower if state cache dominates
spark.memory.storageFraction0.5Half of execution+storage pool for cached state
spark.serializerKryoSerializerSmaller payloads, fewer intermediate objects
spark.memory.offHeap.enabledtruePark state cache outside GC
spark.memory.offHeap.size4gSized for state store working set

Off-heap memory is invisible to the GC. A 4 GB off-heap state cache means 4 GB less heap the collector must walk every micro-batch.


๐Ÿ› ๏ธ Dataproc GCP specific configuration

gcloud dataproc clusters create streaming-retail \
  --region=us-central1 \
  --master-machine-type=n2-standard-16 \
  --worker-machine-type=n2-standard-16 \
  --num-workers=6 \
  --master-boot-disk-size=500GB \
  --worker-boot-disk-size=500GB \
  --image-version=2.1-debian11 \
  --optional-components=ANACONDA,JUPYTER \
  --properties=core:fs.defaultFS=gs://your-bucket/ \
  --properties=spark:spark.dynamicAllocation.enabled=true \
  --properties=spark:spark.dynamicAllocation.minExecutors=2 \
  --properties=spark:spark.dynamicAllocation.maxExecutors=12 \
  --properties=spark:spark.executor.cores=4 \
  --properties=spark:spark.executor.memory=16g \
  --properties=spark:spark.executor.memoryOverhead=4g \
  --properties=spark:spark.sql.streaming.stateStore.rocksdb.boundedMemoryUsage=true \
  --properties=spark:spark.sql.streaming.stateStore.rocksdb.boundedMemoryRatio=0.3 \
  --properties=spark:spark.sql.streaming.kafka.maxOffsetsPerTrigger=1350000 \
  --properties=spark:spark.sql.shuffle.partitions=200 \
  --properties=spark:spark.serializer=org.apache.spark.serializer.KryoSerializer \
  --properties=spark:spark.memory.offHeap.enabled=true \
  --properties=spark:spark.memory.offHeap.size=4g \
  --properties=spark:spark.executor.extraJavaOptions=-Xlog:gc*:time,uptime,level,tags \
  --initialization-actions=gs://dataproc-initialization-actions/python/pip-install.sh

Key Dataproc considerations:

  • Local SSD for state store: RocksDB spills to local disk. Use 500 GB+ boot disks or attach local SSDs. State store size grows with watermark lag; monitor disk usage.
  • Preemptible workers: Use for base load only. Streaming state is not preemption-safe โ€” do not use preemptibles for workers holding streaming state.
  • Regional vs single-zone: Regional clusters survive zone failures but add cross-zone network latency. For 30-second SLA, single-zone with fast local SSD is often better.
  • BigQuery sink: Use the Spark BigQuery connector with writeStream.foreachBatch. Batch writes per micro-batch reduce BigQuery API cost and quota pressure.

๐Ÿ“ฆ Sink latency blind spot: BigQuery in the 30-second budget

For a 30-second SLA, writing the final aggregations to BigQuery can consume 5โ€“15 seconds of that budget. If the network or the BigQuery load API throttles, no amount of GC tuning will save the SLA โ€” you will breach the window before a single executor even finishes processing.

The sink consumes the trigger

Your 30-second budget is not all Spark processing time:

ComponentBudgetReality
Spark micro-batch processing~15โ€“20 sGC, shuffle, state store reads
Network to BigQuery~2โ€“5 sCross-region, packet loss
BigQuery load API write~5โ€“15 sQuota throttling, slot contention
Commit and checkpoint~1โ€“2 sStreaming offset commit
Total30 sCan exceed 40 s easily

If Spark processing takes 20 s and BigQuery takes 12 s, you are at 32 s โ€” already breached. The Streaming tab will show batch duration under 25 s (Spark only), giving you false confidence while the dashboard goes stale.

The fix

  1. Budget the sink separately. Treat sink time as a first-class citizen in your SLA math. If BigQuery consistently takes >10 s, reduce write frequency (write every 2โ€“3 micro-batches instead of every trigger).

  2. Use foreachBatch with batched writes. Do not write row-by-row. Accumulate micro-batch results and write in larger chunks to amortize BigQuery API overhead:

def write_to_bigquery(batch_df, batch_id):
    batch_df.write \
        .format("bigquery") \
        .option("table", "project:dataset.sales_agg") \
        .mode("append") \
        .save()

agg.writeStream \
    .foreachBatch(write_to_bigquery) \
    .option("checkpointLocation", "gs://your-bucket/checkpoints/") \
    .trigger(processingTime="30 seconds") \
    .start()
  1. Monitor sink duration. In the Spark UI Streaming tab, the Sink duration column tells you how long each micro-batch spent writing. If it is creeping toward 10โ€“15 s, your BigQuery write is the bottleneck โ€” not GC, not partitioning, not memory.

  2. Consider streaming insert vs batch load. For sub-30-second latency, BigQuery’s streaming insert API has lower latency than batch load but higher cost and stricter quota. Choose based on your SLA vs budget tradeoff.


๐Ÿ”„ The diagnosis loop for streaming

Internalize this loop. Do not skip ahead.

Streaming tab: batch duration vs T_trigger
โ”‚
โ”œโ”€ Is batch duration < 25 s consistently?
โ”‚   โ”œโ”€ NO โ”€โ”€โ–บ Executors tab: GC/Task ratio per executor
โ”‚   โ”‚   โ”œโ”€ One executor high โ”€โ”€โ–บ Stages tab: skew on store_id
โ”‚   โ”‚   โ”‚   fix: repartition by store_id, salt hot keys
โ”‚   โ”‚   โ””โ”€ All executors high โ”€โ”€โ–บ GC log: churn vs retention
โ”‚   โ”‚       โ”œโ”€ Many young GC, low reclaim โ”€โ”€โ–บ MEMORY_AND_DISK_SER, Kryo
โ”‚   โ”‚       โ””โ”€ Pause Full present โ”€โ”€โ–บ heap too small or state too large
โ”‚   โ””โ”€ YES โ”€โ”€โ–บ Storage tab: state store size bounded?
โ”‚       โ”œโ”€ NO โ”€โ”€โ–บ Watermark too low or event-time skew
โ”‚       โ”‚   fix: raise watermark, monitor late data ratio
โ”‚       โ””โ”€ YES โ”€โ”€โ–บ Sink tab: BigQuery write time
โ”‚           โ”œโ”€ High โ”€โ”€โ–บ increase foreachBatch batch size, reduce write frequency
โ”‚           โ””โ”€ Stable but total latency > 30 s โ”€โ”€โ–บ Sink is the bottleneck, not Spark
โ”‚
โ”œโ”€ Exit code 137 / OOM killed on Dataproc workers?
โ”‚   โ””โ”€ YES โ”€โ”€โ–บ RocksDB off-heap memory exceeded limit
โ”‚       fix: raise spark.executor.memoryOverhead, enable rocksdb.boundedMemoryUsage
โ”‚
โ””โ”€ Consumer lag growing after GC incident?
    โ””โ”€ YES โ”€โ”€โ–บ maxOffsetsPerTrigger too low or processing too slow
        fix: raise cap, add executors, or optimize query โ€” do not raise blindly

One lever per iteration. Change four configs at once and you learn nothing โ€” you get a new latency number with no causal story.


๐Ÿ“‰ What it looks like on the bill

Numbers below are representative of a real retail streaming pipeline after three changes: repartition by store_id, MEMORY_AND_DISK_SER for dimension cache, and off-heap state store on Dataproc.

MetricBeforeAfter
GC time / 30s trigger12 s (40%)2 s (7%)
Average batch duration34 s (breach)18 s (meets SLA)
State store size2.4 GB, growing600 MB, bounded
Dataproc workers (n2-standard-16)12 static6 min, 12 max DRA
End-to-end latency3โ€“40 min drifting25โ€“28 s stable
Core-hours per day2,8801,152
Daily Dataproc cost~$864~$346

The arithmetic: before, 12 workers ร— 16 vCPU ร— 24 h = 4,608 core-hours. But you only needed 24 cores at peak. After DRA, average is ~48 core-hours ร— 24 h = 1,152. ~75% less compute for the same SLA.

You did not process less data. You stopped paying for GC pauses, unbounded state, and idle executors.


โœ… The streaming checklist

Run it in this order:

  1. Streaming tab โ€” record batch duration vs 30 s trigger. Is it stable or drifting?
  2. Executors tab โ€” compute GC Time รท Task Time per executor. One high = skew. All high = pressure.
  3. Stages tab โ€” compare max task duration to p75 on the dominant streaming stage. Rule out skew before touching JVM.
  4. Storage tab โ€” is state store size bounded by watermark? If growing, your watermark is wrong.
  5. GC log โ€” confirm -Xlog:gc*:... is on executors (not driver). Count Pause Full occurrences per hour.
  6. Cache audit โ€” find every .persist() in the streaming path. Convert to MEMORY_AND_DISK_SER or remove.
  7. Repartition check โ€” are keys distributed evenly across executors? If one store is 10ร— the volume, salt or repartition.
  8. Dataproc DRA โ€” set min/max executors from the math above. Monitor scale-up/down frequency.
  9. RocksDB off-heap โ€” enable spark.sql.streaming.stateStore.rocksdb.boundedMemoryUsage and size spark.executor.memoryOverhead for the RocksDB working set.
  10. Throttling โ€” set spark.sql.streaming.kafka.maxOffsetsPerTrigger to prevent backlog death spiral during recovery.
  11. Late data guard โ€” add a filter to drop events older than your watermark tolerance before they reach the state store.
  12. Sink duration โ€” monitor BigQuery write time in the Streaming tab; it can consume 5โ€“15 s of your 30-second budget.
  13. Re-measure โ€” compare the same two numbers (batch duration, GC/Task ratio) after each change.

The one-sentence version

In streaming, GC time is a recurring tax on every micro-batch. Fix partition skew, serialized caching, watermark bounds, and off-heap state before adding executors โ€” because more executors on a thrashing heap just buys you a larger invoice for the same stale dashboard.

#PySpark #StructuredStreaming #GC #Dataproc #GCP #StreamingEngineering #RetailAnalytics #PerformanceTuning #DataEngineering #JVM

Activity Feed

GC Pressure, Memory, and Watermarks in PySpark Structured Streaming

Oct 31, 2026 · New Article

GC Pressure in Spark: How to Find It, Read It, and Actually Fix It

Oct 12, 2026 · New Article

Welcome to the Field Notes

Oct 11, 2026 · New Article

The Real Math Behind PySpark Resource Sizing

Oct 11, 2026 · New Article

The Ultimate SQL Cheatsheet

Oct 11, 2026 · Cheatsheet Updated

View All Activity →