Anti pattern Hibernate
J'ai trouvé à plusieurs reprises lors d'audits d'optimisation de performances des soucis liés à une mauvaise utilisation d'hibernate, d'où le nom d'antipattern d'utilisation d'Hibernate.
Contexte
Utilisation d'Hibernate en Java (et .Net je suppose). On cherche à accéder à un objet en Java via sa clé primaire. La requête est effectuée via une Query Hibernate. Exemple de code:
Query tQuery =getSession().createQuery("from ParamCourtierPo where codeBanque=:codeBanque");
tQuery.setInteger("codeBanque", pParamCodeBanque);
ParamCourtierPo param = (ParamCourtierPo) tQuery.uniqueResult();
Problème
Que ce passe-t-il lors de l'exécution du code précédent ? Dans ce cas... Lors de l'exécution d'une Query hibernate va toujours effectuer requête Sql nécessaire pour exécuter votre recherche et charger celle-ci dans un objet. Ensuite, Hibernate va comparer (via la clé primaire) si ces objets sont déjà présents dans le cache de niveau 1 (Session) ou de niveau 2 (partagé) si vous avez défini un cache de niveau 2. Si un objet identique est trouvé, alors Hibernate l'utilisera, et le nouvel objet chargé dans la base sera ignoré. Vous aurez fait la requête Sql pour rien.
Le risque de voir cet antipattern augmente si les classes ont des clés fonctionnelles à la place des techniques.
Solution
Ne pas utiliser de Query à la place de Session.load(thisPrimaryKey) ou de Session.get(thisPrimaryKey) pour charger des objets par leur clé primaire, ces méthodes sont là pour ça. Dans ces cas Hibernate commence par rechercher votre objet (grace à la primary Key) dans le(s) cache, et c'est seulement s'il n'y est pas trouvé qu'il fera un accès à la base.
Exemple rencontré lors d'un audit : Sur un des audit, la soumission d'un formulaire réalisait 1000 requêtes sql pour son exécution. Sur ces 1000, 944 requêtes (exécutées par hibernate) chargeaient en fait les 3 mêmes objets Java, même avec du cache (niveau 1 et 2). L'utilisation de load ou get a permis de supprimer 941 requêtes sur la base.
Limitation
Il existe des cas où l'on préfère perdre quelques objets (ie recharger des objets déjà présents dans le cache), par exemple si vous chargez des objets en masse. Imaginons que notre contrat est lié à une liste de bénéficiaires. On va tout d'abord charger le contrat puis ses bénéficiaires. Vaut-il mieux charger ceux-ci via un load (ou un get) pour améliorer le scoring du cache ou faire une requête de masse, quitte à ramener 50% de déchet (ie 50% d'objets déjà chargés) ? Ce sera à voir au cas par cas en fonction de la probabilité de trouver les objets en cache et à leurs tailles/complexités. Si j'ai un score de 5% sur mon cache, il vaudra mieux faire une requête de masse, avec 95% je préférerai plutôt faire plusieurs petits voyages (pour les 5% manquant). Entre les 2... à vous de le découvrir sur vos projets.
Information complémentaire sur les load et les get
Dans quel cas utiliser Session.load(thisPrimaryKey) ou Session.get(thisPrimaryKey) ? En effet dans les 2 cas, ces API permettent d'obtenir une instance d'un objet depuis la base grâce à sa clé primaire. En utilisant l'API load, vous aurez l'impression d'avoir déjà l'instance (en fait un proxy) de votre objet, en fait avec un debugger vous pourrez vous rendre compte qu'aucun champ (autre que la clé primaire) n'est chargé dans votre instance, ce n'est que lorsque vous accéderez à un champ que la requête sera réellement effectuée sur la base. Avec un get l'objet est réellement chargé lors de l'appel de la méthode.
A quoi sert de faire un load ? Si je charge des objets c'est pour m'en servir !
Et, bien pas toujours... Imaginons que vous liez un contrat à un type de contrat via une relation 1 => n. Vous avez déjà chargé le contrat et vous créez la relation à un type de contrat ... Si vous utilisez un load, le type de contrat ne sera pas chargé car en fait Hibernate se rendra compte que vous avez juste enrichi la relation et qu'il n'a pas besoin d'avoir le type de contrat pour mettre à jour la base. En effet, dans ce cas c'est le contrat qui porte la relation via une clé étrangère. Il a juste besoin d'avoir la clé primaire du type de contrat pour enregistrer la création de la relation en base, et le proxy a déjà cette information.
Ceci est vrai chaque fois que le proxy ne porte pas la relation (en base de données) et que la mise à jour ne porte que sur cette relation. C'est donc également vrai pour les relations n=>n via une table de lien intermédiaire.
Pourquoi avoir une API get alors si load est plus efficace ? Pour contrôler le moment où est exécutée la requête. Recevoir une exception " Table Not Found " est toujours surprenant lorsqu'on accède à une chaine via l'accesseur d'un objet...
La principale raison est donc : si vous voulez tester l'existante d'un objet en base de donnée. Compte tenu de ce que j'ai expliqué un load vous reverra toujours une instance même si elle n'existe pas en base. Les ennuis commenceront quand vous vous en servirez...
On devrait toujours accéder à un objet par un load lorsque l'on est certain de l'existence (la plupart du temps) d'un objet en base.
Démonstration par JUnit : il suffit de lancer la classe de test suivante, pour pouvoir la lancer il faut avoir une dépendance sur JMonitoring. Voici les liens pour télécharger le jar et les dépendances. JMonitoring est un framework opensource réalisé pour conduire des missions d'audit de performance d'application Java et qui a permis de détecter cet antipattern.
Si vous ne souhaitez pas exécuter ces tests, il suffit de les lire en sachant qu'ils passent :-)
package com.octo.temp;
import org.hibernate.Query;
import org.hibernate.stat.Statistics;
import org.jmonitoring.core.dao.ExecutionFlowDAO;
import org.jmonitoring.core.dao.PersistanceTestCase;
import org.jmonitoring.core.dao.TestExecutionFlowDAO;
import org.jmonitoring.core.persistence.ExecutionFlowPO;
public class TestLoadAndGet extends PersistanceTestCase
{
public void testAvecNameQuery()
{
ExecutionFlowDAO tFlowDAO = new ExecutionFlowDAO(getSession());
ExecutionFlowPO tFlow = TestExecutionFlowDAO.buildNewFullFlow();
assertEquals(1, tFlowDAO.insertFullExecutionFlow(tFlow));
getSession().flush();
getSession().clear();
getSession().getSessionFactory().getStatistics().clear();
Query tQuery = getSession().createQuery("from ExecutionFlowPO where Id=:Id");
tQuery.setInteger("Id", 1);
ExecutionFlowPO tPo = (ExecutionFlowPO) tQuery.uniqueResult();
assertNotNull(tPo);
Statistics tStats =getSession().getSessionFactory().getStatistics();
assertEquals(1, tStats.getEntityLoadCount());
assertEquals(0, tStats.getEntityFetchCount());
assertEquals(1, tStats.getQueryExecutionCount());
assertEquals(1, tStats.getPrepareStatementCount());
tPo = (ExecutionFlowPO) tQuery.uniqueResult();
assertNotNull(tPo);
tStats =getSession().getSessionFactory().getStatistics();
assertEquals(1, tStats.getEntityLoadCount());
assertEquals(0, tStats.getQueryCacheHitCount());
assertEquals(0, tStats.getEntityFetchCount());
assertEquals(2, tStats.getQueryExecutionCount());
assertEquals(2, tStats.getPrepareStatementCount());
}
public void testAvecLoad()
{
ExecutionFlowDAO tFlowDAO = new ExecutionFlowDAO(getSession());
ExecutionFlowPO tFlow = TestExecutionFlowDAO.buildNewFullFlow();
assertEquals(1, tFlowDAO.insertFullExecutionFlow(tFlow));
getSession().flush();
getSession().clear();
getSession().getSessionFactory().getStatistics().clear();
ExecutionFlowPO tPo = (ExecutionFlowPO) getSession().get(ExecutionFlowPO.class,
new Integer(1));
assertNotNull(tPo);
Statistics tStats =getSession().getSessionFactory().getStatistics();
assertEquals(1, tStats.getEntityLoadCount());
assertEquals(0, tStats.getEntityFetchCount());
assertEquals(0, tStats.getQueryExecutionCount());
assertEquals(1, tStats.getPrepareStatementCount());
tPo = tPo = (ExecutionFlowPO) getSession().get(ExecutionFlowPO.class,
new Integer(1));
assertNotNull(tPo);
tStats =getSession().getSessionFactory().getStatistics();
assertEquals(1, tStats.getEntityLoadCount());
assertEquals(0, tStats.getQueryCacheHitCount());
assertEquals(0, tStats.getEntityFetchCount());
assertEquals(0, tStats.getQueryExecutionCount());
assertEquals(1, tStats.getPrepareStatementCount());
}
}