Cache de second niveau d’Hibernate

Les différents caches d’Hibernate

Hibernate fonctionne avec deux niveaux de cache.

Le cache de premier niveau est lié à la session Hibernate, et les objets qui sont cachés ne sont visibles que pour une seule transaction. Quand la session est fermée, le cache n’a plus d’existence. Ce cache ne peut pas être désactivé.

Le cache de second niveau est quant à lui lié à la session factory d’Hibernate. La portée de ce cache est la JVM, voire le cluster, où l’application est déployée. Les objets cachés sont donc visibles depuis l’ensemble des transactions. Par défaut, ce cache n’est pas activé. Une configuration est donc nécessaire, afin de l’activer.

Il existe enfin un cache de requête qui permet de cacher les résultats des requêtes exécutées.

Principe du cache de second niveau

Le cache de second niveau permet de cacher des entités, ainsi que des associations de type collection. Il convient donc de sélectionner les classes et les collections à cacher, puis de configurer les fichiers de mapping correspondant.

Gestion du cache des entités

La configuration consiste à ajouter l’élément <cache> dans le fichier de mapping, juste avant l’élément <id>. Il convient aussi de choisir une stratégie transactionnelle, sur laquelle nous reviendrons plus tard (attribut usage).

 <class name="Produit" table="PRODUIT">
   <cache usage="read-only"/>
   <id name="id" column="ID">
     <generator class="identity"/>
   </id>
   <property name="code" column="CODE"/>
   <property name="libelle" column="LIBELLE"/>
   <many-to-one name="categorie" column="CATEGORIE_ID" class="Categorie"/>
 </class>

L’exemple ci-dessus permet de cacher les entités de type Produit lorsqu’elles sont chargées depuis la base de données. En fait, ce ne sont pas les instances Java issues du requêtage qui sont placées directement dans le cache, mais une copie de ces instances. En effet, Hibernate utilise un mécanisme de sérialisation rapide pour la mise en cache d’un objet, afin d’éviter que plusieurs transactions ne possèdent une référence sur le même objet, ce qui occasionnerait des problèmes d’accès concurrents. Par ailleurs, une fois placées en cache, les entités sont récupérées du cache à partir de leur identifiant. Ce dernier est donc utilisé comme index lors de l’ajout d’une entité dans le cache.

Prenons l’exemple de 3 produits récupérés dans une transaction T1 :

+--------------------------------+
|             Cache des Produits |
+--------------------------------+
|  7 -> [ "P007", "Tomate",  9 ] |
| 10 -> [ "P010", "Celeri",  9 ] |
| 12 -> [ "P012", "Pomme" , 13 ] |
+--------------------------------+

Remarque : l’association many-to-one Produit → Categorie est cachée sous la forme de l’identifiant de la catégorie, pas l’objet Categorie associé (dernière colonne : id=9 ⇒ catégorie "Légume" / id=13 ⇒ catégorie "Fruit").

Ces produits n’étaient pas présents dans le cache avant T1, et donc 3 select ont été exécutés (on suppose que T1 effectue un requêtage par identifiant avec session.get() par exemple) :

select * from produit where id = 7;
select * from produit where id = 10;
select * from produit where id = 12;

Si, pour chaque produit récupéré, T1 parcourt l’association Produit → Categorie (par exemple invoquant produit.getCategorie().getLibelle()), deux requêtes supplémentaires sont générées sur la table CATEGORIE.

select * from categorie where id = 9;
select * from categorie where id = 13;

Mais les deux instances de catégorie ne sont pas placées en cache de second niveau, puisque nous ne l’avons pas demandé dans le mapping :

 <class name="Categorie" table="CATEGORIE">
   <id name="id" column="ID">
     <generator class="identity"/>
   </id>
   <property name="code" column="CODE"/>
   <property name="libelle" column="LIBELLE"/>
   <set name="produits">
     <key column="CATEGORIE_ID"/>
     <one-to-many class="Produit"/>
 </class>

Lorsqu’une seconde transaction T2 charge un des produits placés en cache lors de T1 (avec la méthode session.get() par exemple), aucun select sur la table PRODUIT n’est généré. En revanche, si T2 parcourt l’association Produit → Categorie pour le produit P010, une requête est générée sur la table CATEGORIE :

select * from categorie where id = 9;

Gestion du cache des associations many

La configuration du cache des collections est effectuée dans le fichier de mapping, à l’aide de l’élément <cache>. Dans l’exemple ci-dessous, nous demandons à cacher la collection de produits associée à une catégorie :

 <class name="Categorie" table="CATEGORIE">
   <id name="id" column="ID">
     <generator class="identity"/>
   </id>
   <property name="code" column="CODE"/>
   <property name="libelle" column="LIBELLE"/>
   <set name="produits">
     <cache usage="read-only"/>
     <key column="CATEGORIE_ID"/>
     <one-to-many class="Produit"/>
 </class>

Ainsi, chaque fois qu’une association many est parcourue, Hibernate ajoutera en cache la liste des identifiants des entités associées (et non pas les objets associés), indexée par l’identifiant de l’objet parent. Les objets associés sont mis en cache uniquement si le cache d’entité est défini pour ces objets.

