RDD, DataFrame ou Dataset : lequel choisir en Spark

Trois API pour le même moteur, mais un seul optimiseur les comprend. Pourquoi le DataFrame gagne presque toujours, et les trois cas où le RDD reste indispensable.

7 min de lecturesparkscalardddataframebig-data

Vous avez écrit votre premier word count en RDD. L’étape suivante, presque tout le monde la fait mal : on continue en RDD par habitude, alors que Spark propose depuis longtemps une API plus rapide, plus lisible, et — surtout — connue de son optimiseur. Le RDD n’est pas déprécié, mais l’utiliser par défaut coûte du temps de calcul et de la sueur au débogage.

On regarde la même tâche écrite deux fois, on compare ce qui se passe sous la surface, et on donne la seule règle simple à retenir.

La même tâche, deux fois

Compter les occurrences de prénoms dans un fichier.

En RDD :

scala
val counts = sc
  .textFile("etudiants.txt")
  .flatMap(_.split(" "))
  .map(word => (word, 1))
  .reduceByKey(_ + _)

En DataFrame :

scala
import org.apache.spark.sql.functions._

val counts = spark.read.text("etudiants.txt")
  .select(explode(split($"value", " ")).as("prenom"))
  .groupBy("prenom")
  .count()

Le résultat est identique. Ce qui change n’apparaît pas dans le code : c’est ce que Spark peut voir de vos intentions.

Ce que Spark voit dans chaque cas

AspectRDDDataFrameDataset[T]
ReprésentationObjets JVM opaquesTable avec schémaTable avec schéma et type
ImmuableOuiOuiOui
Tolérance aux pannesOui, par la lignéeOuiOui
Schéma de colonnesNonOuiOui
Types vérifiés à la compilationOuiNonOui
Optimiseur CatalystAucunCompletComplet, sauf sur les lambdas
Génération de codeNonWhole-stage codegenOui
SérialisationJava/Kryo, lenteTungsten binaireTungsten via encodeur
Filtres poussés vers la sourceNonOui (PushedFilters)Oui
Niveau d’abstractionBasHautHaut
Disponible en PythonOuiOuiNon — JVM seulement

Deux lignes méritent d’être lues ensemble, parce qu’elles expliquent tout le reste.

Le niveau d’abstraction. C’est la différence de fond, et elle se formule en une phrase : avec un RDD vous décrivez comment traiter, avec un DataFrame vous décrivez ce que vous voulez obtenir.

RDD        → « Voici exactement comment je veux travailler. »
DataFrame  → « Voici le résultat voulu. Spark, choisis comment. »
Dataset    → « Pareil, mais avec mes objets Scala typés. »

L’optimiseur. Une map en RDD est une boîte noire : Spark ne peut pas savoir si vous filtrez, si vous réécrivez une colonne, si vous jetez les autres. Il exécute vos étapes telles quelles. En DataFrame, il lit l’expression, la simplifie, la réordonne, la fusionne avec les étapes voisines, et pousse vers la source ce qui peut l’être.

Ces deux propriétés sont liées : c’est précisément parce que vous cédez le comment que Spark peut l’optimiser. Et c’est pour cela que le RDD coûte plus cher à l’exécution — plus de CPU, plus de mémoire, plus de données lues, donc une facture cloud plus élevée sur les mêmes données.

Le même plan, deux réalités

Sur le compte de prénoms, Spark fait :

Sur un fichier de 3 lignes, la différence est invisible. Sur un lac de données de 500 Go, elle se compte en facteurs 5 à 20. Ce n’est pas un chiffre marketing : c’est la conséquence directe du codegen et du format binaire, mesurée par les benchmarks internes de Databricks depuis 2016.

Quand rester en RDD, malgré tout

Trois cas de figure — trois seulement.

  1. Vous manipulez des types sans schéma clair : parseurs binaires, formats industriels bricolés, sérialisation maison. Faire semblant d’avoir un schéma vous coûte plus cher que la perte de Catalyst.
  2. Vous avez besoin d’un contrôle fin de la partition : mapPartitions, zipPartitions, partitionBy avec un partitioner custom. Les DataFrames s’en approchent avec repartition et coalesce, mais pour du traitement par partition avec état, le RDD reste plus direct.
  3. Vous portez un code MapReduce historique : le mapping mental mapper → reducer colle au RDD. Migrer d’un coup vers DataFrame ajoute de la charge cognitive à un chantier déjà lourd.

