12. Optimisation des lectures

Le moment est venu de chercher à contrôler les requêtes SQL engendrées par Hibernate pour éviter de se retrouver dans la situation défavorable où de nombreuses requêtes sont exécutées pour charger, petit à petit, des parties minimes du graphe de données. Cette situation apparaît typiquement quand le graphe à matérialiser est découvert par la couche Hibernate au fur et à mesure des navigations de l’application. Or, cette dernière (ou plus exactement le concepteur de l’application: vous) sait souvent à l’avance à quelles données elle va accéder, et peut donc anticiper le chargement grâce à des requêtes SQL (idéalement, une seule) qui vont matérialiser l’intégralité la partie du graphe contenant ces données visitées.

S1: Stratégies de chargement

Supports complémentaires :

La stratégie de chargement spécifie cette anticipation. Essentiellement, elle indique quel est le voisinage d’un objet persistant qui doit être matérialisé quand cet objet est lui même chargé. La stratégie de chargement peut être paramétrée à deux niveaux.

  • dans la configuration du mapping, autrement dit sous forme d’une annotation JPA;
  • à l’exécution de requêtes HQL, grâce au mot-clé fetch, déjà évoqué.

Ces deux niveaux sont complémentaires. La spécification au niveau de la configuration a l’inconvénient d’être insensible aux différents contextes dans lesquels la base de données est interrogée. Pour prendre l’exemple de notre base, dans certains contextes il faudra, quand on accède à un film charger les acteurs, dans d’autre cas ce seront les notes, et enfin dans un troisième cas ni l’une ni l’autre des collections associées ne seront nécessaires.

La bonne méthode consiste donc à indiquer une stratégie de chargement par défaut au niveau de la configuration, et à la surcharger grâce à des requêtes HQL spécifiques dans les contextes où elle ne fait pas l’affaire. C’est ce que nous étudions dans ce chapitre.

Note

Nous reprenons les requêtes du contrôleur Requeteur et nous allons nous pencher sur les requêtes SQL engendrées par Hibernate.

L’étude des stratégies de chargement est rendue compliquée par plusieurs facteurs défavorables.

  • la stratégie par défaut (si on ne spécifie rien) tend à changer d’une version Hibernate à une autre;
  • la stratégie par défaut n’est pas la même en Hibernate et en JPA;
  • la documentation n’est pas limpide, c’est le moins qu’on puisse dire.

Il est donc préférable de ne pas se fier à la stratégie par défaut mais de toujours la donner explicitement, en se basant sur des principes que nous allons identifier. Et il est sans doute nécessaire de vérifier le comportement d’Hibernate sur les requêtes les plus importantes de notre application, pour éviter des accès sous-optimaux à la base sous-jacente.

Nous commençons par une petite étude de cas pour bien comprendre le problème.

Petite étude de cas: le problème

Vous avez déjà écrit une action lectureParCle dans votre contrôleur, qui se contente de charger un film par son id avec load() ou get(). Nous allons associer à cette action maintenant la vue minimale suivante.

<%@ page language="java" contentType="text/html; charset=UTF-8"
      pageEncoding="UTF-8"%>

<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  <title>Accès à un film par la clé, sans navigation</title>
</head>
<body>

      <h2>Le film no 1</h2>

      Nous avons bien ramené le film ${film.titre}. Et c'est tout.

 </body>
</html>

Nous n’accédons donc à aucun objet associé. Seul le titre est affiché. Exécutez cette action et examinez la requête SQL engendrée par Hibernate. Si vous avez utilisé les annotations JPA, cette requête devrait être la suivante (j’ai retiré la liste du select pour ne conserver que les jointures).

select [...]
from Film film0_ left outer join Genre genre1_ on film0_.genre=genre1_.code
   left outer join Pays pays2_ on film0_.code_pays=pays2_.code
     left outer join Artiste artiste3_ on film0_.id_realisateur=artiste3_.id
where film0_.id=?

Hibernate a donc engendré une requête qui effectue une jointure (externe) pour toutes les associations de type @ManyToOne. C’est clairement inutile ici puisque nous n’accédons ni au genre, ni au pays, ni au metteur en scène.