Dans l’exemple ci-dessous, la catégorie "Légume" (id=9) et la catégorie "Fruit" (id=13) sont chargées depuis la base de données par une transaction T1. Suite à l’accès à la collection de produits pour chaque catégorie, les associations sont placées en cache (id=7 ⇒ "Tomate" et id=10 ⇒ "Celeri" pour la catégorie "Légume" (id=9) / id=12 ⇒ "Pomme" pour la catégorie "Fruit" (id=13)).

+----------------------------------------------+
| Cache des associations Categorie -> Produits |
+----------------------------------------------+
|  9 -> [ 7, 10 ]                              |
| 13 -> [ 12 ]                                 |
+----------------------------------------------+

Les requêtes exécutées par T1 sont les suivantes:

select * from categorie where id = 9;
select * from categorie where id = 13;
select * from produit where categorie_id = 9;
select * from produit where categorie_id = 13;

Si une nouvelle transaction T2 charge la catégorie "Légume" (id=9) et accède aux produits associés, les requêtes suivantes sont exécutées :

1) Si cache d’entité pour Produit

select * from categorie where id = 9; (il n'y a pas de cache d'entité pour Categorie)

2) Si pas de cache d’entité pour Produit

select * from categorie where id = 9; (il n'y a pas de cache d'entité pour Categorie)
select * from produit where id = 7;
select * from produit where id = 10;

3) Si pas de cache d’association Categorie → Produit

select * from categorie where id = 9;
select * from produit where categorie_id = 9;

Conclusion : si on utilise un cache d’association, il est important de vérifier que le cache d’entité est activé pour les objets associés, sinon on risque de générer plus de requêtes que sans cache !

Les méthodes Hibernate qui exploitent le cache de second niveau

Session.load() et Session.get()

Les méthodes load() et get() interrogent toujours le cache avant d’émettre une requête vers la base de données.

Query.list() et Criteria.list()

La méthode list() n’utilise pas le cache de second niveau pour les objets sélectionnés par la requête. En revanche, Hibernate interroge le cache en ce qui concerne les objets associés. Par ailleurs, les objets sélectionnés sont placés dans le cache après l’exécution de la requête.

Query.iterate()

La méthode iterate() charge les instances une à une à partir de leur identifiant. Par conséquent, cette méthode interroge toujours le cache pour les objets sélectionnés, ainsi que pour les associations.

Les stratégies transactionnelles

Il existe quatre stratégies transactionnelles possibles pour le cache de second niveau (read-only, read-write, nonstrict-read-write, transactionnal).

La stratégie read-only

Cette stratégie permet de cacher des données en lecture seule. Si l’application tente de modifier des données read-only, une exception UnsupportedOperationException est levée par Hibernate. Par ailleurs, l’accès aux données du cache est synchronisé.

  • Pour les environnements single node : EHCache et OSCache.

  • Pour les environnements cluster : SwarmCache et JBossCache

La stratégie read-write

Cette stratégie permet de cacher des données en lecture / écriture. Lors d’opérations de mise à jour, les modifications sont répercutées dans le cache. Par ailleurs, l’accès aux données du cache étant synchronisé, les transactions sont assurées de lire les versions les plus à jour des données "committées" (ce niveau transactionnel se comporte comme un niveau d’isolation read committed). Généralement, cette stratégie ne convient pas aux environnements en cluster, car aucun des cache providers cités dans la documentation Hibernate ne gère le verrouillage des données dans un tel environnement. Il semble cependant que le produit Tangosol Coherence gère correctement la stratégie read-write au sein d’un cluster (note: Tangosol Coherence est désormais un produit Oracle).

  • Pour les environnements single node : EHCache et OSCache.

  • Pour les environnements cluster : Oracle Coherence

La stratégie nonstrict-read-write

Cette stratégie permet de cacher des données qui sont modifiées occasionnellement. L’accès aux données n’est pas synchronisé, ce qui en fait la stratégie la plus rapide. Cependant, il n’est pas garanti que les transactions récupèrent les données les plus à jour, suite à des modifications.

Lors de mise à jour, les données sont simplement évincées du cache. Ce principe permet de synchroniser facilement les caches des différents noeuds d’un cluster par simple notification.
  • Pour les environnements single node : EHCache et OSCache.

  • Pour les environnements cluster : SwarmCache

Principe du cache des requêtes

Pour utiliser le cache de requête, il faut d’abord l’activer au niveau de la configuration Hibernate :

 <property name="hibernate.cache.use_query_cache">true</property>

Ensuite, on choisit de cacher les résultats d’une requête au moment de la création de la requête via la méthode setCacheable() sur un objet Query ou Criteria.

 Query query = session.createQuery("from Produit p where p.prix > :prix")
                      .setFloat("prix", 10f)
                      .setCacheable(true);
 List produits = query.list();

Après la première exécution de la requête, Hibernate cache les identifiants des entités récupérées par la requête (et non pas les entités elles-mêmes), les données cachées étant indexées par la chaîne de requêtes + les paramètres injectés.

+----------------------------------------------------------------+
|                                             Cache des requêtes |
+----------------------------------------------------------------+
| [ "from Produit p where p.prix > :prix", 10 ] -> [ 5, 12, 19 ] |
+----------------------------------------------------------------+

Activation du cache provider

Pour utiliser le cache de second niveau, il faut sélectionner un cache provider dans le fichier de configuration Hibernate. L’exemple suivante montre comment activer EHCache :

 <property name="hibernate.cache.provider_class">
   org.hibernate.cache.EhCacheProvider
 </property>