Requirements:- Detect and fix severe data skew that causes long tasks and executor OOM in a Spark job while preserving correctness and performance.- Provide debugging, mitigation (split heavy keys, map-side pre-aggregation, salting), and a minimal repro/verification approach.High-level approach:1) Detect skew from metrics- Inspect Spark UI: task time/GC/Shuffle read/write per task, executor logs. Skew shows small number of tasks with very high duration/shuffle/read bytes or repeated OOMs.- Use job metrics: per-partition sizes from mapOutputMetrics, stage/task metrics, and application logs. Export to monitoring (Prometheus/Grafana) and alert on 95/99th percentile task time, max shuffle bytes per task.- Run a quick job to compute partition size histogram:python
# pyspark: size per key partition
rdd = sc.textFile(...)
pairs = rdd.map(lambda x: (key_fn(x), 1))
partition_sizes = pairs.partitionBy(100).mapPartitions(lambda it: [sum(1 for _ in it)]).collect()
2) Root-cause analysis- Find heavy keys by counting keys and top-N by size (sample if data huge) using approximate algorithms (HyperLogLog, Count-Min Sketch) or a reduceByKey(count). Identify whether skew arises from logical key hotspot or bad joins.3) Mitigations- Map-side pre-aggregation / combiner - aggregateByKey / map-side combine to reduce shuffle volume before partitioning. - Example: use combineByKey or reduceByKey with numPartitions tuned.- Split heavy keys (key splitting) - For a small set of heavy keys, expand them into sub-keys before shuffle: (k -> (k, salt)) where salt in 0..N-1 chosen by hashing or round-robin, aggregate, then merge subkeys back. - Use two-stage approach: 1. map: (k -> (k, salt)) and aggregate 2. reduce: remove salt and final aggregate- Salting strategies - Deterministic salting for joins: add salt to both sides where heavy-key counts known; otherwise asymmetric salting (salt only larger side) then replicate small side appropriately. - Choose salt factor based on heavy-key size and target partition size: salt = ceil(size_of_key / target_partition_size)- Adaptive partitioning - Use custom Partitioner that routes heavy keys across many partitions; or use RangePartitioner after sampling.- Memory tuning and spill controls - Increase shuffle.memoryFraction (Spark config), enable external shuffle service, tune spark.memory.offHeap if offheap used, increase spark.executor.memory temporarily. - Prefer algorithmic fixes over memory increases.4) Verification with minimal repro- Create a small dataset that reproduces skew: include heavy key with scaled-up count and run the pipeline locally or on a small cluster with same partitioning logic.- Example repro:python
# create RDD with one heavy key
heavy = sc.parallelize([("hot", i) for i in range(1000000)])
others = sc.parallelize([(f"k{i}", i) for i in range(1000)])
rdd = heavy.union(others)
# run join/aggregation steps to trigger skew
- Apply fix (e.g., salting) and compare: - Task duration distribution (max vs median) - Shuffle bytes per task - Peak executor memory / GC pauses - Correctness of aggregate results- Automate tests in CI with scaled-down but proportionally skewed data.Trade-offs:- Salting increases shuffle/metadata and complicates logic — must remove salt after aggregation.- Map-side aggregation is best when aggregation function is associative/commutative.- Memory increases are short-term relief; algorithmic fixes scale better.Summary checklist to run in production:- Alert on skew metrics (max shuffle bytes, task time)- Identify top keys via sampling/aggregates- Implement map-side pre-aggregation + selective salting for hot keys- Validate on minimal repro and measure task distributions and memory- Deploy with feature flag, monitor, and roll back if regressions appear.