Faisons la même expérience, cette fois en utilisant HQL. Vous avez dû écrire une action parTitre() qui exécute la méthode suivante:

public List<Film> parTitre(String titre)
{
        Query q = session.createQuery("from Film f where f.titre= :titre");
        q.setString ("titre", titre);
        return q.list();
}

Associez à cette action la vue suivante, qui affiche pour chaque film son titre et le nom du réalisateur.

<h2>Les films</h2>

Voici les films ramenés:

<ul>
        <c:forEach items="${films}" var="film">
                <li>Nous avons bien ramené le film ${film.titre} dont le
                        réalisateur est ${film.realisateur.nom}</li>
        </c:forEach>
</ul>

Exécutez l’action. Vous devriez voir 4 requêtes SQL.

  • la première correspond à l’exécution de la requête HQL.

  • les trois suivantes correspondent, respectivement, à la recherche du genre, du pays et du réalisateur; soit:

    select * from Genre genre0_ where genre0_.id=?
    select * from Pays pays0_ where pays0_.id=?
    select * from Artiste artiste0_ where artiste0_.id=?
    

    Les id recherchés sont bien entendu les valeurs des clés étrangères trouvées dans Film.

Les seconde et troisième requêtes sont ici inutiles. La quatrième ne l’est que parce que nous affichons le réalisateur, mais en fait Hibernate effectue « aveuglément » et systématiquement la recherche des objets liés au film par une association @ManyToOne, dès que ce film est placé dans le cache, sans se soucier de savoir si ces objets vont être utilisés. Il s’agit d’une stratégie dite de chargement immédiat (ou « glouton », eager en anglais), en général peu efficace.

Petite étude de cas: la solution

Bien, nous allons remédier à cela en indiquant explicitement la stratégie de chargement à appliquer. Editez la classe Film.java et complétez toutes les annotations @ManyToOne comme suit:

@ManyToOne(fetch=FetchType.LAZY)

Nous souhaitons un chargement « paresseux » (lazy). Maintenant, exécutez à nouveau l’action de recherche par clé: la requête SQL devrait être devenue:

select [...] from Film film0_ where film0_.id=?

Beaucoup mieux n’est-ce pas? On ne charge que ce qui est nécessaire, sans jointure superflue. Considérons la seconde action, celle qui effectue une recherche HQL. Si vous la ré-exécutez avec la stratégie indiquée, vous devriez voir deux requêtes SQL:

select [...] from Film film0_ where film0_.id=?
select * from Artiste artiste0_ where artiste0_.id=?

Pourquoi? La première requête est toujours celle correspondant à la requête HQL. La seconde est déclenchée par navigation: quand on veut afficher le nom du réalisateur, Hibernate déclenche l’accès à l’artiste et son chargement. Cet accès est nécessaire car l’objet n’est pas présent dans le cache, Hibernate étant maintenant en mode Lazy.

La stratégie indiquée est donc parfaite pour notre première action, mais pas pour la seconde où une jointure avec la table Artiste quand on recherche le film serait plus appropriée. En fait, cela montre qu’aucune stratégie générale, indiquée au niveau de la configuration, ne peut être optimale dans tous les cas. Il nous reste une solution, qui est d’adapter la stratégie de chargement au contexte particulier d’utilisation.

Remplacez la requête HQL dans la méthode parTitre() par la suivante:

select film from Film as film join fetch film.realisateur
where film.titre= :titre

Ré-exécutez l’action de recherche par titre, et vous ne devriez plus voir qu’une seule requête SQL effectuant la jointure entre Film et Artiste. Cette requête charge le film, l’artiste, et surtout matérialise le graphe d’association entre les deux objets. Du coup, quand on navigue du film vers son réalisateur, l’objet Artiste est trouvé dans le cache et il n’est plus nécessaire d’effectuer une requête SQL supplémentaire. Avez-vous noté le mot-clé fetch dans la requête HQL? C’est lui qui entraine une surcharge de la stratégie de chargement par défaut.

