Gestion de session avancée avec Hibernate

On ne présente plus Hibernate tellement il est utilisé de nos jours dans les applications Java, cependant le fonctionnement exact de la session reste souvent un mystère pour beaucoup d’utilisateurs.
Il est donc utile de voir des exemples de manipulation de la session, et notamment dans un domaine cher à nos clients, les traitements de masse et l’optimisation de performance.
Ce billet présente donc différentes façons de traiter des quantités importantes de données et détaille les caractéristiques de chaque solution.

Détail du billet

Le scénario utilisé ici sera l’insertion de 10 000 enregistrements en une seule transaction. Pour cela, nous utilisons un module de réservation de transport qui utilise le modèle de données suivant : Les différents moyens de transport (avion, train) sont déjà présents en base, et l’ajout de données concernera la table réservation et ses tables associées : Facture (one-to-one), Voyageur (one-to-many) et Transport (many-to-many).
diagramme_classe

Scénarii, mesures et optimisations

Utilisation standard

Voici les temps mesurés lorsqu’on persiste traditionnellement les objets en base avec session.persist() (JPA : entityManager.persist()), avec 64 Mo de mémoire.

|------------------------------|-------|------------|------------|------------|------------|
|       Measurement Point      |   #   |   Average  |     Min    |     Max    |    Total   |
|------------------------------|-------|------------|------------|------------|------------|
| Nominal Test - Flush Default |     1 | 15 021,604 | 15 021,604 | 15 021,604 | 15 021,604 |
|   Flush Session              |     1 | 13 584,654 | 13 584,654 | 13 584,654 | 13 584,654 |
|   Add 10000 reservations     |     1 |  1 423,281 |  1 423,281 |  1 423,281 |  1 423,281 |
|     Add 1 Reservation        | 10000 |      0,133 |      0,106 |     22,735 |  1 326,291 |
|------------------------------|-------|------------|------------|------------|------------|
Total : 15 021 ms

On remarque que le flush est effectué une seule fois, à la fin du traitement et qu’il prend plus de 12 secondes à lui tout seul ! Cette méthode présente un inconvénient majeur. L’utilisation de la mémoire n’est pas maîtrisée et on risque une OutOfMemoryError. En effet, toutes les entités sont conservées dans le cache de niveau 1 d’Hibernate (la session) et ne sont supprimées qu’a la fin de la session.
Avec 48 Mo de mémoire, le même test échoue avec un beau OutOfMemoryError.

Impact du nombre d’entités en session

J’en profite pour faire un aparté sur le fonctionnement d’Hibernate. En interne, pour optimiser ses traitements (flush, regroupement d’instruction, …), Hibernate analyse régulièrement sa session et plus il y’a d’objets dans la session, plus l’utilisation interne d’Hibernate est coûteuse.
Le test suivant montre les temps d’exécution du code précédant, alors que plusieurs milliers d’entités sont déjà présents dans la session

|-----------------------------------|-------|------------|------------|------------|------------|
|         Measurement Point         |   #   |   Average  |     Min    |     Max    |    Total   |
|-----------------------------------|-------|------------|------------|------------|------------|
| Nominal Test - Pre-loaded session |     1 | 25 792,565 | 25 792,565 | 25 792,565 | 25 792,565 |
|   Pre-load objects in session     |     1 |     68,448 |     68,448 |     68,448 |     68,448 |
|   Add 10000 reservations          |     1 |  1 525,327 |  1 525,327 |  1 525,327 |  1 525,327 |
|     Flush Session                 |     1 | 23 893,634 | 23 893,634 | 23 893,634 | 23 893,634 |
|     Add 1 reservation             | 10000 |      0,126 |      0,104 |     18,338 |  1 263,967 |
|-----------------------------------|-------|------------|------------|------------|------------|
Total : 25 724 ms (25 792ms – 68ms)

Dans ce cas, le même code met 66% de temps en plus pour s’exécuter ! Cependant la valeur n’est pas représentative du fait de l’interférence avec les autres facteurs du test et notamment la mémoire.

Vidage manuelle de la session

La méthode la plus simple pour remédier à ces problèmes est d’effectuer après chaque insertion un flush session.flush() et un nettoyage de la session session.clear().
Attention, le nettoyage de la session n’est possible que si aucun objet déjà présent en session n’est utilisé dans la suite du traitement. Pour ce genre de problème, il est possible d’utiliser Session.evict() pour détacher un objet précis de la session. Voici les nouvelles mesures :

