Skip to main content

Skew Diagnosis with Percentiles: Max vs Median, Not Max vs Mean

Level 10: Distributed Compute & Data Operationshard28 mindata skew diagnosismedian via ROW_NUMBERpercentiles in SQLAQE skew-join splittingsalting a hot keyspill as a corroborating signalwindow functions

Compute a median and a percentile in plain SQL, apply the max-over-median skew signal and AQE's 5x-median rule, and name the fix ladder in the order an interviewer expects.

The straggler poisons the average it is hiding in

Level 6 compared a slow task to the stage average and flagged that this was an imperfect proxy. This lesson is that caveat, promoted to the whole point.

Say a stage runs seven tasks: six finish in about 400 seconds and one takes 2,400. The mean is about 690 seconds, so the straggler looks like it took 3.5 times the average. That understates it badly, because the straggler is one of the numbers being averaged. It drags the mean up toward itself and then hides behind the number it moved.

The median is immune to that. Six tasks at roughly 400 seconds and one at 2,400 gives a median of 400, and 2,400 over 400 is a clean 6x. That ratio, max task time over median task time, is the signal every Spark interview corpus cites verbatim, and it is what the Spark UI's summary-metrics row is showing you when it prints min, 25th, median, 75th, and max side by side.

Table
Same seven tasks, three ways of asking the same question. Only the median-based ratio reports the skew at its true size.
what you comparethe number it giveswhy it misleads
max over mean3.5xthe straggler is inside the mean, pulling it up
max over median6xnothing: the median ignores the outlier entirely
max minus mean1,710san absolute number with no scale to judge it against
Same seven tasks, three ways of asking the same question. Only the median-based ratio reports the skew at its true size.

Computing a median in SQL that has no median function

SQLite has no PERCENTILE_CONT, and that limitation is useful, because the technique that works without it works everywhere:

  1. Number the rows within each group, ordered by the value: ROW_NUMBER() OVER (PARTITION BY stage_id ORDER BY duration_ms).
  2. Count the rows in the same group: COUNT(*) OVER (PARTITION BY stage_id).
  3. Keep the row whose number is the middle one: WHERE rn = (n + 1) / 2.

With 7 rows, (7 + 1) / 2 is 4, the fourth-smallest; with 11 rows it is 6. Integer division does the work, and because the rank comes out of the row count rather than out of your head, the same query is correct for a stage with 5 tasks and a stage with 500. The same shape gives you any percentile: the 75th is rn = (3 * n + 3) / 4, which is the nearest-rank definition of a percentile written with integers.

AQE's rule, with its real numbers

Adaptive Query Execution will split a skewed shuffle partition automatically, but only when both conditions hold: the partition is more than 5 times the median partition, and it is larger than 256 MB. Those are two settings with names worth knowing, spark.sql.adaptive.skewJoin.skewedPartitionFactor (default 5) and spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes (default 256 MB). Both matter. A partition 8 times the median but only 40 MB is not worth splitting, so AQE leaves it, and if your job is still slow you are looking at the wrong stage.

Be careful which ratio you are applying. The signal you report to a human is max task TIME over median task time, because time is what the Spark UI puts in front of you and time is what the person asking cares about. The rule AQE acts on is about SIZE: the factor of 5 and the 256 MB floor are both measured in bytes, against the median partition size. Usually the two agree, since a partition that holds five times the data takes roughly five times as long. When they disagree, the size numbers are the ones that decide whether the split happens, and a task that ran long because it spilled is the usual reason they disagree: spilling makes time grow faster than bytes.

The fix ladder

In the order an interviewer wants to hear it:

  1. Let AQE split it. Free, already on by default since Spark 3.2 and Glue 4.0, and it is the modern first answer. Confirm it is enabled before you do anything cleverer.
  2. Broadcast the small side. Only if the small side fits the band from the module opener: under 10 MB automatically, up to about 100 MB by hint. This removes the shuffle entirely, so the hot key stops being a problem instead of being split.
  3. Salt the hot key. Append a small random suffix to the skewed key on the big side, replicate the small side across every salt value, join on the salted key, then aggregate away the salt. It works on any engine and needs no runtime magic, which is why it is still the answer when the first two do not apply.

spill_mb corroborates. A straggler that also spilled was not merely unlucky in scheduling; it genuinely held more data than its share of memory could take, which is the fingerprint of a hot key rather than a slow machine.

Common mistake: treating one slow task as skew without checking the others. If every task in the stage is slow, the stage is just big, and the answer is more parallelism or less data, not salting. Skew is a distribution claim, so it needs a distribution to back it.

