Un job che prima finiva in 12 minuti adesso ne prende 90. Il tuo manager vuole sapere il perché prima dello standup. Hai un tab della Spark UI aperto e una sensazione vaga.
Questo è il momento in cui tutti vogliono iniziare a tunare. Vanno dritti su spark.sql.shuffle.partitions, alzano la executor memory, spruzzano chiamate repartition(), cachano cose a caso. Niente di tutto questo funziona perché niente di tutto questo è stato diagnosticato. Bruciano un’ora e il job è ancora lento.
La disciplina è: non tunare mai alla cieca, leggi sempre prima la UI. Sotto c’è il loop da 30 minuti che faccio girare su qualsiasi job lento. Sei step, in ordine, ogni volta. Per la maggior parte dei job troverai la risposta allo step 2 o 3. Gli altri step ci sono per i casi più tosti.
Step 1 - Apri la UI e trova lo stage lento (3 minuti)
I job lenti di solito sono lenti per uno stage, non tutti.
Apri la Spark UI (history server se il job è già finito, UI live se sta ancora girando). Clicca Jobs. La run lenta è quella con la barra orizzontale lunga.
Cliccaci dentro. Adesso sei sul tab Stages per quel job. Ordina per Duration decrescente. La riga in cima è il tuo colpevole.
Stage Id | Description | Duration | Tasks | Input | Shuffle Read
18 | aggregate at App.scala:142 | 47 min | 200 | 1.2 GB | 89 GB
12 | scan parquet ... | 4.1 min | 1200 | 380 GB | 0
...
Lo stage 18 è dove sono andati i 47 minuti. Tutto il resto è rumore. Da adesso in poi, ogni step è sullo stage 18.
Una nota sulla lettura delle durate: la barra dello stage è wall-clock dall’inizio alla fine. Dentro hai molti task. Il wall-clock può essere lungo perché i task sono lenti, o perché lo stage ha molte ondate di task, o perché un task è molto più lento degli altri. Non puoi ancora dirlo. Vai avanti.
Step 2 - Distribuzione della durata dei task (5 minuti)
Clicca dentro lo stage lento. Scorri fino a Summary Metrics for Completed Tasks. Questa tabella è oro. Ti da’ min/25esimo/mediana/75esimo/max per ogni metrica per-task: duration, GC time, input size, shuffle read, shuffle write, peak memory.
La colonna da guardare per prima è Duration.
Distribuzione sana:
Min: 4s | 25th: 6s | Median: 7s | 75th: 8s | Max: 12s
I task finiscono in una banda stretta. Lo stage sta semplicemente lavorando. Vai allo step 3.
Distribuzione skewed:
Min: 0.4s | 25th: 1.1s | Median: 1.4s | 75th: 2.3s | Max: 41 min
Quel max è lo stage intero. Un task sta facendo il 99% del lavoro. Hai skew. Questa è la singola causa più comune di “il mio job è diventato lento” su un workload reale.
Il fix path è nelle lezioni 28 e 29: capisci quale chiave è hot (groupBy e count, cerca gli outlier giganti), poi o la filtri via, broadcasti il lato piccolo, o salti la chiave di join. AQE gestisce molti casi automaticamente (lezione 59), quindi controlla prima se AQE è abilitato: se non lo è, attivarlo è mezzo flip di config.
Step 3 - Frazione di GC time (3 minuti)
Nella stessa tabella Summary Metrics, guarda GC Time. Sano è GC time sotto il 10% della task duration. Se vedi GC time al 30% della duration sul task mediano, i tuoi executor stanno torturando il garbage collector ed è per quello che il lavoro arranca.
Lo puoi vedere anche sul tab Executors: una colonna Task Time (GC Time) mostra i secondi totali spesi e i secondi totali in GC, per executor. Se il GC è oltre il 20% circa del task time, la JVM è affamata.
Possibili cause:
- Un DataFrame cachato troppo grande per la executor memory. Controlla il tab Storage: se hai cachato 80 GB su executor con 12 GB usabili ciascuno, le eviction sono costanti. Passa a
MEMORY_AND_DISK_SER, o non cachare, o cacha una projection più piccola. - Uno shuffle che tira troppe righe in un singolo task (rivedi lo step 2: potrebbe essere skew che porta pressione sul GC).
- Memoria configurata troppo stretta. La lezione 57 copre
spark.executor.memory,memoryOverhead, e lo split on-heap vs off-heap.
Tunare la memoria alla cieca senza controllare il GC time è il modo più comune di sprecare un venerdi’ pomeriggio. Controlla il numero per primo.
Step 4 - Shuffle read e shuffle write (5 minuti)
Guarda le colonne Shuffle Read e Shuffle Write a livello di stage. Confrontale con la dimensione del tuo output.
Se lo stage produce 200 MB e shuffla 89 GB, stai muovendo 450 volte più dati di quanti te ne servano. Il fix è quasi sempre filtrare e fare project prima, prima dello shuffle, non dopo. L’optimizer di Spark fa parte di questo lavoro per te, ma non può pushare filtri attraverso UDF, attraverso stringhe selectExpr che non capisce, o attraverso codice utente che branca imperativamente sui dati.
Vittorie rapide:
- Sposta le
.filter()prima dei join. - Sostituisci
select("*")con le colonne che ti servono davvero. - Droppa i campi struct annidati che non usi (
select("id", "amount", "ts")invece di portarti dietro un record da 200 campi attraverso cinque stage). - Se due stage shufflano entrambi sulla stessa chiave, vedi se puoi fare
.repartition(key)una volta e riutilizzarla via cache, o usare tabelle bucketed (lezione 36).
Controlla anche come il volume di shuffle scala con l’input. Un input da 4 GB che produce 100 GB di shuffle implica row explosion, di solito un join many-to-many. Le lezioni 25-30 coprono i meccanismi di join in dettaglio.
Step 5 - Leggi il SQL plan (5 minuti)
Clicca il tab SQL / DataFrame nella UI. Trova la query del tuo job lento (i timestamp e le duration aiutano a fare il match). Cliccaci dentro. Hai il physical plan, con metriche per-operatore.
Cosa cercare, in ordine di “questo è brutto”:
BroadcastNestedLoopJoin. Quasi sempre sbagliato. Significa che Spark non ha trovato una condizione di join e sta facendo un prodotto cartesiano. Controlla il join: hai dimenticato la clausolaon=? Stai facendo join su una funzione tipodf1.id == upper(df2.id)che non è stata tradotta? Questo operatore è un allarme rosso su qualsiasi input non minuscolo.SortMergeJoindove ti aspettavi un broadcast. Il lato piccolo non era abbastanza piccolo (la soglia di broadcast di default è 10 MB, alzala conspark.sql.autoBroadcastJoinThreshold). Oppure la sua stima di size è sbagliata perché viene da un DataFrame per cui Spark non aveva statistiche. Forzalo conbroadcast(df)(lezione 27).- Operatori Filter che stanno sopra uno scan invece di essere pushati dentro. Per i source Parquet/ORC, il predicate pushdown dovrebbe ripiegarli dentro. Se non sono pushati, il tuo filtro è su una colonna che il source non sa filtrare (un risultato di UDF, un path di campo struct, un’espressione).
- Subexpression ripetute: lo stesso filtro o projection che compare in più rami del plan. Una
cache()del DataFrame condiviso può ripagarsi in fretta.
Il plan ti dice cosa Spark sta davvero facendo, non cosa il tuo codice Python suggerisce. Divergono.
Step 6 - Input size e spill (4 minuti)
Altri due numeri, entrambi sul tab Stages.
Input size: dovrebbe corrispondere a quello che ti aspetti. Se pensi di stare scannando una tabella partizionata da 4 GB e lo stage mostra Input: 380 GB, il tuo partition pruning è fallito. Causa comune: un filtro su una colonna che non è la colonna di partition, o un’espressione di filtro che sconfigge il pruning (partition_dt = string_col dove string_col è un varchar non partizionato che Spark non riesce a dimostrare costante).
Spill (Memory) e Spill (Disk): queste colonne compaiono quando i task hanno finito lo spazio in memoria e hanno dovuto spillare i buffer di sort/aggregation. Spill non zero significa che un task ha provato a tenere più di quanto stesse. Due strade: alza la executor memory (lezione 57) o partiziona più piccolo (più partition = meno per task = meno spill).
Se vedi grosso spill su disco, il tuo job è bottlenecked sul throughput dell’SSD locale. In cloud, è il disco effimero dell’executor. Alcune forme (sort pesante + poca memoria) spilleranno sempre: il tuo job è quello che è e la domanda è solo se lo spill è accettabile.
Mettendola insieme: la checklist stampabile
Salvala da qualche parte. Attaccala al monitor. La prossima volta che qualcuno dice “il job è lento”:
- Apri la Spark UI. Trova lo stage più lento per duration.
- Guarda la distribuzione della duration dei task. Max vs mediana. Gap grosso = skew, lezioni 28-29.
- Guarda il GC time. Oltre il 20% del task time = pressione di memoria, lezione 57 o sistema il caching.
- Guarda shuffle read/write. Centinaia di GB shufflati per un piccolo output = filtra prima, lezioni 25-30.
- Leggi il SQL plan.
BroadcastNestedLoopJoin, broadcast mancante, filtro sopra lo scan, sistema la query. - Guarda input size e spill. Scan size sbagliata = pruning fallito; spill non zero = memoria o partition sbagliate.
La disciplina è l’ordine. Lo skew è più comune dei problemi di memoria. I problemi di memoria sono più comuni dei problemi di plan. I problemi di plan sono più comuni dei problemi di scan. Risolverai l’80% dei job lenti allo step 2.
Cosa questa checklist non prenderà
- Problemi di cluster fuori dal job. Un vicino rumoroso su infra condivisa, un nodo degradato, una network partition. La UI ti mostra una vista del mondo a forma di Spark; se la VM sottostante è malata, gli executor sembreranno lenti senza una causa ovvia. Controlla i log degli executor e le metriche di piattaforma.
- Codice che è solo lento. Una UDF Python che fa una regex su una colonna da 200 GB sarà lenta a prescindere da come tuni Spark. La UI ti dirà che sta passando tutto il suo tempo in
MapPartitionsvicino a un Python row, ma il fix è “smetti di usare una UDF Python” (lezione 40), non “tuna la executor memory”. - Regressioni di data quality. Ieri il job girava su 4 GB. Oggi sta girando su 40 GB perché upstream ha iniziato a fare double-write. Il job non è lento, l’input è più grande. Controlla sempre per primo l’input size quando “il job è diventato lento di notte”.
La promessa dei 30 minuti
Il loop sopra ci mette 30 minuti le prime volte che lo fai. Poi 15. Poi 5, perché imparerai a gettare uno sguardo ai tab giusti e saltare le parti sane. Quando avrai debuggato venti job lenti avrai un’intuizione su dove guardare per primo basandoti sui sintomi.
Ma torna sempre alla disciplina. Il giorno in cui inizi a tunare prima di leggere la UI è il giorno in cui passerai tre ore a sistemare la cosa sbagliata.
Prossima lezione: Adaptive Query Execution. La feature di Spark che gestisce molti dei problemi sopra automaticamente, quando può. Vedremo quando può, quando non può, e come si leggono i plan che riscrive a runtime.