MythBuster: Apache Spark • Épisode 1: Parsing d'une requête SQL
Parsing SQL avec Apache Spark : analyse et compréhension
Spark est en évolution constante et maintient un rythme soutenu de sorties de releases, en témoigne la dernière version en date, la 2.2. Dans cette série d'articles, nous allons revenir ensemble sur différentes mécaniques actuellement en place au sein de cet outil et essayer d'en comprendre le fonctionnement.
code {<br /> background-color: #efefef;<br /> font-family: Courier New, Courier,Lucida Sans Typewriter, Lucida Typewriter, monospace;<br />}<br />
Préface
Spark 2 et le projet Tungsten
Depuis la version 2.0, Spark s'appuie sur un moteur d'exécution retravaillé, nommé Tungsten. Celui-ci embarque avec lui son lot d'améliorations, et confirme la volonté des contributeurs de rendre Spark toujours plus performant et accessible.
En particulier, le gros des efforts de développement s'est concentré sur l'amélioration des <br>DataFrame<br>
s (et plus récement les <br>Dataset<br>
s) qui permettent, entre autres, de manipuler de la donnée à partir de requêtes SQL classiques. L'enjeu de ces améliorations est d'offrir aux développeurs une API simple et haut-niveau avec le minimum de contrepartie en termes de performances.
Dans cette optique, une des évolutions du moteur Tungsten semble avoir fait faire un grand pas en avant à Spark puisque, sous réserve d'utilisation des <br>DataFrame<br>
s, sans aucune modification de code entre Spark 1.6 et Spark 2.0, les performances pourraient être multipliées par 10. Cet exploit serait en majeur parti du à une technique de génération du code à la volée afin d'accroître la vitesse d'exécution… Rien que ça.
Démystification
Je ne sais pas pour vous, mais pour nous, « plan d'exécution », « génération de code à la volée », « moteur d'optimisation », etc. sont des concepts dont on a tous entendu parler sans trop vraiment savoir ce qui se cache derrière. Et quand on y pense, on pourrait même dire la même chose de Spark, non ?
C'est suite à cette réflexion que nous avons décidé de décortiquer le code source de Spark et d'en extraire l'essence, afin de comprendre en détail ce qu'il se passe sous le capot… Pour cela, nous allons construire ensemble un moteur capable de partir d'une simple requête SQL sous forme de <br>String<br>
et de l'exécuter, en s'appuyant sur le code source de Spark.
Nous chercherons à répondre aux questions suivantes :
- Comment peut-on passer d'une requête SQL à une structure exploitable directement avec du code ?
- Comment est construit et optimisé un plan d'exécution ?
- Quel est l'intérêt de générer du code et comment faire ?
Ce sera également l'occasion d'appréhender ensemble certaines fonctionnalités de Spark.
Pour cela, nous allons découper cet article en 3 parties : la première traitera du parsing d'une requête, la deuxième de la construction d'un plan d'exécution physique, la dernière partie mettra l'accent spécifiquement sur la génération de code.
L'intégralité du code que nous allons réaliser est présent sur le GitLab d'OCTO : que les plus curieux d'entres-vous n'hésitent-pas à y faire un tour avoir une vision détaillée du fonctionnement de notre implémentation !
Un sous-ensemble du langage SQL
La génération de code sera le point d'arrivée de cette série d'article mais nous allons voir qu'avant cela, il y a un certain nombre d'étapes par lesquelles nous devons passer. La première consiste à transformer une requête SQL sous forme de <br>String<br>
en quelque-chose d'un peu plus pratique à manipuler avec du code.
L'objectif ici n'étant pas de réimplémenter Spark mais uniquement de comprendre son fonctionnement, nous n'allons pas chercher à supporter l'ensemble de la version 2011 de SQL. Le langage SQL que nous allons implémenter va se restreindre aux contraintes suivantes :
- Seulement des requêtes de type
<br>SELECT<br>
avec des clauses<br>FROM<br>
et<br>JOIN<br>
seront interprétées - Seul deux types seront supportés, à savoir
<br>NUMBER<br>
et<br>TEXT<br>
- Les mots clés devront être en majuscules et les identifiants (nom des tables, des colonnes, etc.) en minuscules
- Les données des tables seront situées dans des fichiers CSV dont le nom doit être le même que celui de la table
Concernant le dernier point, notons que Spark possède une API particulière pour décrire les sources de données (qui seront considérées comme des tables au sein des requête SQL) et qu'il est possible d'ajouter ses propres sources, mais ce n'est pas quelque-chose que nous allons couvrir ici.
Interprétation d'une requête SQL
La première étape de notre périple va être de transformer une <br>String<br>
en quelque chose de programmatiquement utilisable…
Un peu de théorie…
Cette transformation se déroule en deux étapes réalisées par deux composants :
- Le Lexer : il décrit l'ensemble des mots clés qui définit une requête SQL (
<br>SELECT<br>
,<br>WHERE<br>
,<br>FROM<br>
, etc.) et les localise dans une<br>String<br>
donnée en entrée. Il transforme ainsi une<br>String<br>
en une liste d'éléments qui composent notre lexique. Ces éléments sont ce que l'on appelle des Tokens. Les mots qui ne font pas partie de ce lexique sont identifiés par le token Literal s'il s'agit d'une valeur (un nombre ou un texte entre guillemets), et sinon, il s'agit d'un Identifier. - Le Parser : là où le Lexer a une approche lexicale, le Parser a une approche sémantique : c'est lui qui va donner un sens à la requête SQL à partir des tokens identifiés par le Lexer. Pour cela, la liste de Tokens va être convertie en une structure un peu particulière, l'AST (ou Abstract Syntax Tree)…
L'AST est un arbre dont le rôle est de représenter une requête SQL parsée, en hiérarchisant les Token identifiés par le Lexer.
Par exemple, lorsque l'on écrit <br>SELECT column<br>
, il faut comprendre que le terme <br>column<br>
s'applique au terme <br>SELECT<br>
. Le premier est donc un argument du second. Dans l'AST, le <br>SELECT<br>
se trouve au dessus du <br>column<br>
.
Deux catégories d'AST sont à distinguer :
- La catégorie Expression qui va correspondre aux opérations que l'on réalise sur les lignes (comme par exemple les prédicats appliqués au niveau des clauses
<br>WHERE<br>
et<br>ON<br>
de nos requêtes) ; - La catégorie Relation qui va déterminer la manière dont les lignes sont produites (dans notre cas, via les
<br>FROM<br>
et<br>JOIN<br>
, mais on peut aussi penser aux<br>SELECT<br>
si l'on avait implémenté la notion de sous-requête).
En deux mots : la Relation correspond à l'origine de la donnée, et l'Expression à ce que l'on en fait.
Notre implémentation !
Nos classes <br>Lexer<br>
et <br>Parser<br>
Chacun des types de Token identifiables par le <br>Lexer<br>
est représenté par une case class Scala qui étend le trait <br>Token<br>
. Le rôle de la classe <br>Lexer<br>
va donc être simplement d'associer certains motifs d'une <br>String<br>
à la sous classe de <br>Token<br>
correspondante.
L'idée n'étant pas d'écrire un parser qui supporte la totalité du langage SQL, nous allons utiliser les Parser Combinators qui suffisent amplement dans notre cas.
def select: Parser[Select] = positioned { "SELECT" ^^ { _ => Select() } }
Exemple d'un Parser Combinator qui permet de parser le mot clé <br>SELECT<br>
en un Token <br>Select()<br>
(qui est une case class Scala)
Pour une explication détaillée du fonctionnement des Parsers Combinators, nous redirigeons le lecteur vers la documentation de cette librairie.
Comme leur nom l'indique, il est possible d'associer plusieurs Parser Combinators ensemble, chacun étant en charge d'un mot clé. Nous sommes ainsi capables d'identifier tous les termes que l'on souhaite au sein d'une <br>String<br>
, ce en itérant sur chacun des mots de la <br>String<br>
et en appliquant tous nos Parser Combinators.
L'ensemble des règles ainsi implémentées dans notre code est regroupé dans notre objet <br>Lexer<br>
présent dans Lexer.scala. Token.scala contient l'ensemble des Tokens pris en compte par notre <br>Lexer<br>
.
Liste des case classes qui dérivent de <br>Token<br>
Le trait AST
L'objet <br>Parser<br>
s'appuie également sur les Parser Combinators pour construire notre AST.
Si l'implémentation des différents parsers peut sembler légèrement absconse au premier abord, il n'est pas nécessaire d'en comprendre les détails pour poursuivre la lecture.
L'utilisation des Parser Combinators constitue néanmoins une pratique courante pour le parsing en Scala.
Cette fois, grâce au travail du Lexer, ce ne sont plus des <br>String<br>
que l'on identifie, mais des <br>Token<br>
, ce qui simplifie le travail et la compréhension du programme.
Pour pouvoir construire un arbre à partir de ces <br>Token<br>
s, on ne peut plus se contenter d'appliquer l'ensemble de nos Parser Combinators à nos éléments (comme nous l'avions fait dans le <br>Lexer<br>
). Nous allons devoir appliquer de façon récursive, et dans un certain ordre nos différents Parser Combinator.
Par exemple, nous allons commencer par le parser <br>Select<br>
, qui lui même attend en paramètre un nom de colonne, un <br>From<br>
, et, éventuellement, un <br>Where<br>
. Chacun de ces paramètres correspondent également à des <br>Parser<br>
qui attendent, eux aussi, d'autres paramètres. De façon récursive, nous allons ainsi construire notre arbre syntaxique (le Parser Combinator correspondant aux Expressions est un peu particulier, cf. Pour aller plus loin).
Le <br>Select<br>
est donc un type d'<br>AST<br>
qui attend en paramètre :
- Une liste d'expressions qui correspondent à l'ensemble des projections décrites juste après le terme
<br>SELECT<br>
- Une liste de relations qui correspondent à l'ensemble des tables sources décrites juste après le terme
<br>FROM<br>
- Éventuellement une dernière expression qui correspond aux filtres (ou prédicats) décrits juste après le terme
<br>WHERE<br>
(sachant qu'il est possible d'appliquer plusieurs filtres en utilisant les expressions<br>AND<br>
ou<br>OR<br>
)
Hiérarchie des case classes qui dérivent de <br>AST<br>
Récapitulatif
Pour résumer, le parsing se déroule donc en deux étapes :
- Le lexing de la
<br>String<br>
en une<br>Seq[Token]<br>
grâce à l'objet<br>Lexer<br>
qui va valider le vocabulaire utilisé au sein de la requête SQL - Le parsing de la
<br>Seq[Token]<br>
en un<br>AST<br>
grâce à l'objet<br>Parser<br>
qui va valider la sémantique de la requête SQL
Les deux étapes qui constituent le parsing
Et du côté de chez Spark ?
Les auteurs de Spark ont quant à eux pris le parti de déléguer cette fastidieuse étape de parsing à un célèbre outil qui mature depuis plus de 20 ans cette problématique : ANTLR !
Je pense qu'on l'a tous déjà croisé quelque-part…
A l'inverse de bon nombre de projets Scala, les développeurs de Spark ont décidé d'utiliser Maven en lieu et place de SBT pour gérer le build. Juste avant la compilation de Spark, c'est donc le plugin Maven d'ANTLR qui va générer les classes nécessaires pour réaliser le parsing (ce que nous avons fait manuellement en implémentant le Lexer et Parser ci-dessus) en se basant sur la grammaire définie ici.
La traduction en case classes est quant-à-elle réalisée ici par l'<br>AstBuilder<br>
.
Pour aller plus loin…
Recursive Descent Parser
La première implémentation basée sur les Parser Combinator que nous avions réalisée était assez naïve, ce qui a eu pour conséquence des embêtantes <br>StackOverflowException<br>
… Tiens-donc.
En effet : bien que relativement simple, la grammaire que nous avons définie est récursive (par exemple, un terme d'une addition peut lui-même être une addition) ce qui n'est pas supporté de base par les Parser Combinators. De ce fait, nous avons dû revoir notre implémentation et mettre en place un Recursive Descent Parser dont le principe est très bien décrit ici. Pour nous aider dans cette tâche, nous nous sommes énormément inspirés de cette classe disponible sur GitHub.
Apache Calcite
Le parsing de requête SQL est une problématique récurrente : en plus des SGBDR communs, nombre de solutions Big Data s'appuient aujourd'hui sur le langage SQL pour manipuler les données.
Apache Calcite est une initiative d'Apache dont l'objectif est de résoudre différentes problématiques liées à la conception de base de données (SGBDR ou NoSQL), dont - entres autres - le parsing du SQL.
Par exemple, Apache Hive s'appuie sur Calcite pour le parsing du SQL (mais également pour l'optimisation des requêtes, mais ça, c'est pour une autre fois…).
A suivre…
A partir de notre <br>String<br>
de départ qui était difficilement utilisable, nous avons pu, à l'aide de notre <br>Parser<br>
et de notre <br>Lexer,<br>
en tirer un <br>AST<br>
beaucoup plus exploitable programmatiquement et qui décrit l'objectif de la requête SQL.
Nous allons voir dans le prochain article comment transformer cet <br>AST<br>
en une nouvelle structure qui va cette fois-ci décrire la manière dont la requête va s'exécuter… Rendez-vous la semaine prochaine !
Découvrez aussi notre article sur la planification et l’exécution des requêtes SQL dans Apache Spark.