Dans tous les autres cas — c’est-à-dire 90 % de ce que vous écrirez — le DataFrame gagne.

Le prix du DataFrame : les colonnes sont des chaînes

C’est la seule case « non » du DataFrame dans le tableau, et elle se paie en production :

scala
df.select("age")     // fonctionne
df.select("agge")    // compile parfaitement… et échoue à l'exécution

Le compilateur Scala ne voit qu’un String. L’erreur remonte à l’analyse du plan par Spark — donc après le lancement du job, parfois après vingt minutes de calcul en amont, et sur un cluster que vous payez à la seconde.

Dataset[T] supprime cette classe d’erreurs en attachant un type réel aux lignes :

scala
case class Etudiant(nom: String, age: Int)

val ds: Dataset[Etudiant] = df.as[Etudiant]

ds.map(_.age)     // vérifié à la compilation
ds.map(_.agge)    // ✗ ne compile pas — erreur détectée immédiatement

DataFrame est en réalité Dataset[Row] : la même classe, avec un type de ligne générique. Passer de l’un à l’autre est donc gratuit, via .as[T].

Deux limites à connaître avant d’adopter Dataset partout :

  1. Les lambdas redeviennent opaques. Dès qu’on écrit ds.map(_.nom.toUpperCase), Catalyst repasse en boîte noire pour cette étape — les expressions SQL restent optimisables, les fonctions Scala non. Sur les étapes critiques, préférez org.apache.spark.sql.functions.* : c’est le mécanisme décrit dans pourquoi votre UDF est dix fois plus lente.
  2. Dataset[T] n’existe pas en Python. Le typage à la compilation suppose un compilateur ; PySpark n’en a pas. C’est l’un des rares avantages structurels de Scala évoqués dans Scala ou PySpark.

En pratique, la règle qui tient : Dataset[T] sur les frontières — ce qui entre et sort de votre pipeline, là où une erreur de colonne coûte cher — et DataFrame au milieu, là où vous enchaînez des transformations et où vous voulez tout Catalyst.

Passer d’un monde à l’autre sans casse

Vous héritez d’un code RDD ? La conversion est bilatérale, et le coût est faible du côté RDD → DataFrame.

scala
// RDD -> DataFrame (avec schéma)
import spark.implicits._
val rdd: RDD[(String, Int)] = /* … */
val df = rdd.toDF("prenom", "n")

// DataFrame -> RDD (rare, mais parfois nécessaire)
val backToRdd = df.rdd  // RDD[Row]

Faux ami : .rdd sur un DataFrame matérialise la table en Row sur la JVM. Vous perdez Tungsten, vous perdez Catalyst, et vous payez le coût de la conversion. À ne faire qu’en connaissance de cause — pas pour « revenir à quelque chose de plus familier ».

La règle simple à emporter

Écrivez en DataFrame par défaut. Descendez en RDD quand vous avez une raison précise que vous savez nommer.

Si votre pipeline commence par spark.read.parquet(...) et enchaîne des select, filter, join, groupBy, agg, window — vous êtes exactement au bon endroit. Catalyst est écrit pour ce vocabulaire ; il rend chaque étape trois fois plus rapide qu’un équivalent RDD, sans que vous ayez rien à optimiser vous-même. C’est probablement l’action à plus haut retour sur investissement que vous puissiez faire sur votre code Spark aujourd’hui.

Ce qu’il faut retenir

Le RDD apprend Spark, le DataFrame le fait travailler. Deux APIs pour le même moteur, un seul optimiseur — et cet optimiseur ne comprend que le langage du DataFrame. Le seul cas où revenir au RDD est raisonnable est celui où vous pouvez expliquer, en une phrase, pourquoi Catalyst ne vous servira pas. Toute autre raison — « c’est ce qu’on faisait avant », « je préfère les tuples », « c’est plus court » — est une raison d’y regarder à deux fois.

Pour aller plus loin sur le contrat lazy/eager de Spark, la transformation vs action est le compagnon direct de cet article.

Ce sujet fait partie d’un cours complet

Développement et déploiement de solutions de données — les premiers modules sont en accès libre.

Voir le plan du cours

Continuer sur le même sujet