Abbiamo passato le ultime quarantotto lezioni su quello che Spark chiama processing batch: hai dei dati, stanno fermi in un file o in una tabella di database, lanci un job, il job finisce, ottieni un output. I dati c’erano prima che iniziassi; i dati ci sono dopo che hai finito. Fatto.
Il modulo 9 è dedicato all’altra metà della storia. I dati non sono ancora arrivati tutti. Stanno ancora arrivando. Continueranno ad arrivare domani, la settimana prossima, l’anno prossimo, finché qualcuno non spegne il sistema. Il tuo lavoro è calcolare sopra di essi mentre si presentano, non in un singolo passaggio che termina, ma in un processo che gira di continuo ed emette risultati man mano che diventano disponibili.
Questo è lo streaming. E prima di toccare codice, voglio rallentare e fissare cosa significa davvero quella parola in Spark, perché è uno di quei termini in cui il marketing è corso più avanti dell’ingegneria e l’ingegneria è cambiata due volte nell’ultimo decennio.
Dati bounded vs unbounded
Il modo più pulito di pensarci non è “batch” vs “streaming”, ma la forma del dataset.
I dati bounded sono un dataset fisso con una dimensione nota. Gli ordini di ieri. Le transazioni dell’ultimo trimestre. Il contenuto di un file Parquet su S3. Lo stato corrente di una tabella Postgres nel momento in cui la leggi. C’è un MIN, un MAX, un conteggio di righe. Puoi calcolare sull’intero dataset. Puoi finire.
I dati unbounded sono un feed che non finisce. Eventi Kafka che arrivano da un webserver. Righe di log applicativi scritte ogni secondo. Letture di sensori IoT, click stream, eventi CDC da un database OLTP, ping GPS da una flotta. Non c’è un MAX perché i dati vengono ancora prodotti. Le righe che vedi oggi sono una piccola fetta di quelle che alla fine esisteranno. Puoi calcolare su quanto è arrivato finora, ma non puoi mai finire.
Entrambi i tipi di dati sono reali, entrambi sono comuni, e Spark può processare entrambi. La distinzione conta perché le ipotesi che puoi fare sono molto diverse. Con dati bounded puoi ordinare l’intero dataset. Puoi calcolare aggregazioni esatte. Puoi iterare due volte. Con dati unbounded niente di tutto questo funziona, almeno non allo stesso modo. Non puoi ordinare una lista infinita. Non puoi calcolare “la media di tutte le righe” perché ne stanno arrivando altre. Devi ridefinire cosa significa “risultato”.
Lo streaming, in Spark, è l’API per calcolare su dati unbounded.
Batch e streaming sono un continuum
Ecco la cosa che la doc non sempre rende ovvia: non c’è una linea netta tra batch e streaming. Sono due estremi di uno spettro.
Un ETL notturno che processa le ultime 24 ore di dati è “batch”, ma solo perché qualcuno ha deciso che una volta al giorno bastava. Se lanciassi lo stesso job ogni ora, poi ogni minuto, poi ogni 10 secondi, staresti facendo streaming a micro-batch senza cambiare la logica. Al limite, ogni-riga-appena-arriva, staresti facendo streaming record-at-a-time. La differenza è la latenza, non l’operazione.
Questa è l’intuizione centrale dietro l’API di streaming moderna di Spark. Invece di avere un set di operatori per il batch e uno totalmente diverso per lo streaming, Spark li ha unificati: gli stessi select, filter, groupBy, join che hai scritto per quarantacinque lezioni funzionano anche su DataFrame in streaming. Impari l’API una volta, la riutilizzi.
Il modello mentale che fa scattare il click è la tabella infinita.
La tabella infinita
Immagina una tabella che non smette mai di essere scritta. Ogni volta che un nuovo evento arriva alla source, una nuova riga viene appesa in fondo. La tabella cresce in modo monotono. Non vedi mai una riga sparire; vedi solo aggiungersi nuove.
È così che Spark Structured Streaming modella uno stream: come una tabella logica che cresce nel tempo. Il topic Kafka con tre nuovi messaggi in questo secondo? Sono tre nuove righe nella tabella. La directory di file di log dove sono apparsi due nuovi file? Quei nuovi file contribuiscono nuove righe. La tabella è concettualmente infinita, ma in ogni istante ha un numero specifico di righe: quelle arrivate finora.
Quando scrivi una query streaming, stai descrivendo una trasformazione su questa tabella infinita. Il lavoro di Spark è tenere quella trasformazione aggiornata man mano che arrivano nuove righe. Se la tua query è SELECT count(*) FROM events WHERE country = 'IT', Spark mantiene un running count ed emette un valore aggiornato ogni volta che la tabella di input cresce. Se la tua query è SELECT user_id, COUNT(*) FROM clicks GROUP BY user_id, Spark mantiene un conteggio per utente in corso ed emette i delta.
La query che scrivi sembra SQL batch. L’esecuzione sotto è incrementale: invece di ricalcolare da zero a ogni trigger, Spark capisce cosa c’è di nuovo e aggiorna il risultato.
Il modello micro-batch
Come fa Spark a eseguire tutto questo in pratica? Di default, bara leggermente. Non processa eventi uno alla volta, li processa in micro-batch.
Un micro-batch è esattamente quello che sembra: un piccolo batch. A ogni intervallo di trigger (default: il più velocemente possibile, tipicamente ogni qualche centinaio di millisecondi), Spark fa quanto segue:
- Chiede a ogni source quali nuovi dati sono apparsi dall’ultimo micro-batch (nuovi file nella directory, nuovi offset in Kafka, nuove righe nella rate source).
- Legge quei nuovi dati in un DataFrame normale.
- Esegue la tua query su di esso, eventualmente combinata con lo state dei micro-batch precedenti (per cose come aggregazioni in corso).
- Scrive l’output al sink.
- Registra gli offset consumati nel checkpoint così il prossimo micro-batch sa da dove riprendere.
Il risultato: latenze end-to-end di circa 100 ms fino a qualche secondo, a seconda di quanto è pesante il lavoro e quanto sono grandi i micro-batch. Non è “vero” streaming sub-millisecondo, ma è ampiamente abbastanza veloce per quasi ogni caso d’uso analitico (monitoraggio frodi, aggiornamento dashboard, ETL verso un warehouse, anomaly detection a scale temporali umane).
Il processing micro-batch eredita tutti i punti di forza di Spark batch: il Catalyst optimizer pianifica ogni micro-batch, Tungsten ne genera il codice, la fault tolerance esce dal meccanismo di retry batch esistente, e puoi usare ogni operatore che già conosci. Il prezzo è la latenza: ogni micro-batch ha un overhead, quindi non puoi realisticamente spingere gli intervalli sotto i ~100 ms.
Continuous mode: l’altra strada sperimentale
Per workload che hanno genuinamente bisogno di latenza single-row, sub-millisecondo (high-frequency trading-adjacent, alerting in tempo reale su singoli eventi, feature serving a bassa latenza) Spark offre anche la continuous processing mode, attivata con .trigger(continuous="100 ms").
In continuous mode, Spark non esegue micro-batch. Task long-running stanno sugli executor e processano i record man mano che arrivano, uno alla volta. La latenza end-to-end scende nel range dei millisecondi.
La fregatura: la continuous mode supporta un set molto più piccolo di operazioni. Da Spark 4.x supporta ancora solo operazioni map-like (select, filter, cast, withColumn) e una manciata di source/sink. Niente aggregazioni, niente join, niente windowing. È stata marcata sperimentale per anni e la superficie supportata è cresciuta a malapena. Per il 99% dei job, micro-batch è quello che vuoi; la continuous mode è uno strumento per una nicchia specifica dove la latenza micro-batch genuinamente non basta.
Passerò il resto di questo modulo sul percorso micro-batch, perché è quello che lo Spark streaming in produzione è davvero.
Una breve storia: i DStreams e perché sono spariti
Se leggi libri vecchi, blog post o risposte di Stack Overflow, vedrai riferimenti ai DStreams, abbreviazione di “discretized streams”. I DStreams sono stati la prima API di streaming di Spark, introdotta in Spark 1.x.
I DStreams erano basati su RDD. Un DStream era concettualmente una sequenza di RDD, uno per intervallo micro-batch, e lo trasformavi con operazioni stile RDD (map, flatMap, reduceByKey, ecc.). Funzionava, è girato in produzione in molte aziende, e ha insegnato molto al team, ma aveva problemi:
- Era separato dai DataFrame e dal Catalyst optimizer. Non potevi riutilizzare la logica DataFrame batch; dovevi riscriverla in operazioni RDD.
- Era basato sul tempo solo come processing time, non event time. Calcolare window su quando gli eventi sono effettivamente accaduti (invece di quando Spark li ha processati) era doloroso.
- La storia di fault tolerance era per-RDD. Eventi in ritardo o fuori ordine erano difficili.
- L’API era a livello più basso dell’API DataFrame, quindi crescevano in parallelo due codebase: batch in DataFrame, streaming in DStream. Doloroso da mantenere per un team.
Structured Streaming, introdotto in Spark 2.x, sostituisce i DStreams con l’API a forma di tabella che ho descritto sopra. Usa i DataFrame, gira attraverso Catalyst e Tungsten, e supporta correttamente il processing event-time (lezione 52, watermark).
I DStreams sono deprecati da Spark 3.4 e il modulo pyspark.streaming, incluse le vecchie classi StreamingContext e DStream, è stato rimosso in Spark 4. Se trovi un tutorial che importa from pyspark.streaming import StreamingContext, chiudi la tab. Quel codice non girerà su uno Spark attuale e non c’è motivo di scrivere nuovo codice in quello stile.
Per il resto di questo modulo, “streaming” significa Structured Streaming. Il capitolo dei DStreams è chiuso.
Un assaggio: un job streaming in cinque righe
Per ancorare l’astrazione, ecco un job streaming completo. Osserva una directory, legge ogni nuovo file CSV che appare, e stampa un conteggio per secondo di righe sulla console:
from pyspark.sql import SparkSession
from pyspark.sql.functions import current_timestamp, window
spark = SparkSession.builder.appName("HelloStream").getOrCreate()
events = (spark.readStream
.schema("user_id STRING, action STRING")
.csv("/tmp/incoming/"))
counts = (events
.withColumn("ts", current_timestamp())
.groupBy(window("ts", "1 second"))
.count())
query = (counts.writeStream
.outputMode("complete")
.format("console")
.start())
query.awaitTermination()
Cinque cose da notare:
spark.readStreaminvece dispark.read. Quello è tutto il delta dell’API sul lato input. Tutto il resto (schema, formato, opzioni) sembra identico a una read batch.- Il risultato di
readStreamè un DataFrame. Specificamente,events.isStreamingritornaTrue, ma è ancora un DataFrame normale per quanto riguarda le tue trasformazioni. Le chiamatewithColumn,groupBy,windowsono esattamente l’API batch. writeStreaminvece diwrite. Stesso delta sul lato output.outputMode("complete")dice “emetti l’intera result table corrente a ogni micro-batch”, appropriato per un’aggregazione. Vedremo i tre output mode (append, complete, update) nella lezione 50.awaitTermination()mantiene vivo il processo mentre la query streaming gira. Senza, il tuo script esce e Spark ferma la query.
Butta un CSV in /tmp/incoming/, guarda la console mostrare i conteggi, butta un altro file, guarda i conteggi aggiornarsi. Stai facendo streaming.
Dove sta andando questo modulo
Le prossime tre lezioni coprono le fondamenta:
- Lezione 50 —
readStream,writeStream, i tipi di trigger, e l’importantissimo checkpoint. La meccanica concreta di far girare un job streaming. - Lezione 51 — la Kafka source, da cui il 90% dei job streaming in produzione legge. Offset, deserializzazione, semantica exactly-once, le insidie.
- Lezione 52 — event time e watermark. Il pezzo che fa funzionare correttamente “a che ora è successo davvero questo evento?” anche quando gli eventi arrivano fuori ordine.
Dopo copriremo gli output mode (53), le operazioni stateful (54), e gli stream-stream join (55) prima di chiudere il modulo con il lato operations: deployment in produzione, monitoring, le failure mode (56-58).
Per ora, il messaggio: lo streaming non è un mondo magico separato. È la stessa API DataFrame che già conosci, applicata a dati che non hanno finito di arrivare, eseguita in modo incrementale su micro-batch, con un checkpoint per tracciare il progresso. Una volta che quella foto è in testa, il resto è meccanica.
Riferimenti: Apache Spark Structured Streaming Programming Guide (https://spark.apache.org/docs/latest/structured-streaming-programming-guide.html) e le note di migrazione di Spark 4.0 sulla rimozione di pyspark.streaming (https://spark.apache.org/docs/latest/streaming/). Consultati il 2026-05-01.