Interview nuance: "one task took 40 minutes, the median was 2 minutes, diagnose it" is the detection half of the Amazon EMR question. The expected answer names the ratio first (20x, so this is skew, not size), then the corroborating evidence (spill, and the partition's row count), then the ladder in order. Candidates who jump straight to salting without measuring first lose the point.

On a real platform this differs. The Spark UI hands you the percentiles precomputed in the stage summary-metrics table, so nobody writes this query against a live cluster. You write it against exported history, against Glue job metrics in CloudWatch, or against a query-history table in a warehouse, where the same ROW_NUMBER trick is the only way to get a median out. And in a warehouse, skew shows up as one distribution slice doing all the work rather than one task, which you find with the same ratio over a different table.

Sample data for this example
CREATE TABLE spark_tasks (
  task_id         INTEGER,
  stage_id        INTEGER,
  duration_ms     INTEGER,
  shuffle_read_mb INTEGER,   -- the size of the one shuffle partition this task read
  spill_mb        INTEGER
);
INSERT INTO spark_tasks (task_id, stage_id, duration_ms, shuffle_read_mb, spill_mb) VALUES
  (2101, 21,  250000, 120,    0),
  (2102, 21,  320000, 140,    0),
  (2103, 21,  380000, 130,    0),
  (2104, 21,  400000, 110,    0),
  (2105, 21,  460000, 150,    0),
  (2106, 21,  520000, 160,    0),
  (2107, 21, 2400000, 900, 8192),
  (2201, 22,  110000, 190,    0),
  (2202, 22,  118000, 195,    0),
  (2203, 22,  122000, 200,    0),
  (2204, 22,  125000, 205,    0),
  (2205, 22,  130000, 210,    0),
  (2301, 23,    6000,  20,    0),
  (2302, 23,    7000,  22,    0),
  (2303, 23,    8000,  24,    0),
  (2304, 23,    9000,  26,    0),
  (2305, 23,   10000,  28,    0),
  (2306, 23,   11000,  30,    0),
  (2307, 23,   12000,  32,    0),
  (2308, 23,   13000,  34,    0),
  (2309, 23,   95000, 256,  256),
  (2401, 24,   40000, 120,    0),
  (2402, 24,   42000, 125,    0),
  (2403, 24,   45000, 130,    0),
  (2404, 24,   48000, 134,    0),
  (2405, 24,   50000, 136,    0),
  (2406, 24,   52000, 140,    0),
  (2407, 24,   55000, 142,    0),
  (2408, 24,   58000, 146,    0),
  (2409, 24,   60000, 150,    0),
  (2410, 24,   65000, 154,  128),
  (2411, 24,  300000, 700, 2048),
  (2501, 25,   20000,  90,    0),
  (2502, 25,   22000,  95,    0),
  (2503, 25,   24000, 100,    0),
  (2504, 25,   26000, 105,    0),
  (2505, 25,   28000, 110,    0),
  (2506, 25,   30000, 115,    0),
  (2507, 25,  143000, 600, 4096);
Worked example (SQL)
-- The task-duration distribution for one stage, slowest last. Notice how far the
-- last task sits from every other one, and that it is the only one that spilled.
SELECT task_id, duration_ms, shuffle_read_mb, spill_mb
FROM spark_tasks
WHERE stage_id = 21
ORDER BY duration_ms;

Apply

Your turn

The task this lesson builds to.

Write a query that returns the skew signal for every stage, as (stage_id, median_ms, max_ms, max_over_median), highest ratio first, over spark_tasks(task_id, stage_id, duration_ms, shuffle_read_mb, spill_mb).

median_ms is the middle task duration in the stage, found with ROW_NUMBER rather than a percentile function. max_ms is the slowest task. max_over_median is max_ms divided by median_ms, rounded to 2 decimals. Every stage has an odd number of tasks, so the median is a single row. Alias the columns exactly.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 3 bonus drills.

Write a query that returns the stages AQE would actually split for skew, as (stage_id, max_over_median, max_partition_mb, size_over_median), biggest size_over_median first, over spark_tasks(task_id, stage_id, duration_ms, shuffle_read_mb, spill_mb) and shuffle_partition_stats(stage_id, partition_id, rows, size_mb).

AQE decides on partition sizes, so size_over_median is the stage's largest size_mb divided by its median size_mb, and the stage is split only when both conditions hold: size_over_median is more than 5, and max_partition_mb is more than 256. Both cutoffs are strict, so a stage sitting exactly on either one is not split. Report max_over_median, the task-time ratio from Apply, alongside them, because that is the number you would show a human even though it is not the number AQE tests. Round both ratios to 2 decimals.

3 hints and 1 automated check are waiting in the workspace.