Dacă citești suficiente postări de blog despre Spark, vei rămâne cu impresia că framework-ul are trei moduri complet diferite de a face același lucru, că trebuie să alegi cu grijă și că o alegere greșită te va costa. Prima parte e pe jumătate adevărată. A doua parte e în mare parte falsă. A treia parte e categoric falsă în 2026.
În practică aproape toată lumea folosește DataFrame-uri aproape tot timpul, și există un motiv foarte bun pentru asta. Dar ca să știi de ce DataFrame-urile au câștigat, trebuie să știi ce au înlocuit și ce au oferit pe care API-ul original nu îl avea. Așa că hai să parcurgem cele trei, în ordinea în care au apărut.
Un pic de istorie, fiindcă explică totul
Spark a luat ființă la AMPLab-ul UC Berkeley pe la 2009, a fost open-source-uit în 2010 și a devenit proiect Apache top-level în 2014. Abstracția originală, cea pe care a introdus-o lucrarea lui Matei Zaharia din 2010, a fost Resilient Distributed Dataset, RDD-ul. Pentru primii trei ani, ăsta a fost întreg API-ul Spark.
Apoi în martie 2015, Spark 1.3 a livrat API-ul DataFrame. Arăta ca un DataFrame din R sau pandas, dar distribuit: rânduri, coloane denumite, o schemă, operații tip SQL. Sub el era un lucru complet nou numit Catalyst optimizer.
În ianuarie 2016, Spark 1.6 a adăugat API-ul Dataset, care era DataFrame plus type safety la compile time în Scala și Java. În Spark 2.0 (iulie 2016) cele trei au fost unificate la nivel de API, DataFrame a devenit oficial Dataset[Row] în Scala, dar distincția conceptuală este încă vie și merită cunoscută.
Trei API-uri. Un singur engine. Abstracții diferite peste același model de execuție.
RDD: originalul
Un RDD este, în esență, o colecție partiționată de obiecte arbitrare. Dacă ai folosit liste Python, un RDD e „o listă, dar tăiată în partiții, fiecare bucată trăiește pe o altă mașină, și o poți map-a și filtra în paralel.”
# This is RDD code. We rarely write this anymore.
rdd = spark.sparkContext.parallelize(range(1_000_000), numSlices=8)
result = (
rdd.map(lambda x: x * 2)
.filter(lambda x: x % 3 == 0)
.reduce(lambda a, b: a + b)
)
Trei lucruri din snippet-ul ăla sunt caracteristice codului RDD. Primul, operațiile (map, filter, reduce) iau funcții arbitrare, lambdas Python, în cazul ăsta. Al doilea, tipul de date e ce pui acolo: integers, tuple-uri, propriile tale clase, dicts, orice poate Python să facă pickle. Al treilea, nu există schemă. RDD-ul nu are habar ce e înăuntru; doar are încredere că lambda x: x * 2 va face ceva sensibil.
Flexibilitatea aia e și slăbiciunea. Pentru că operațiile sunt funcții Python opace, Spark nu poate să se uite înăuntrul lor. Nu poate rescrie map(f).filter(g) în nimic mai isteț decât „aplică f pe fiecare rând, apoi aplică g pe fiecare rând.” Nu are habar dacă f citește tot rândul sau doar o coloană. Nu poate împinge filtre. Nu poate optimiza join-uri pentru că nu știe ce e o „cheie de join”, pentru un RDD, un join e doar un shuffle generic de perechi (K, V).
Ce au dat RDD-urile lumii:
- Un primitive general de procesare paralelă. Puteai folosi Spark să faci lucruri care nu sunt deloc tabulare: traversări de grafuri, iterații ML, procesare de string-uri, muncă geospațială.
- Fault tolerance prin lineage. Fiecare RDD ține minte cum a fost derivat. Dacă o partiție e pierdută, Spark o recalculează din sursă.
- Control de nivel jos. Puteai să-ți alegi propriul partitioner, serializare custom, broadcast variables, accumulators, tot tacâmul.
Când ai apela RDD-uri în 2026:
- Un algoritm specific care are nevoie de control fin asupra partiționării (de exemplu, un partitioner custom pentru o distribuție de chei specifică unui domeniu).
- Algoritmi pe grafuri care nu se potrivesc curat cu DataFrame-urile. Deși și aici, GraphFrames (bazat pe DataFrame) a mâncat majoritatea cazurilor de utilizare.
- Serializare custom pentru obiecte care nu se potrivesc cu modelul de schemă DataFrame, să zicem, blob-uri binare mari cu pattern-uri de acces neobișnuite.
- Cod legacy pe care îl întreții. Există mult cod RDD care încă rulează în producție din era 2014-2017.
Pentru 99% din munca analitică, citește fișiere, filtrează, join, agregă, scrie, RDD-urile sunt unealta greșită. Sunt mai greu de scris, mai lente la rulat și optimizer-ul nu te poate ajuta.
DataFrame: API-ul care a câștigat
Un DataFrame este un tabel distribuit cu coloane denumite și o schemă. Arată așa:
df = spark.read.parquet("s3://runehold/orders/")
(df.filter(df.country == "IT")
.groupBy("product_id")
.agg({"amount": "sum"})
.show())
Trei lucruri sunt diferite față de codul RDD. Primul, operațiile (filter, groupBy, agg) sunt declarative, descriu ce vrei, nu cum să calculezi. Al doilea, coloanele sunt denumite, df.country, df.amount. Al treilea, există o schemă, Spark știe că country e string, amount e double, și așa mai departe.
Motivul pentru care toate astea contează e Catalyst, query optimizer-ul Spark. Când scrii df.filter(df.country == "IT").groupBy("product_id").agg(...), Spark nu rulează efectiv acei pași în acea ordine. Construiește un plan logic, apoi îl trece prin Catalyst, care:
- Împinge filtrul în jos până în data source. Dacă citești Parquet, filtrul pe
countrydevine parte din file scan, Spark citește doar row groups în carecountry = 'IT'e posibil. Poți economisi 99% din I/O pe un dataset partiționat corespunzător. - Curăță coloane. Dacă folosești doar
country,product_idșiamount, Spark citește doar acele coloane de pe disc. Layout-ul columnar al Parquet face ca asta să fie aproape gratis. - Alege strategia de join potrivită. Broadcast hash join când o parte e mică. Sort-merge join când ambele sunt mari. Shuffle hash join în circumstanțe specifice. Optimizer-ul ia decizia bazat pe statistici, nu pe ce ai scris.
- Rearanjează operațiile. Filtre înainte de join. Agregări înainte de join unde e posibil. Constant folding. Common subexpression elimination.
- Generează cod pentru tot stage-ul. Asta e partea magică. Catalyst ia planul fizic și generează bytecode Java la runtime care fuzionează multe operații într-o singură buclă strânsă. Un lanț
filter -> map -> filter -> sumnu rulează ca patru pași; rulează ca unul singur. De aceea DataFrame Spark pe JVM e aproximativ la fel de rapid ca Java scris de mână care face același lucru.
Diferența de performanță între RDD și DataFrame pentru muncă analitică tipică nu e subtilă. Este în mod regulat 5x până la 50x în favoarea DataFrame-urilor și se scalează cu inteligența operațiilor pe care le faci. Un count simplu e similar între cele două. Un multi-join complex cu filtre și agregări e dramatic mai rapid ca DataFrame.
Deep-dive-ul de la Databricks despre Catalyst (linkat la final) este descrierea tehnică originală și e încă cea mai clară explicație dacă vrei să înțelegi exact cum funcționează optimizer-ul.
DataFrame-urile vorbesc și SQL. Orice poți face cu API-ul DataFrame, poți face și cu spark.sql("SELECT ..."). Cele două sunt interschimbabile, același Catalyst, aceeași execuție. Multe codebase-uri de producție amestecă liber ambele: API DataFrame pentru manipularea datelor, SQL pentru interogările analitice efective fiindcă SQL-ul e mai ușor de citit pentru analiști.
Dataset: vărul tipizat
În Scala (și Java), un Dataset este un DataFrame tipizat. Definești o case class, iar Dataset-ul știe că coloanele sunt exact acele câmpuri cu acele tipuri:
case class Order(orderId: Long, country: String, amount: Double)
val orders: Dataset[Order] = spark.read.parquet("...").as[Order]
orders.filter(_.country == "IT") // typed: _.country is a String
.map(_.amount * 1.22) // typed: returns Dataset[Double]
Beneficiul este type safety la compile time. Dacă scrii _.cuontry (typo), compilatorul Scala îl prinde înainte să trimiți măcar job-ul. Dacă scrii _.amount.toUpperCase (prostie), compilatorul îl prinde. Refactorizarea unei redenumiri de coloană devine o eroare de compilare în fiecare loc care are nevoie de update.
Costul este că atunci când folosești API-ul tipizat (map, filter cu lambdas care operează pe Order), Catalyst nu poate vedea înăuntrul lambda-ei, aceeași problemă ca la RDD-uri. Câștigi type safety, pierzi ceva optimizare. Majoritatea echipelor Scala folosesc un mix: operații DataFrame netipizate unde Catalyst poate ajuta, operații Dataset tipizate unde type safety valorează mai mult decât optimizarea.
În PySpark, Datasets nu există. Python e tipizat dinamic; nu există un sistem de tipuri la compile time care să poată impune ceva. DataFrame din PySpark este conceptual echivalent cu Dataset[Row] din Scala. Când citești documentație PySpark care zice „asta returnează un DataFrame”, și citești documentație Scala care zice „asta returnează un Dataset[Row]”, e același lucru.
E o ușurare, de fapt. Utilizatorii PySpark au cu o abstracție mai puțin de învățat. Avem DataFrame-uri și RDD-uri, atât.
Ce înseamnă asta pentru PySpark-ul tău de zi cu zi
Trei reguli de pumn:
Regula 1: Folosește DataFrame-uri. Ăsta e API-ul. Ăsta e cel pentru care e scrisă documentația, pe care e construit optimizer-ul, ce va aștepta orice review de cod PySpark. Dacă te trezești că ajungi după RDD-uri, întreabă-te de ce de trei ori înainte să o faci.
Regula 2: Folosește funcții native Spark, nu lambdas Python, oriunde e posibil. În interiorul API-ului DataFrame, sunt două moduri de a exprima o transformare. Modul rapid: from pyspark.sql import functions as F și apoi F.upper(F.col("name")), F.when(...), F.regexp_replace(...). Astea sunt operații pe partea JVM, complet vizibile pentru Catalyst, fără Python implicat. Modul lent: un Python UDF (decorator @udf) care procesează un rând la un moment dat într-un Python worker. JVM-ul și Python-ul trebuie să vorbească pentru fiecare rând. Vom intra în asta în lecția următoare.
Regula 3: Coboară la RDD-uri doar cu un motiv specific. „Prefer sintaxa” nu e un motiv. „Am nevoie de un partitioner custom pe care nicio primitivă DataFrame nu îl poate exprima” e un motiv. „Avem un modul legacy de 4.000 de linii scris pe RDD-uri” e un motiv. „Portez cod GraphX” e un motiv. În afara acelora, scrie DataFrame-uri.
Într-un codebase tipic de data engineering în stil Runehold, citește parquet, fă join cu date de referință, agregă, scrie înapoi, poți să mergi ani fără să atingi API-ul RDD. Singura dată când majoritatea echipelor se întâlnesc cu RDD-urile e când fac .rdd pe un DataFrame ca să inspecteze partiționarea, sau când un răspuns vechi de pe StackOverflow sugerează ceva care se dovedește a fi vintage 2017.
Comparație rapidă
| Aspect | RDD | DataFrame | Dataset (Scala) |
|---|---|---|---|
| Abstracție | Colecție partiționată de obiecte | Tabel distribuit cu schemă | Tabel distribuit tipizat |
| Optimizer | Niciunul | Catalyst | Catalyst (ops netipizate) |
| Schemă | Nu | Da | Da (compile time) |
| Type safety | Doar runtime | Doar runtime | Compile time |
| Disponibil în PySpark | Da | Da | Nu (= DataFrame) |
| Performanță pentru analytics | De referință | 5-50x mai rapid | Ca DataFrame |
| Când să folosești | Nișă / legacy | Implicit | Echipe Scala care vor tipuri |
Tabelul ăla e cheat sheet-ul tău. Lipește-l pe monitor pentru prima lună și apoi aruncă-l, până la lecția 20 îl vei avea memorat.
Lecția următoare: PySpark vs Scala Spark, și ce traversează efectiv firul când apelezi .filter() din Python.
Referințe
- Apache Spark, RDD Programming Guide: https://spark.apache.org/docs/latest/rdd-programming-guide.html
- Apache Spark, SQL, DataFrames and Datasets Guide: https://spark.apache.org/docs/latest/sql-programming-guide.html
- Databricks, Deep Dive into Spark SQL’s Catalyst Optimizer: https://databricks.com/blog/2015/04/13/deep-dive-into-spark-sqls-catalyst-optimizer.html
- Zaharia et al., Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing (NSDI 2012): https://www.usenix.org/conference/nsdi12/technical-sessions/presentation/zaharia
Consultat 2026-05-01.