|---------------------------|-------|-----------|-----------|-----------|-----------|
|     Measurement Point     |   #   |  Average  |    Min    |    Max    |   Total   |
|---------------------------|-------|-----------|-----------|-----------|-----------|
| Nominal Test - Flush Unit |     1 | 9 777,319 | 9 777,319 | 9 777,319 | 9 777,319 |
|   Add 10000 reservations  |     1 | 9 776,878 | 9 776,878 | 9 776,878 | 9 776,878 |
|     Add 1 reservation     | 10000 |     0,126 |     0,113 |     5,443 | 1 262,092 |
|     Clear & Flush Session | 10000 |     0,840 |     0,723 |   163,181 | 8 404,844 |
|       Flush Session       | 10000 |     0,805 |     0,697 |   163,140 | 8 053,038 |
|---------------------------|-------|-----------|-----------|-----------|-----------|
Total : 9 777 ms

On observe que le temps de flush et le temps d’insertion unitaire a fortement diminué. Pour autant, il est possible d’optimiser encore le traitement en faisant des flush moins réguliers, par exemple toutes les 250 opérations unitaires, ce qui nous donne cette fois :

|-------------------------------|-------|-----------|-----------|-----------|-----------|
|       Measurement Point       |   #   |  Average  |    Min    |    Max    |   Total   |
|-------------------------------|-------|-----------|-----------|-----------|-----------|
| Nominal Test - Flush Interval |     1 | 8 904,851 | 8 904,851 | 8 904,851 | 8 904,851 |
|   Add 10000 reservations      |     1 | 8 904,113 | 8 904,113 | 8 904,113 | 8 904,113 |
|     Add 1 reservation         | 10000 |     0,118 |     0,105 |     9,580 | 1 184,427 |
|     Clear & Flush Session     |    40 |   190,863 |   178,202 |   341,430 | 7 634,537 |
|       Flush Session           |    40 |   190,746 |   178,100 |   341,319 | 7 629,834 |
|-------------------------------|-------|-----------|-----------|-----------|-----------|
Total : 8 904 ms

On obtient donc un traitement de 8,9s, soit un gain d’environ 40% par rapport au traitement initial.

Gestion personnalisée du flush

Une autre façon d’optimiser le traitement est d’intervenir sur le FlushMode d’Hibernate.
Par défaut Hibernate flush de façon automatique, au moment qui lui semble opportun pour assurer le bon déroulement du traitement.
Il est possible de changer ce comportement par défaut :

On peut désactiver le flush automatique et demander que le flush ne s’effectue qu’au commit de la transaction. Pour cela on indique à la session d’utiliser le mode de flush (FlushMode.COMMIT et en JPA FlushModeType.COMMIT).

Une autre solution est de désactiver complètement le flush automatique et d’indiquer à Hibernate que le flush sera effectué de façon manuelle, uniquement sur demande (FlushMode.MANUAL, pas d’équivalence en JPA).

Ces deux modes de flush optimisent les traitements en intervenant sur le fonctionnement interne d’Hibernate, car celui-ci ne vérifie plus l’état des objets en session pour savoir s’ils doivent être flushés ou non en fonction du type de requête traitée.
Attention, en changeant le mode de flush, vos modifications en cours ne sont pas persistées en base automatiquement, donc toutes les requêtes effectuées ramènent les valeurs en base. Il faut donc appeler le flush avant de lire des données en cours de modifications.
Avec le flush mode en manual, on obtient les résultats suivants :

|----------------------------------|-------|------------|------------|------------|------------|
|         Measurement Point        |   #   |   Average  |     Min    |     Max    |    Total   |
|----------------------------------|-------|------------|------------|------------|------------|
| FlushMode Manual - Flush Default |     1 | 14 810,387 | 14 810,387 | 14 810,387 | 14 810,387 |
|   Flush Session                  |     1 | 13 376,280 | 13 376,280 | 13 376,280 | 13 376,280 |
|   Add 10000 reservations         |     1 |  1 414,282 |  1 414,282 |  1 414,282 |  1 414,282 |
|     Add 1 reservation            | 10000 |      0,129 |      0,105 |     23,002 |  1 288,071 |
|----------------------------------|-------|------------|------------|------------|------------|
Total : 14 810 ms

Les résultats montrent très peu de différences, mais ceci est dû au traitement effectué. Dans notre traitement, il n’y a pas de lecture en base, opération qui demande à Hibernate de vérifier l’état de ses objets en session pour flusher si nécessaire. Les gains ne sont pas visibles dans ce test mais existent réellement dans d’autres situations.

