Navigation

Hibernate Survival Guide Partie 3

Un article de ODcWiki.

Jump to: navigation, search
Image:Auteur.png Auteur : hdarmet.


Après avoir expliqué ce qu’était le cache de premier niveau et son rôle fondamental dans le fonctionnement d’Hibernate (HSG partie I) et la manière dont est gérée la persistance des relations (HSG partie II), nous allons nous attaquer, dans cet article au système de requêtage, aux pièges qu’il présente, ainsi qu’aux solutions pour les éviter.

Il ne s’agit pas ici de décrire le langage (HQL, JPA-QL) utilisé par Hibernate. De multiples ouvrages, à commencer par la documentation de référence Hibernate, le font longuement et avec talent. Le propos ici est plutôt de décrire comment Hibernate exploite ses requêtes. Et d’expliquer comment on peut éviter les deux écueils majeurs : le problème du N+1 et les jointures croisées insuffisamment réduites. C'est-à-dire comment utiliser de façon efficace le système de requêtage d’Hibernate.


Sommaire

Lazy or not lazy ?

Les relations entre deux entités peuvent être déclarées « paresseuses » (lazy en anglais). Une relation paresseuse qui part de l’entité A vers l’entité B, n’est pas résolue lorsque l’entité A est chargée. C’est uniquement lorsque l’on tente de passer de l’entité A pour atteindre l’entité B qu’Hibernate charge l’entité B.

Les relations paresseuses sont une nécessité vitale : si les relations étaient systématiquement résolues, le seul fait de charger une entité entrainerait automatiquement le chargement d’une grappe d’entités attachées – directement ou non – qui peut être potentiellement très large. Voire englober l’ensemble de la base de données !

Pour éviter ce problème, peut-on – doit-on ? – déclarer toutes les relations comme étant paresseuses ? La réponse est complexe, évidemment. Le fait de déclarer qu’une relation est « souhaitée » (eager), c'est-à-dire non paresseuse, a de multiples avantages :

  • Hibernate sait (la plupart du temps) charger l’entité A et en même temps l’entité B en une seule requête SQL lorsque l’on utilise la méthode « Session.get(EntityAClass, idA) ».
  • Si l’entité A est détachée (retirée du cache de premier niveau), le fait de traverser le lien de A vers B n’entrainera pas la levé d’une exception de type LazyLoadingException.

La réponse est donc de ne pas utiliser systématiquement le chargement paresseux. En fait, le comportement par défaut d’Hibernate est souvent le plus approprié : les relations de type référence (OneToOne et ManyToOne) sont « souhaitées » et les relations de type collection de références (OneToMany et ManyToMany) sont « paresseuses ». En utilisant cette stratégie, il n’y a pas de risque de charger un arbre d’objets, mais seulement un fil. C’est un bon compromis.

Le problème est que les relations « souhaitées », même en nombre limité, peuvent provoquer le problème dit du « N+1 » lorsque l’on utilise le mécanisme de requêtage d’Hibernate.

Le problème du « N+1 »

Le problème du « N+1 » consiste à envoyer N+1 requêtes vers la base de données pour avoir la description étendue d’une liste de N objets. La première requête fait l’acquisition des informations de base des N objets de la liste (une liste de clients par exemple). Et ensuite pour chaque client, une série de requêtes – une par client - permet d’obtenir la liste des produits attachés à chacun d’eux.

Un tel comportement a des conséquences souvent très fâcheuses sur les performances. Le « coût » associé à la prise en charge d’une requête par la base de données est largement supérieure à la restitution d’une ligne de la base de données. Dit plus simplement, une requête qui retourne mille lignes est beaucoup moins coûteuse que mille requêtes retournant une ligne ou même cent requêtes retournant dix lignes.

Ce phénomène est très courant sur Hibernate. Pour de multiple raisons que nous allons aborder dans la suite de l’article. Ce phénomène explique bien souvent les temps de réponse désastreux de projets utilisant Hibernate. Il est pourtant assez simple de diagnostiquer le mal : dans le fichier de configuration d’Hibernate ou JPA (hibernate.cfg.xml ou persistence.xml), il faut affecter la valeur « true » à la propriété « hibernate.show_sql ». Le phénomène se traduit alors par un flot de messages présentant des requêtes SQL, apparaissant sur le log ou la console, alors que peu de requêtes HQL ont été exécutées.

Premier conseil donc : ne pas laisser « trainer » les avalanches suspectes de messages SQL d’Hibernate !

