Paths Subjects Questions Quizzes Pricing Search
Advanced Open Free

A GroupBy Aggregation Hangs at 799/800 Tasks

A nightly job aggregates total watch-time per device_id from a 120GB events table:

result = (
    events
    .groupBy("device_id")
    .agg(F.sum("watch_seconds").alias("total_watch_seconds"))
)
result.write.parquet(output_path)

In the Spark UI, the aggregation stage shows 799 of 800 tasks completing in 20-40 seconds each. The 800th task is still running after 90 minutes. Its "Shuffle Read Size / Records" column shows 340,000,000 records, versus a median of roughly 600,000 records for the other 799 tasks. Data-team context: device_id is nullable, and roughly 15% of raw events fail device attribution and are landed with device_id = NULL.

  1. Diagnose the root cause precisely — not just "it's skewed," but why this specific key ended up this large.
  2. Propose a fix, including whether salting is appropriate here and why (or why not), and show the code.
  3. Is there a fix that's simpler than salting for this specific case, given what you know about the NULL key? Would you use it instead, or in addition to a general skew-handling strategy?
Solution

1. Root cause

This is single-key skew, and the specific mechanism matters: NULL is being treated as a valid device_id for grouping purposes, so every event that failed device attribution — 15% of a 120GB table, which is a very large absolute number of rows — collapses into one group. The task handling the device_id = NULL partition has to shuffle-read roughly 340M records, ~570x the median task's 600K, which exactly matches the observed task-duration outlier. This is a different flavor of skew than a single "hot" real key (like a popular product) — it's a sentinel/missing-value key absorbing everything that didn't get a real value, which is extremely common in production event pipelines and worth naming as its own category in an interview answer.

2. Is salting appropriate, and the fix

Salting is appropriate here if the NULL group's aggregate value is actually needed downstream (e.g., "unattributed watch time" is a real metric someone consumes). sum is associative and decomposable, so a two-pass salted aggregation is a correct fix:

SALT_BUCKETS = 50  # NULL key needs more buckets than a typical hot key given its size

salted = events.withColumn(
    "salt",
    F.when(F.col("device_id").isNull(), (F.rand() * SALT_BUCKETS).cast("int"))
     .otherwise(F.lit(0))  # non-null keys don't need salting, keep them as one bucket
)

partial = (
    salted
    .groupBy("device_id", "salt")
    .agg(F.sum("watch_seconds").alias("partial_sum"))
)

result = (
    partial
    .groupBy("device_id")
    .agg(F.sum("partial_sum").alias("total_watch_seconds"))
)

Only salting the NULL key (rather than every key) keeps the fix targeted — salting every key would add unnecessary shuffle overhead to the 799 already-fast tasks for no benefit, since they weren't skewed to begin with. The second-stage shuffle here is cheap: it's shuffling num_real_keys + SALT_BUCKETS rows, not the original 120GB.

3. A simpler fix given the NULL-specific context

Yes — if unattributed watch time doesn't need to be broken out by any dimension (it's just "total unattributed," a single number), the simplest fix is to filter device_id IS NOT NULL before the aggregation and compute the NULL-group total separately with a trivial filter().agg(F.sum(...)) (a full-table scan and a simple sum, no per-key grouping, so no skew is possible — there's only one group). This avoids salting's complexity entirely for this specific case:

real_device_totals = (
    events.filter(F.col("device_id").isNotNull())
    .groupBy("device_id")
    .agg(F.sum("watch_seconds").alias("total_watch_seconds"))
)
unattributed_total = (
    events.filter(F.col("device_id").isNull())
    .agg(F.sum("watch_seconds").alias("total_watch_seconds"))
    .withColumn("device_id", F.lit(None))
)
result = real_device_totals.unionByName(unattributed_total)

I'd use this instead of salting for this specific case — it's simpler and directly exploits the fact that the skewed group doesn't need further breakdown. I'd still keep AQE's automatic skew-join handling enabled as a general safety net for other skew that might show up in joins elsewhere in the pipeline (a real hot device, for instance), since that's a zero-cost default that doesn't require knowing about every possible skewed key in advance the way this targeted fix does.

Share this question

← Back to Spark Performance Tuning practice

We use cookies for product analytics to improve OmniAtlas. See our Privacy Policy.