Session sans états

Hibernate propose également un type de session particulier, appelé session sans état (@@StatelessSession@) qui permet de manipuler les entités sans les rattacher à la session.
Ce type de session agit simplement comme une interface directe entre vos objets et votre base de données. Toutes les opérations effectuées (lecture, écriture) sont directement traitées et il n’est plus question de cache de niveau 1 et de problème mémoire.
Si ce type de session est rapide, il faut savoir qu’il y’a des limitations à son utilisation. Premièrement, les objets chargés n’étant pas rattachés à la session, le mécanisme de chargement paresseux (lazy-loading) n’existe pas et ce n’est pas si mal quand on sait que c’est souvent une source de mauvaises performances dans ce type de traitement.
Le plus gros inconvénient de cette session est qu’elle ne gère pas les relations de type *ToMany lors de l’insertion de données. Il faut donc passer par une requête en SQL natif pour insérer des nouveaux enregistrements dans ces cas précis.
Note, pour les utilisateurs de JPA, la session sans état ne fait pas partie de la norme, il faudra appeler directement le code Hibernate.
Une fois cette présentation terminée, voyons les temps obtenus avec la session sans état :

|---------------------------|-------|-----------|-----------|-----------|-----------|
|     Measurement Point     |   #   |  Average  |    Min    |    Max    |   Total   |
|---------------------------|-------|-----------|-----------|-----------|-----------|
| Stateless Session         |     1 | 8 214,875 | 8 214,875 | 8 214,875 | 8 214,875 |
|   Add 10000 reservations  |     1 | 8 214,194 | 8 214,194 | 8 214,194 | 8 214,194 |
|     Add 1 reservation     | 10000 |     0,806 |     0,686 |    26,316 | 8 057,380 |
|---------------------------|-------|-----------|-----------|-----------|-----------|
Total : 8 214 ms

Les temps mesurés sont légèrement en dessous des meilleurs temps obtenus lors de l’utilisation de la session « classique », mais il faut dans notre cas passer par l’écriture de requête SQL native. Je dirai que l’utilisation de la session sans état est fortement conseillé si vous ne faites que de la lecture. Pour les traitements avec des insertions, cela dépend de la complexité de votre modèle de données et de votre besoin.

Schéma récapitulatif

Le schéma suivant récapitule les différents temps obtenus en fonction du scénario utilisé et de la mémoire définie au niveau de la JVM.
graph
On remarque qu’avec beaucoup de mémoire, on obtient des temps très similaires dans nos tests et qu’avec 48 Mo de mémoire, l’utilisation standard de la session ne permet pas d’aller au bout du test, à cause d’un OutOfMemoryError.

Conclusion

S’il y’a une chose à retenir de cet article, c’est que dans les problématiques de traitement de masse, il est impératif de maîtriser l’utilisation de la mémoire en passant pas une gestion manuelle de la session.
Si on voulait aller plus loin, il y’a beaucoup d’autres paramètres sur lesquels on peut agir pour optimiser les performances.
Il y’a bien sûr le cache de niveau 2 et cache de requête, mais aussi les modes de récupération de données (FetchMode ou FectType en JPA), le traitement par batch, les récupérations de données en mode lecture seule (voir LockMode), l’utilisation des références en JPA (EntityManager.getReference()), l’utilisation de DTO (DataTransfertObject), …
Malgré tous ces facteurs à prendre en compte, le plus important est de comprendre comment vous utilisez la session et quelles sont les requêtes que vous générez (attention au lazy-loading). J’espère que cet article vous aura permis d’en apprendre un peu plus sur le fonctionnement de la session et sur les impacts sur les performances de votre application.
Dans un prochain billet, je m’intéresserai à l’utilisation de JPA avec Hibernate.
D’ici là, bon développement avec Hibernate.

Une réflexion sur “Gestion de session avancée avec Hibernate

  • 5 mars 2011 à 1 h 58 min
    Permalien

    Hello,

    J’ai eu moi aussi le problème de cache L1 qui explosait lors de la génération avec des entités JPA (+OpenSessionInView) d’un fichier d’export CSV/XLS.
    Un evict + pagination a bien fait l’affaire en effet 🙂
    Mais ça m’a juste posé un problème de conscience d’appeler un evict depuis la couche vue…
    On fait comme on peut avec l’existant parfois…

    Répondre

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

%d blogueurs aiment cette page :