En utilisant le requêtage Hibernate, les raisons de tomber dans ce piège sont multiples. La première raison, la plus surprenante est la présence d’au moins une relation en chargement « eager ». Surprenant, car justement, la méthode Session.get() utilise le fait que la relation soit marquée « eager » pour optimiser le chargement de l’entité en n’émettant qu’une seule requête ! Certes. Mais cette optimisation n’est pas utilisée lorsque vous utilisez HQL (ou l’interface Criteria) !

Ainsi, pour un client muni de son adresse (avec un lien « eager » entre Client et Adresse), il faudra une requête à Session.get pour ramener un objet client à partir de son identifiant :

Client c = sess.get(Client.class, 7);

Et 1+N requêtes pour ramener une liste de clients avec leurs adresses ! (une requête pour ramener la liste des N entités clientes et N requêtes pour ramener les N adresses de ces clients).

List<Client> clients = s.createQuery("select c form Client c").list();

La requête HQL ne demande pas les adresses. Certes. Mais comme le lien entre Client et Adresse est « eager », elles sont automatiquement chargées – d’où les N requêtes supplémentaires intempestives. La réflexion légitime que l’on peut alors se faire est : pourquoi Hibernate ne complète-t-il pas la requête SQL pour ramasser automatiquement les adresses liées aux clients recherchés ? Il fait bien ce travail pour les appels de type Session.get. Il sait faire donc. Alors ?

Le fait que le chargement des objets attachés par une relation « eager » ne soit pas pris en compte lorsque l’on utilise un mécanisme de requêtage (HQL, Criteria) est une décision délibéré de l’équipe Hibernate motivée par le fait qu’il est alors très difficile pour le moteur Hibernate d’éviter l’autre grand problème : la jointure croisée insuffisamment réduite (dont nous parlons plus loin). Au développeur de le faire au coup par coup !

La première option que l’on pourrait prendre est de n’utiliser que des relations paresseuses. Cette solution est certes envisageable mais ne vient pas sans inconvénient : c’est se priver de toute possibilité d’optimisation du chargement d’un objet par « Session.get » (celles demandées explicitement et celles demandées par effet de bord comme lors de certains appels à « Session.merge » par exemple). Et puis, on peut aussi avoir vraiment besoin des adresses chaque fois que l’on charge un client.

Le chargement à la volée

Heureusement, Hibernate nous propose un mécanisme qui permet de répondre au problème du N+1 : c’est le chargement à la volée (fetch). Pour cela, on ajoute la définition d’une jointure – généralement externe – à notre requête HQL. Et on indique que cette jointure doit être préchargée. Pour reprendre l’exemple donné plus haut :

List<Client> clients = 
s.createQuery("select c form Client c left outer join fetch c.adresse").list();

Le fait de précharger une jointure a deux vertus :

  • Pré-charger les adresses (c’est l’effet recherché)
  • Initialiser le lien entre les entités clients et les entités adresses.

Le second effet n’est pas sans conséquence. Pour illustrer ce point, prenons le cas d’une relation Client-Produit. Un client peut disposer de plusieurs produits. Le code suivant :

List<Client> clients = 
s.createQuery("select c form Client c left outer join fetch c.produits "+
"where c.ville.name=’Grenoble’ ").list();

Ramène la liste des clients Grenoblois avec leurs produits associés. Le lien entre les clients et les produits est établi.

Si on avait écrit :

List<Produit> produits = 
s.createQuery("select p form Produit p where p.client.ville.name=’Grenoble’ ").list();
List<Client> clients = 
s.createQuery("select c form Client c where c.ville.name=’Grenoble’ ").list();

On aurait ramené les mêmes objets (clients et produits), mais les collections de produits dans les clients n’auraient pas été initialisées. Conséquence : si on tente d’accéder aux produits d’un client, Hibernate lance une requête SQL pour recenser ces produits. Pour s’apercevoir finalement qu’il en disposait déjà dans son cache de premier niveau et que c’était donc inutile !

Chargements multiples

Peut-on pré-charger ainsi plusieurs relations ? La réponse est oui et non, tout dépend du contexte. En fait, le seul cas qui pose problème est celui qui se présente lorsqu’il faut pré-charger deux collections non imbriquées. C’est un cas, malheureusement très courant. Reprenons par exemple, le cas de notre client qui dispose d’une liste de produits et d’une liste de contrats. Le code suivant ramène, comme voulu, clients, produits et contrats en une seule requête SQL :

List<Client> clients = 
s.createQuery("select c form Client c"+
" left outer join fetch c.produits "+
" left outer join fetch c.contrats "+
" where c.ville.name=’Grenoble’ ").list();