Et voilà! Récapitulons.

Résumé des leçons tirées

Qu’avons-nous appris? Et bien, en fait, l’essentiel de ce qu’il faut comprendre pour adopter une méthode fondée de choix d’une stratégie de chargement. Pour résumer:

  • Hibernate (ou JPA) applique une stratégie par défaut qui tend à multiplier les requêtes SQL ou les jointures externes inutilement.
  • Cette stratégie par défaut peut être remplacée par une configuration explicite dans les annotations de mapping.
  • Une stratégie au niveau de la configuration ne peut être optimale dans tous les cas: on peut alors, dans un contexte donnée, la remplacer par un chargement adapté au contexte, exprimé en HQL avec l’option fetch.

J’ai pris l’hypothèse un peu simpliste que l’on cherche à minimiser le nombre de requêtes SQL. C’est une bonne approche en général, car elle laisse l’optimiseur du SGBD déterminer la bonne méthode d’exécution, en tenant compte de ses ressources propres (index, mémoire). Il se peut qu’une requête SQL engendrée soit trop complexe et pénalise au contraire les performances. Là, on tombe dans l’expertise des plans d’exécution de requête: je vous renvoie à mon cours sur les aspects systèmes des bases de données pour en savoir plus (http://sys.bdpedia.fr).

S2: Configuration des stratégies de chargement

Supports complémentaires :

La stratégie de chargement peut donc être spécifiée avec la configuration des classes persistantes, et « surchargée » par l’exécution de requêtes HQL indiquant explicitement une stratégie particulière, adaptée au contexte local. Dans cette section nous étudions la spécification par la configuration.

Les stratégies Hibernate/JPA

Note

La documentation Hibernate étant particulièrement confuse sur le sujet (cf. chapitre 20), je tente une simplification/clarification: si quelqu’un me démontre qu’elle ne correspond pas complètement à la réalité je serai ravi de faire un ajustement.

Un petit rappel sur la terminologie avant de commencer: dans ce qui suit, étant donné un objet x chargé par Hibernate, désigné par entité principale, l’expression entité (secondaire) désigne un objet y lié à x via une assocation @ToOne, et l’expression collection désigne un ensemble d’objets, liés à x donc via une association @ToMany. Si, par exemple un objet instance de Film est l’entité principale, le réalisateur (instance de Artiste) est une entité (secondaire) et les rôles du film constituent une collection.

Nous avons donc essentiellement deux stratégies de chargement, s’appliquant aux entités et collections:

  • Eager (« glouton », « gourmand » en anglais) consiste à charger une entité ou une collection le plus tôt possible, indépendamment du fait que l’application les utilise ou non par la suite.
  • Lazy (« paresseux » en anglais) indique au contraire qu’une entité ou une collection est chargée le plus tard possible, au moment où l’application cherche à y accéder.

Par défaut, JPA charge les entités avec une stratégie Eager, et les collections avec une stratégie Lazy. C’est cette stratégie qui est appliqué quand on utilise les annotations JPA, même quand le moteur implantant JPA est Hibernate.

Toujours sur le même exemple, avec la stratégie par défaut JPA, le réalisateur, le pays et le genre sont des entités chargées en mode Eager quand un film est chargé dans la session; les rôles ne sont chargés que quand l’application tente de les récupérer avec la méthode getRoles(). Reprenez notre petite étude de cas en début de chapitre pour vérifier.

Note

Hibernate applique une stratégie par défaut différente de JPA: tout est chargé en mode Lazy.

Examinons maintenant comment ces stratégies sont implantées.

Eager, le chargement glouton

Dans ce mode, Hibernate va tenter d’associer le plus tôt possible les entités secondaires à l’entité principale. On peut constater en pratique que deux méthodes sont utilisées:

  • Par des jointures externes. C’est notamment le cas quand on charge un objet par load() ou get(). Hibernate engendre alors une requête comprenant des outer join.
  • Par des requêtes SQL complémentaires. Si Hibernate n’a pas pu anticiper en plaçant des outer join, des requêtes SQL sont effectuées, une par entité secondaire. C’est le cas par exemple quand l’entité principale est obtenue par une requête HQL, ou quand elle fait partie d’une collection chargée de manière paresseuse.
Le mode Eager ne se justifie que dans deux cas
  • on est sûr que l’application accèdera toujours ou presque toujours aux entités secondaires quand une entité principale est chargée;
  • on est sûr que les entités secondaires sont dans un cache de premier ou de second niveau.

Il me semble difficile d’assurer qu’une application, pour toujours et en toutes circonstances, satisfaira une des deux conditions ci-dessus. Cette stragégie a le grave inconvénient d’engendrer parfois en grande quantité des requêtes SQL élémentaires, ramenant chacune une seule ligne. Je propose donc la recommandation suivante.

Recommandation: toujours adopter le mode Lazy par défaut

Au niveau de la configuration du mapping, je vous recommande d’appliquer systématiquement une stratégie lazy par défaut. Ce qui revient à compléter les annotations @OneToOne ou @ManyToOne comme suit:

@ManyToOne (fetch=FetchType.LAZY)

Pour les associations de type @ToMany, le mode par défaut est toujours Lazy et il est donc inutile de compléter l’annotation (mais faites-le si vous voulez privilégier la clarté).

Note

La documentation Hibernate parle de Immediate fetching, Eager fetching et Join fetching, de manière à vrai dire assez confuse et mélangeant le quand et le comment. Il semble suffisant de considérer qu’il n’y a que deux méthodes, gloutonne (Eager) ou paresseuse (Lazy), et pour chacune plusieurs implantations possibles (par requête indépendante, par outer join, etc.) selon les circonstances.

Le mode Lazy

Le mode Lazy est le mode par défaut pour les collections. Cela semble indispensable car la taille de la collection n’est pas connue à priori, et se mettre en mode Eager ferait courir le risque de charger un très grand nombre d’objets, sans garantie sur leur utilisation, et un coût d’initialisation et de stockage en mémoire élevé. Pensez par exemple à ce que donnerait le chargement de tous les films d’un pays comme les USA si on associait une collection films en mode Eager à la classe Pays.

Par défaut, toutes les collections devraient donc être en mode Lazy. Aucune solution n’étant idéale en toutes circonstances, ce mode peut donner lieu à un chargement inefficace, caractérisé par l’expression “1+n requêtes”.

Le problème des 1+n requêtes

Supposions que vous vouliez parcourir tous les films de la base, et pour chacun analyser les notes reçues. La structure de votre action serait la suivante:

// On recherche les films
Set<Film> films = session.createQuery ("from Film");

for (Film film: films) {
  // Pour chaque film on traite les notes
  for (Notation notation: films.getNotation()) {
    // Faire qq chose avec la notation
  }
}

Nous avons deux boucles imbriquées. Quelles sont les requêtes SQL engendrées? Il y en a une par boucle.

  • La première sélectionne tous les films et ressemble simplement à:

    select * from Film
    
  • La seconde sélectionne les notes d’un film donné

    select * from Notation where id_film=:film.id
    

Comment sont-elles exécutées? La première est exécutée une fois, la seconde autant de fois qu’il y a de films, d’où la caractérisation par l’expression “1+n” requêtes. Le problème est que cette stratégie est inefficace car elle soumet potentiellement beaucoup de requêtes au SGBD. Si vous avez 10 000 films, vous exécuterez 10 000 fois la même requête, alors que les SGBD sont conçus, grâce à l’opération de jointure, pour ramener tous les objets en une seule requête.

On peut envisager plusieurs solutions au problème.

  • en changeant la configuration pour accéder à la collection en mode Eager: comme je l’ai déjà signalé, utiliser une configuration générale pour résoudre un problème survenant dans un contexte spécifique ne fait que transposer le problème ailleurs;
  • en utilisant le chargement par lot, décrit ci-dessous;
  • en utilisant une requête HQL de chargement.

La troisième solution me semble de loin la meilleure. Pour votre culture Hibernate, voici une brève présentation du chargement par lot.

Le chargement par lot, une variante de Lazy

Le chargement par lot (batch fetching) permet de factoriser les requêtes effectuées pour obtenir les collections associées aux objets de la boucle extérieure (celle sur les films dans notre exemple). Il est caractérisé par la taille d’un lot, k, exprimant le nombre de collections recherchées en une seule fois. La syntaxe de l’annotation est la suivante:

@BatchSize(size=k)

Reprenons l’exemple de nos 10 000 films, avec les 10 000 requêtes cherchant la collection roles de chaque film. Avec un chargement par lot de taille 10, on va grouper la recherche des collections 10 par 10. Supposons pour simplifier que les identifiants des films sont séquentiels: 1, 2, 3, …., etc. Quand l’application cherche à accéder à la collection roles du premier film, la requête suivante sera effectuée:

select * from Notation where id_film in (1, 2, 3, 4, 5, 6, 7, 8 , 9, 10)

et les 10 collections des 10 premiers films seront initialisées. L’accès aux roles pour les films 2, 3, 4, …, 10 se fera donc dans le cache, sans avoir besoin d’effectuer une nouvelle requête SQL. L’accès aux roles du film 10 déclenchera la requête:

select * from Notation where id_film in (11, 12, 13, 14, 15, 16, 17, 18 , 19)

et ainsi de suite. Avec ce paramétrage, on est passé du problème des 1+n requêtes au problème des 1+n/10 requêtes!

C’est donc un mode intermédiaire entre Eager et Lazy. Incontestablement, il va amener une amélioration des performances de l’application. Cela peut être une solution simple et satisfaisante, même si elle repose sur un paramétrage dont la valeur n’est pas évidente à fixer.

Cela dit, on peut aisément faire la même critique: le paramétrage du lot dans la configuration n’est sans doute pas adapté à tous les contextes présents dans l’application. Dans la mesure où on peut, localement, dans un contexte donné, opter pour une stratégie Eager, pourquoi se priver de le faire, ce qui est à la fois simple, efficace et compréhensible? Cela passe par l’utilisation de requêtes de chargement HQL.

Exercice: appliquez le chargement par lot

Ecrivez une action qui parcourt tous les films et affiche leur rôles: vous devriez constater l’effet des 1+n requêtes. Appliquer le chargement par lot pour limiter son impact, et consultez les requêtes SQL engendrées par Hibernate.

S3: Charger un sous-graphe avec HQL

Supports complémentaires :

Nous avons étudié HQL dans une optique de sélection de données à matérialiser. Dans cette section nous abordons l’option fetch qui indique que l’on veut charger les objets associés dans le cache. Si, par exemple, on sait à l’avance que l’on va traiter un film, son metteur en scène et ses acteurs, on peut décider de charger par une seule requête tout ce sous-graphe.

L’option fetch

On ne peut pas utiliser fetch sans left join. Le fetch indique que les objets de la table associée doivent être placés dans le cache.

select film
from Film as film
left join fetch film.roles as role

Pour chaque film placé dans le cache, on initialise donc la collection des rôles.

Combiner requêtes de chargement et de sélection

Attention à ne pas combiner sans précaution une requête de chargement (avec fetch) et de sélection (avec where). Prenons la requête suivante: on veut les films dont un des rôles est « McClane ». Et on l’exprime de la manière suivante:

select film
from Film as film
left join fetch film.roles as role
where role.nom= 'McClane'

Le fetch indique que l’on veut charger dans le cache le graphe Film-Role pour les lignes sélectionnées dans la base. Mais ici nous avons un problème: comme nous avons exprimé une restriction avec la clause where sur les rôles, seuls les rôles dont le nom est McClane seront chargés. La mauvaise surprise est que quand nous naviguerons du film vers ses rôles, nous n’en trouverons qu’un, ce qui n’était pas l’objectif initial.

La solution est d’utiliser deux fois la table Role. La première occurence, sans restriction, apparaît dans le left join fetch, la seconde dans le where. Voici une expression qui me semble claire (d’autres sont possibles):

from Film as film
left join fetch film.roles as role
where film in (select r2.pk.film from Role as r2 where nom= 'McClane')

Ce n’est pas très élégant, mais cela correspond à la satisfaction de deux besoins différents: sélectionner des données et les charger sous forme d’objet.

Résoudre les 1+n requêtes avec HQL

Reprenons le problème des 1+n requêtes. Il serait préférable d’effectuer la jointure suivante en lieu et place de la requête SQL qui sélectionne simplement les films:

select * from Film as film, Notation as note
where film.id=note.id_film

Il se trouve que nous savons maintenant obtenir cette jointure et le chargement du sous-graphe Film-Notation avec la requête HQL suivante:

select distinct film from Film as film left join fetch film.notations

En utilisant cette expression HQL, les notations seront placées dans le cache, et la navigation sur les notations pour chaque film ne nécessitera plus de l’exécution de la seconde requête SQL.

// On recherche les films
Set<Film> films = session.createQuery ("select distinct film from Film as film "
                                     + "left join fetch film.notations");

for (Film film: films) {
  // Pour chaque film on traite les notes
  for (Notation notation: films.getNotation()) {
    // Faire qq chose avec la notation
  }
}

Exercice: testez la solution au problème des 1+n requêtes

Reprenez l’action qui affiche un film et ses rôles. Utilisez la requête HQL de chargement appropriée pour avoir une seule requête et prendre tous les objets dans le cache.

Exercice: minimisation des requêtes SQL

Prenez l’application de votre projet, qui devrait consister en un ensemble d’actions consistant, chacune, à naviguer dans une partie du graphe d’objet. Le but est de minimiser le nombre de requêtes SQL, si possible avec une seule requête par action. À vous de jouer.

Exercice (difficile): validation des performances

Nous prenons jusqu’à présent l’hypothèse implicite qu’il faut minimiser le nombre de requêtes SQL. Pour vérifier que c’est justifié, nous pouvons explorer les deux options suivantes:

  • Charger une base de données volumineuse, mesurer les temps de réponse avec la configuration par défaut, puis notre configuration optimisée, au moins pour quelques requêtes importantes.
  • Etudiez le plan d’exécution de MySQL pour quelques requêtes et vérifier qu’il est optimal.

Cet exercice demande un travail conséquent et constitue un complément significatif au projet. À vous de voir si vous êtes intéressé.

Pour charger une base de données avec beaucoup de lignes, vous pouvez vous reporter au site http://www.generatedata.com/.

Résumé: savoir et retenir

La question du chargement des objets par des requêtes SQL est importante et malheureusement assez complexe pour des raisons déjà évoquées: changements d’une version à une autre, confusion de la documentation, différences JPA/Hibernate, et aussi tout simplement la complexité des options disponibles. Sachez que j’ai essayé de vous dissimuler certains aspects (les proxy, les wrappers) dont le rôle est peu clair et qui ne sont sans doute pas indispensables à la bonne gestion du problème. Voici un résumé de mes recommandations, pour une approche qui me semble solide et gérable sur le long terme.

  • Dans la configuration, déclarez toutes vos associations en Lazy.
  • Configurez la session pour produire l’affichage des requêtes SQL.
  • Explorez votre application systématiquement pour analyser les requêtes SQL qui sont produites; dans une application MVC c’est assez simple: exécutez chaque action et regardez quelle(s) requête(s) SQL est/sont produite(s).
  • Si vous constatez trop de requêtes: remplacez les opérations de navigation par une requête HQL initiale qui charge les objets auxquels l’action va accéder; si possible réduisez à une seule requête SQL produite par action.
  • Si vous constatez qu’une ou plusieurs requêtes SQL paraîssent trop complexes, produisez son plan d’exécution, et modifiez éventuellement vos expressions HQL pour la décomposer.

Le bon réglage des requêtes SQL est un art qui implique une très bonne compréhension de l’exécution des requêtes dans un système relationnel, l’utilisation des index, des algorithmes de jointure, de tri, des ressources mémoire, etc. Si vous n’êtes pas spécialiste, l’aide d’un DBA pour optimiser votre application peut s’avérer nécessaire.