Rendre performant son application Spring Data Hibernate
Spring Data JPA
fournit une implémentation de la couche d’accès aux données pour une
application Spring. C’est une brique très pratique car elle permet de ne pas
réinventer la roue de l’accès aux données à chaque nouvelle application et
donc de se concentrer sur la partie métier. Il y a quelques bonnes
pratiques à respecter lors de l’utilisation de Spring Data JPA : par exemple,
limiter le chargement des objets inutiles pour optimiser les performances. Dans ce but, cet
article donnera quelques conseils afin de réduire les allers-retours vers la
base de données, de ne pas récupérer l’ensemble des éléments de la base de
données et de ce fait ne pas impacter les performances globales de
l’application. Pour cela, nous verrons dans un premier temps les différents outils
que Spring Data JPA nous offre pour améliorer le contrôle de l’accès aux
données, ainsi que quelques bonnes pratiques afin de réduire l’impact qu’aura
la récupération des données sur notre application. Ensuite, je vous ferai part
d’un retour d’expérience qui sera un exemple concret de l’amélioration des
performances d’une application Spring en jouant sur ces différents aspects et
en réduisant ainsi les problèmes sous-jacents. Le chargement des relations
d’entités Un peu de théorie : les
types de chargement EAGER et LAZY Lors de la création d’une
application utilisant Spring Data JPA (et plus globalement Hibernate), les
dépendances d’un objet (par exemple l’auteur d’un livre) peuvent être chargées
de façon automatique (chargement EAGER) ou manuelle (chargement LAZY). Dans le cas d’une dépendance de
type EAGER, à chaque fois que l’on va charger un objet, les objets en relation
seront également chargés : lorsque l’on demande les informations d’un livre,
les informations de l’auteur seront également récupérées. Dans le cas d’une
dépendance de type LAZY, seules les informations de l’objet demandé seront
chargées : on ne récupérera pas les données de l’auteur. Dans Spring Data JPA,
une relation entre deux objets du domaine possède l’une ou l’autre de
ces méthodes de chargement des données. Par défaut, la méthode sera déterminée
par le type de relation. Voici un rappel des différents types de relation avec
la méthode de chargement par défaut : @OneToOne
Pour chaque instance de l’entité A, une et une
seule instance de l’entité B est associée. B n’est également lié qu’à une seule
instance de l’entité A. Un exemple typique est la relation entre un patient et
son dossier patient :
@Entity
public class Patient implements Serializable {
@OneToOne
private DossierPatient dossier;
}
Pour ce type de relation, la méthode de chargement de la
relation par défaut est EAGER : à chaque fois que l’on demande les données du
patient, on aura donc également les données de son dossier. @ManyToOne
Pour chaque instance de l’entité A, une et une seule instance
de l’entité B est associée. En revanche, B peut être lié à plusieurs instances
de l’entité A. Un exemple typique est la relation entre un produit et sa
catégorie associée :
@Entity
public class Produit implements Serializable {
@ManyToOne
private CategorieProduit categorie;
}
Pour ce type de relation, la méthode de chargement de la
relation par défaut est EAGER : à chaque fois que l’on demande les données d’un
produit, on aura donc également les données de sa catégorie. @OneToMany
Pour chaque instance de l’entité A, zéro, une, ou plusieurs
instances de l’entité B sont associées. En revanche, B n’est lié qu’à une seule
instance de l’entité A. Il s’agit de l’inverse de la relation @ManyToOne, nous
pouvons donc prendre l’exemple d’une catégorie de produit avec sa liste de
produits associés :
@Entity
public class CategorieProduit implements Serializable {
@OneToMany
private Set<Produit> produits = new HashSet<>();
}
Pour ce type de relation, la méthode de chargement de la
relation par défaut est LAZY : à chaque fois que l’on demande les données d’une
catégorie, la liste de ses produits ne sera pas chargée. @ManyToMany
Pour chaque instance de l’entité A, zéro, une, ou plusieurs
instances de l’entité B sont associées. L’inverse est également vrai, B peut
être lié à zéro, une, ou plusieurs instances de l’entité A. Un exemple typique
est la relation entre un article et sa liste de thèmes abordés :
@Entity
public class Article implements Serializable {
@ManyToMany
private Set<Theme> themes = new HashSet<>();
}
Pour ce type de relation, la méthode de chargement de la
relation par défaut est également LAZY : à chaque fois que l’on demande les
données d’un article, la liste de ses thèmes ne sera pas chargée. Minimiser l’utilisation
des relations EAGER Le but est de ne récupérer en base de données que les données
nécessaires à ce que l’on a demandé. Par exemple, si l’on souhaite afficher la
liste des noms des auteurs présents dans notre application, on ne souhaite pas
récupérer l’ensemble de leurs relations : les livres qu’ils ont écrits, leur
adresse, etc. Une bonne pratique est donc de minimiser les relations
chargées de façon automatique. En effet, plus on a des relations EAGER, plus on
charge des objets qui ne nous sont pas forcément utiles. Cela va se traduire
par une augmentation du nombre d’allers retours nécessaires vers la base de
données et une augmentation du temps de traitement dédié au mapping des tables
de la base de données vers les entités de notre application. De ce fait, il
peut être intéressant d’utiliser au maximum les relations LAZY et de charger
les données des relations manquantes seulement lorsqu’elles sont souhaitées.
Concrètement, il est conseillé de ne conserver un
chargement EAGER que pour les relations dont nous sommes sûrs que les données
liées seront toujours utiles (et je pense que ce cas n’est pas si courant).
Cela implique de laisser les méthodes de récupération par défaut des relations
@OneToMany et @ManyToMany et de forcer le chargement LAZY pour les relations
@OneToOne et @ManyToOne. Ceci se matérialise par l’attribut “fetch” de la
relation :
@Entity
public class Produit implements Serializable {
@ManyToOne(fetch = FetchType.LAZY)
private CategorieProduit categorie;
}
Cela demande un travail supplémentaire d’ajustement pour
chaque entité et chaque relation car il va être primordial de créer de
nouvelles méthodes qui nous permettront de charger l’ensemble des données
nécessaires à une action en un minimum de requêtes. En effet, si on veut
afficher l’ensemble des données d’un auteur (ses informations, la liste de ses
livres, son adresse, etc.), il sera intéressant de récupérer l’objet et ses
relations en une seule requête, en utilisant donc des jointures en base de
données. Comment contrôler
quelles les requêtes effectuées
Spring Data JPA effectue l’accès aux données à notre place.
Cependant, il faut avoir conscience de comment va être effectué ce traitement.
Afin de vérifier quelles requêtes sont exécutées pour récupérer les données en
base, il faut activer les logs Hibernate. Pour ce faire, plusieurs solutions
sont à notre disposition. Tout d’abord, une option peut être activée dans
spring : Ou alors, il est possible de le configurer au niveau du fichier de conf du logger : NB : Dans ces logs, les arguments passés aux requêtes ne sont
pas affichés (ils sont remplacés par “?”), mais cela n’empêche pas de voir
quelles sont les requêtes exécutées. Comment optimiser la
récupération des objets LAZY Spring Data JPA fournit la possibilité de spécifier quels
seront les relations chargées lors d’une requête de sélection dans la base de
données. Nous allons voir avec différentes méthodes le même exemple : comment
récupérer un article avec ses thèmes abordés en une seule requête. Méthode
n°1 : récupération et chargement des objets avec @Query L’annotation
@Query permet d’écrire une requête de sélection grâce au langage JPQL. On peut
ainsi utiliser le mot-clé JPQL “fetch” positionné sur la jointure des relations
de la requête dans le but de charger ces relations. Dans le repository de
l’entité Article, il est ainsi possible de créer une méthode
“findOneWithThemesById” en indiquant que la liste des thèmes devra être chargée
dans l’instance de l’article récupéré :>spring:
jpa:
show-sql: true
<logger name="org.hibernate.SQL" level="DEBUG"/>
@Repository
public interface ArticleRepository extends JpaRepository<Article,Long> {
@Query("select article from Article article left join fetch article.themes where article.id =:id")
Article findOneWithThemesById(@Param("id") Long id);
}
Méthode n°2 : Récupération et chargement des objets avec
@EntityGraph Depuis Spring Data JPA 1.10, nous
avons la possibilité d’utiliser l’annotation @EntityGraph afin de créer des
graphes de relations à charger en même temps que l’entité lorsque demandé.
Cette annotation s’utilise également dans les repositories JPA. La définition
quant à elle peut se faire soit directement sur la requête du repository, soit
sur l’entité. Définition
du graphe sur la requête
On définit sur la requête les
relations qui seront chargées avec l’entité grâce au mot-clé “attributePaths”
qui représente la liste de relations (ici une liste d’un seul élément) :
@Repository
public interface ArticleRepository extends JpaRepository<Article,Long> {
@EntityGraph(attributePaths = "themes")
Article findOneWithThemesById(Long id);
}
Définition du graphe sur l’entité
On peut également définir ces
graphes sur l’entité grâce à la notion de NamedEntityGraph arrivé avec JPA 2.1.
Le principal avantage est qu’il est possible de se servir de la définition de
ce graphe dans plusieurs requêtes. Dans ce cas, on spécifie la liste des
relations chargées grâce au mot-clé “attributeNodes” qui sont une liste de
@NamedAttributeNode. Voici comment l’implémenter sur l’entité Article :
@Entity
@NamedEntityGraph(name = "Article.themes", attributeNodes = @NamedAttributeNode("themes"))
public class Article implements Serializable {
...
}
Puis il est possible de s’en servir de la façon suivante :
@Repository
public interface ArticleRepository extends JpaRepository<Article,Long> {
@EntityGraph(value = "Article.themes")
Article findOneWithThemesById(Long id);
}
De plus, il est possible de spécifier le type de chargement
des relations non spécifiées avec l’attribut “type” : chargement LAZY de toutes
les relations non spécifiées ou chargement par défaut (EAGER ou LAZY suivant le
type indiqué sur la relation). Il est également possible de créer des
sous-graphes et ainsi travailler de manière hiérarchique afin d’être le plus
fin possible. Limitation
liée à l’utilisation de @EntityGraph Pour ces 2 méthodes liées aux graphes d’entités, on ne peut,
à ma connaissance, pas récupérer une liste contenant l’ensemble des entités
avec des relations. En effet, pour cela, on aimerait créer une méthode qui se
définirait par exemple comme “findAllWithThemes()” avec comme nœuds du graphe
“themes”. Or ce n’est pas possible, il faut forcément utiliser une restriction
de recherche (synonyme d’un “where” dans une requête “select” en base de
données). Pour pallier cette limitation, une solution est de créer une
méthode “findAllWithThemesByIdNotNull()” : l’identifiant n’étant jamais “null”,
toutes les données seront retournées. L’autre solution est de faire cette
requête avec jointure à l’aide de la première méthode, car l’annotation @Query
n’a pas cette limitation. Ajout de l’information
non optionnelle si besoin
Lorsqu’une relation @OneToOne et @ManyToOne a un
caractère obligatoire, c’est-à-dire que l’entité possède forcément sa relation
associée, il est important de spécifier à Spring Data JPA que cette relation
n’est pas optionnelle. Nous pouvons prendre l’exemple suivant : une personne
possède obligatoirement une adresse, qui peut elle-même être partagée par
plusieurs personnes. Ainsi, la définition de la relation est la suivante :
@Entity
public class Personne implements Serializable {
@ManyToOne(optional = false)
@NotNull
private Adresse adresse;
}
L’ajout de l’information “optional = false” va permettre à
Spring Data JPA d’être plus efficace dans la création de ses requêtes de
sélection car il saura qu’il a forcément une adresse associée à une personne.
Par conséquent, une bonne pratique est de toujours spécifier cet attribut lors
de la définition d’une relation obligatoire. Points d’attention Attention aux
conséquences Suite au refactoring du code d’une application pour apporter
ces fonctionnalités, c’est-à-dire la modification du chargement des relations
de EAGER vers LAZY, certaines régressions et bugs peuvent apparaître. Voici
deux exemples très courants. Potentielle
perte d’information Le premier effet de bord peut être la perte d’information,
par exemple lors de l’envoi d’entités via Web Service. En effet, lorsque l’on modifie par exemple la relation entre
Personne et Adresse de EAGER vers LAZY, il faut revoir les requêtes de
sélection des entités Personne afin d’ajouter le chargement explicite de leur
adresse (avec l’une des méthodes vues ci-dessus). Sans quoi, il se peut que le
Web Service de récupération d’une personne fournisse uniquement les données
propres à la personne et que les données de l’adresse aient disparues. Cela est
problématique car le web service peut ne plus respecter son contrat
d’interface, cela peut par exemple impacter l’affichage sur une page web : on
s’attend à ce que l’adresse soit renvoyée car on en a besoin pour l’afficher
dans notre vue HTML. Afin d’éviter ce problème, il est intéressant d’utiliser des
DTO (Data Transfer Object) plutôt que directement renvoyer les entités aux
clients. En effet, le mapper transformant l’entité en DTO va charger l’ensemble
des relations dont il a besoin en allant récupérer en base de données les
relations qui n’ont pas encore été chargées lors de la requête initiale : il
s’agit du Lazy Loading. Ainsi, même si l’on ne refactore pas la récupération
des entités, le web service continuera de renvoyer les mêmes données. Potentiel problème de
transaction Le deuxième effet de bord peut être l’apparition de
l’exception “LazyInitializationException”. Cette exception apparaît lorsque l’on essaye de charger une
relation en dehors d’une transaction, le Lazy Loading ne peut alors pas se
faire car l’objet est détaché : il ne fait pas partie d’une session Hibernate.
Cela peut apparaître après les modifications du type de chargement des
relations car avant ces modifications, les relations étaient chargées
automatiquement à la récupération des données en base de données. L’exception peut dans ce cas être levée pour 2 principales
raisons. La première cause peut être que vous n’êtes pas dans une transaction
Hibernate. Dans ce cas, il faut soit rendre le traitement transactionnel (grâce
à l’annotation Spring @Transactional à positionner sur la méthode ou sur sa classe),
soit appeler un service transactionnel qui pourra s’occuper de charger les
dépendances. La deuxième cause peut être que vous êtes sortis de la transaction
dans laquelle votre entité avait été attachée, et que l’entité n’est pas
attachée à votre nouvelle transaction. Dans ce cas, il faut soit charger les
relations dans la première transaction, soit réattacher l’objet dans la
seconde. Spécificités des
requêtes paginées Lorsque l’on souhaite créer une requête paginée devant
contenir des informations d’une ou plusieurs relation(s), le chargement des
données appartenant aux relations dès la requête de sélection est une (très)
mauvaise idée. Par exemple, lorsque l’on récupère la première page des articles
de l’application avec leurs thèmes, il est préférable de ne pas charger
directement l’ensemble des données articles+thèmes, mais d’abord les données
concernant les articles puis les données thèmes de ces articles dans un second
temps. En effet, sans cela, l’application sera obligée de récupérer l’ensemble
de données de la jointure entre les 2 tables, de les stocker en mémoire, pour
ensuite sélectionner seulement les données de la page demandée. Ceci est
directement lié au fonctionnement des bases de données, elles doivent
sélectionner l’ensemble des données de la jointure même si on ne souhaite qu’un
fragment des données : que ce soit une page, un min/max ou le top x des
résultats.
Dans le cas d’un chargement d’une page avec ses relations, un
message explicite apparaît dans les logs pour vous en avertir :
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
Plus la volumétrie des
2 tables est importante, plus l’impact sera élevé : avec des tables contenant
des millions d’entrées, cela peut se traduire par un traitement extrêmement
coûteux pour l’application. Pour pallier ce
problème, il est donc indispensable de charger dans un premier temps les
entités sans leurs relations puis de charger celles-ci dans un second temps.
Pour charger les données de l’entité de la page demandée, on peut utiliser la
méthode findAll(Pageable pageable) du repository JPA (hérité de la classe
PagingAndSortingRepository). Ensuite, pour charger les données des relations,
vous pouvez utiliser le Lazy Loading en appelant directement le getter des
relations à récupérer pour chacune des entités. Cette opération va
être coûteuse car elle va engendrer beaucoup de requêtes. En effet, on aura
pour chaque entité autant de requêtes de sélection qu’il y a de relations à
charger : ce problème est connu sous le nom du “problème du N+1 avec
Hibernate”. Si on prend l’exemple d’un chargement d’une page de 20 articles
avec une relation à charger, cela se traduira par 21 requêtes : 1 pour la page
et 20 pour les thèmes de chacun des articles.
Afin de réduire ce
coût, il est possible d’utiliser l’annotation @BatchSize(size = n) sur la
relation @OneToMany ou @ManyToMany entre les deux entités. Cela permet
d’indiquer à Hibernate d’attendre d’avoir un nombre suffisant (n) de relations
à récupérer avant de faire la requête de sélection dans la base de données. Ce
nombre n est à associer à la taille des pages (cela signifie néanmoins d’avoir
une taille des pages par défaut car n est défini sur l’entité et donc
constant). Ainsi, dans l’exemple précédent, on peut spécifier le nombre minimal
à 20 :
@Entity
public class Article implements Serializable {
@ManyToMany
@BatchSize(size = 20)
private Set<Theme> themes = new HashSet<>();
}
Dans ce cas, le nombre de requêtes pour charger la page
passera de 21 à 2 : 1 pour la page et 1 pour l’ensemble des thèmes. NB : si la page contient moins de 20 éléments (donc
inférieur à n), les thèmes seront quand même bien chargés. Aller plus loin Voici d’autres points intéressants à creuser pour ne pas voir
les performances de son application chuter. Utilisation d’un cache Pour booster les performances de votre application, il peut
être intéressant d’utiliser un système de cache. Il existe tout d’abord le
cache de second niveau d’Hibernate. Cela permet de garder en mémoire les
entités du domaine ainsi que leur relation et donc de réduire le nombre d’accès
à la base de données. Il existe également le cache de requête. En complément du
cache de second niveau, il est souvent plus utile. Il permet de conserver en
mémoire les requêtes effectuées et leur résultat. Il faut cependant porter
attention à quelques points lors de l’utilisation de celui-ci : le cache de second niveau est utile uniquement lors des accès
aux entités par l’id (et par les bonnes méthodes JPA) ; dans une application distribuée, il est obligatoire d’utiliser également un cache
distribué, ou de ne l’utiliser que sur des objets en lecture seule qui changent
rarement ; la mise en place est plus compliquée lorsque la base de
données peut-être altérée par d’autres éléments (c’est-à-dire lorsque
l’application n’est pas le point central d’accès aux données). Création d’index La création d’index est un passage obligé pour améliorer les
performances d’accès à la base de données. Ceci permet des recherches de
sélection plus rapides et moins coûteuses. Une application dépourvue d’index
sera donc beaucoup moins performante. Quelques précisions sur la création
d’index : Ø
Plus la volumétrie de la table est importante,
plus l’index portera ses fruits ; Ø
Un index est généralement utile que si les
données sont suffisamment réparties (un index sur le sexe avec 50% M et 50% F
n’est pas très efficace) ; Ø
Un index qui accélère la lecture va
également ralentir l’écriture ; Ø
Les SGBD n’ont pas tous les mêmes politiques
d’indexation qui devront donc être étudiées, un index peut par exemple être
créé automatiquement ou non à l’ajout d’une clé étrangère suivant le SGBD. Gestion des
transactions La gestion des transactions est aussi un levier important
d’optimisation : il est nécessaire de bien les utiliser si l’on ne veut pas
voir apparaître des problèmes de performance. Par exemple, leur mise en place
étant coûteuse pour l’application, il est préférable de ne pas en utiliser dans
les cas où elles ne sont pas nécessaires. Par défaut, Hibernate va attendre la
fin de la transaction Spring avant de mettre à jour l’ensemble des données dans
la base (sauf dans les cas où un flush de persistance intermédiaire lui est
nécessaire). Pendant ce temps, il va stocker les mises à jour en mémoire. Si
une transaction concerne une volumétrie de données très importante, on peut
rencontrer des problèmes de performances. Dans ce cas, il est préférable de
découper le traitement en plusieurs transactions. De plus, lorsque la
transaction ne fait que de la lecture, il faut indiquer à Spring qu’elle est en
lecture seule afin d’améliorer les performances : Retour d’expérience
On m’a récemment demandé de travailler sur les performances
d’une application web. Malgré un premier travail sur certaines parties pour
améliorer les performances globales, l’application continuait de provoquer une
saturation des serveurs sur lesquels elle était installée (surtout en charge
CPU), les délais d’attente pour chaque requête étaient très élevés et on
semblait avoir atteint la volumétrie maximale que l’application pouvait
supporter. En remarquant le nombre de requêtes effectuées constamment vers la
base de données (à l’activation des logs Hibernate) et en regardant dans le
code comment étaient produites ces requêtes, j’ai décidé d’appliquer les
quelques conseils de cet article afin de donner un peu d’air à l’application.@Transactional(readOnly
= true)
Procédé utilisé L’application web sur laquelle je
suis intervenu a la particularité de fonctionner beaucoup plus sous la forme de
batch (actions faites toutes les 1 à 60 secondes selon les jobs) que d’actions
utilisateur. Afin de réduire ces problèmes de performance, j’ai essayé de
procéder de manière itérative, en me préoccupant d’un traitement à la fois dans
l’ordre décroissant de la fréquence des jobs : d’abord ceux qui sont effectués
chaque seconde jusqu’à ceux effectués chaque minute. Pour chaque job, je comptais le
nombre de requête Hibernate de sélection effectuées pour réaliser le traitement
et j’essayais de voir si chacune était nécessaire ou s’il s’agissait de données
récupérées à tort. Dès que je trouvais une incohérence dans ce qui était chargé
depuis la base de données, je modifiais le type de relation (EAGER à LAZY), je
créais des requêtes adaptées dans les repositories et je favorisais les
critères de sélection sur la requête (contrairement à un filtre appliqué à
posteriori sur la liste des données récupérées). Le but était de réduire le nombre
de requête effectuées à un nombre minimum (une seule si possible) et de ne
charger que les objets nécessaires à chaque traitement. Afin de vérifier si la charge sur
la base de données (et le chargement des données) diminuait, j’ai décidé de me
baser sur 2 métriques : le nombre de requêtes de sélection et le nombre
d’objets chargés par minute. Pour cela, j’ai utilisé une méthode assez
simpliste : je redirigeais les logs de l’application sur 1 minutes, et de ce fichier,
je comptais : Le nombre de requêtes de
sélection : nombre d’occurrences de “SELECT …” ; le nombre d’objets chargés :
nombre d’occurrences de “SELECT …” additionné au nombre d’occurrences de “…
join fetch …”. Résultats Après quelques jours de travail
sur ce sujet et en appliquant seulement ces quelques procédures, j’ai réussi à
diviser environ par 5 le nombre de requêtes effectuées en base de données et
par environ 7 le nombre total d’objets chargés.
Avec la nouvelle version de
l’application en production, la charge du serveur s’est vraiment amoindrie.
Pour preuve, voici l’état du serveur avant la nouvelle version.
Développement des Applications Web et Mobiles
Projets de Transformation digitale
Scalabilité - Performances - Sécurité
Nous mettons l’accent sur la Précision
la Fiabilité et l’Attention aux détails de livraison
Casablanca, 20050 Maroc
+212 656 40 53 96
+212 642 68 12 95
damename@yahoo.com
Copyright © 2026 | L@D | iTechno