C’est super, n’est ce pas ? Et bien non, ce n’est pas super. Pourquoi ? Parce que la requête SQL générée contient deux jointures complètement indépendantes. Conséquence : le nombre de lignes retournées par SQL pour un client donné est le produit du nombre de produits dont le client dispose par le nombre de ses contrats. Si un client dispose de 10 produits et de 8 contrats, SQL retourne 80 lignes ! Evidement, le problème s’amplifie si on veut aussi ramener la liste des contacts …

Notons que si les listes sont imbriquées, ce problème ne se pose pas. Par exemple, pour des livres constitués de chapitres, eux même divisés en paragraphes, le code suivant :

List<Livre> livres = 
s.createQuery("select l form Livre l"+
" left outer join fetch l.chapitres c "+
" left outer join fetch c.paragraphes p"+
" where l.titre like ’A%’ ").list();

Retourne la liste des livres dont le titre commence par A, avec chapitres et paragraphes, pré-chargés et déjà relies entre eux et aux livres. Le nombre de lignes retournées par SQL est égale au nombre de paragraphes définis en base de données. Et cela parce qu’il existe une restriction entre les chapitres pré-chargés et les paragraphes pré-chargés.

De même, les relations de type référence, ne posent pas non plus de problèmes : On peut multiplier un par un et cela à l’infini sans que cela fasse plus de un ! Ainsi, rien n’interdit de ramener un client avec la description de son adresse, la description de l’agence qui le gère, la catégorie à laquelle il appartient, etc…

List<Client> clients = 
s.createQuery("select c form Client c"+
" left outer join fetch c.adresse "+
" left outer join fetch c.agence "+
" left outer join fetch c.categorie "+
" where c.ville.name=’Grenoble’ ").list();

Bien que ces trois jointures soient complètement indépendantes entre elles, elles ne posent aucun problème car elles ne peuvent retourner chacune, qu’une seule ligne.

La règle est simple : vous pouvez pré-charger autant de relations de type référence que vous voulez plus une (et une seule !) série de relations de type collections de références imbriquées.

L’art de pré-charger

Mais alors, comment ramener efficacement mes clients, avec leurs contrats et leurs produits ? En faisant appel à notre ami : le cache de premier niveau . Si vous devez pré-charger plusieurs listes indépendantes, chargez la liste plusieurs fois ! La synthèse sera faite par Hibernate dans le cache de premier niveau. Ainsi pour charger nos clients avec leurs produits et leurs contrats, on écrira :

List<Client> clients = 
s.createQuery("select c form Client c"+
" left outer join fetch c.produits "+
" where c.ville.name=’Grenoble’ ").list();
clients = 
s.createQuery("select c form Client c"+
" left outer join fetch c.contrats "+
" where c.ville.name=’Grenoble’ ").list();

La première ligne vous semble inutile ? (puisque les mêmes clients sont retournés par la seconde requête) ? Détrompez-vous ! Cette requête charge les clients demandés avec leurs produits. Le résultat n’est pas perdu car il est conservé dans le cache de premier niveau ! La seconde requête ramène aussi la description de ses mêmes clients avec leurs contrats. Le cache de premier niveau s’aperçoit à ce moment là, qu’il dispose déjà des clients demandés. Il se contente alors de les compléter, c'est-à-dire de leur associer les contrats. Et renvoie ensuite des objets clients disposant à la fois de leurs produits et de leurs contrats. Mais la base de données n’a renvoyé que 10+8 lignes pour chaque client et non 10x8 lignes. Nous ne sommes pas non plus tombés dans le problème du N+1 (mais plutôt dans le « 1+1 » qui est parfaitement acceptableImage:Smiley.jpg/30px). Astucieux, non ?

On peut remarquer que cette méthode ramène « trop » d’informations puisque la description des clients est retournée inutilement par la seconde requête. On pourrait être tenté d’optimiser en écrivant :

List<Produit> produits = 
s.createQuery("select p form Produit p where p.client.ville.name=’Grenoble’ ").list();
List<Client> clients = 
s.createQuery("select c form Client c"+
" left outer join fetch c.contrats "+
" where c.ville.name=’Grenoble’ ").list();

C’est une mauvaise idée ! Certes les objets ramenés sont les mêmes. Mais le lien partant de client vers produit n’est pas établi (cf plus haut) ! Et toute tentative d’accéder à la liste des produits à partir d’un client se traduira pour Hibernate par une requête SQL intempestive.

Boîte à outils
Dernière modification de cette page le 24 septembre 2009 à 09:35