Aller au contenu
BackJavaquarkusCache

L'ardoise magique au-dessus du comptoir : utilisation du cache dans Quarkus

À "The Falling Whale", le tavernier descend à la cave à chaque demande du menu du jour. Jusqu'à l'arrivée d'une ardoise magique. À travers cette histoire, découvrez comment Quarkus simplifie la mise en cache avec quelques annotations et améliore instantanément les performances.

Utilisation du cache dans Quarkus

Il est 19h, à The Falling Whale. La salle commence à se remplir, et comme chaque soir, la même scène se répète : un aventurier s'accoude au comptoir et demande

"C'est quoi le menu du jour ?"

Le tavernier soupire, attrape sa lanterne, et descend les marches grinçantes vers la cave pour aller consulter le grand livre des recettes.
Deux minutes plus tard, il remonte, hors d'haleine, pour annoncer fièrement :

"Ragoût de sanglier et pain de seigle !"

Le problème, c'est que le client suivant va demander exactement la même chose. Et le tavernier va redescendre.
Et remonter.
Toute la soirée.
Pour un menu qui ne change pourtant qu'une fois par jour.

Heureusement, un mage de passage (probablement encore un vétéran de la Guerre des Frameworks) a un jour offert au tavernier une ardoise magique, suspendue juste au-dessus du comptoir.
Le principe est simple : la première fois qu'on lui demande le menu d'un jour, le tavernier descend à la cave comme d'habitude, mais il note la réponse sur l'ardoise avant de répondre.
La fois suivante, plus besoin de bouger : il lève les yeux, lit l'ardoise, et répond instantanément.

Cette ardoise magique, dans notre taverne, porte un nom bien moins poétique dans le monde réel : le cache.
Et Quarkus, via l'extension quarkus-cache, nous permet de l'accrocher au mur en quelques annotations.

Accrocher l'ardoise au mur

Avant que la magie ne puisse opérer, il faut d'abord accrocher l'ardoise au-dessus du comptoir. Dans le monde de Quarkus, ça se résume à une dépendance Maven à ajouter au pom.xml :

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-cache</artifactId>
</dependency>

la dépendance à ajouter

C'est tout. Aucune configuration obligatoire, aucun serveur à démarrer, aucun service externe à provisionner. L'extension embarque par défaut un moteur de cache en mémoire : Caffeine, une bibliothèque Java réputée pour ses performances et sa gestion fine de l'éviction (LRU, expiration, taille maximale...). C'est précisément ce moteur qui interprète les propriétés quarkus.cache.caffeine.* qu'on configurera plus loin dans l'article.

Concrètement, cela signifie que l'ardoise magique de notre tavernier n'est rien d'autre qu'une Map sophistiquée, entretenue directement dans le tas mémoire de l'application. Tant qu'on n'a qu'un seul tavernier (une seule instance de l'application).
C'est redoutablement simple et rapide : pas d'aller-retour réseau, pas de sérialisation.
La contrepartie, qu'on abordera en conclusion, c'est que cette ardoise reste strictement personnelle au tavernier qui la possède.

Le problème : descendre à la cave à chaque commande

Avant de parler magie, parlons du mal qu'elle soigne. Dans notre taverne, la cave est représentée par `CaveRepository`. C'est notre "grand livre des recettes", en réalité une simple Map statique, mais qui simule volontairement une opération coûteuse :

public List<Plat> consulterGrandLivre(String jour) {
    LOG.infof("📖 Le tavernier descend à la cave consulter le grand livre pour : %s", jour);
    simulerDelaiCave();
    return Optional.ofNullable(GRAND_LIVRE.get(jour.toLowerCase()))
            .orElse(List.of());
}

Les 200 millisecondes de Thread.sleep ne sont pas là pour faire joli : elles représentent ce que coûterait, en production, une vraie requête SQL, un appel à un service externe, ou n'importe quelle opération dont le résultat ne change pas à chaque appel mais qu'on continue, par réflexe, à recalculer systématiquement.
Multipliez ça par des centaines d'aventuriers affamés et vous comprenez vite pourquoi le tavernier a les mollets fatigués.

Inscrire l'ardoise : @CacheResult

C'est dans MenuService que la magie opère. Le tavernier n'interagit jamais directement avec la cave : il passe systématiquement par son ardoise.

@CacheResult(cacheName = ARDOISE)
public List<Plat> obtenirMenuDuJour(String jour) {
    LOG.infof("🍽️  L'ardoise ne connaît pas encore le menu de %s. Descente à la cave...", jour);
    return caveRepository.consulterGrandLivre(jour);
}

@CacheResult fait exactement ce que ferait un tavernier consciencieux : avant d'exécuter la méthode, Quarkus regarde si une entrée existe déjà dans le cache nommé ardoise-menu pour la clé correspondant aux paramètres de l'appel, ici le jour. Deux cas se présentent :

  • Cache hit : l'ardoise connaît déjà la réponse. Le corps de la méthode n'est même pas exécuté. Le tavernier ne descend pas à la cave, il répond du tac au tac.
  • Cache miss : rien n'est inscrit. La méthode s'exécute normalement (le LOG.infof s'affiche, la descente à la cave a lieu), et le résultat est automatiquement noté sur l'ardoise avant d'être renvoyé.

Le principal avantage de cette annotation, c'est sa discrétion : le code de obtenirMenuDuJour ne sait absolument pas qu'il est mis en cache.
Pas de if (cache.contains(jour)), pas de logique de stockage manuelle. Quarkus s'en occupe via un intercepteur CDI, exactement comme il le fait pour @Transactional ou @Retry. C'est ce qu'on pourrait appeler la magie propre, celle qui ne salit pas le code métier.

Petit détail qui a son importance : la clé de cache est dérivée automatiquement des paramètres de la méthode annotée. "lundi" et "mardi" produisent donc deux entrées distinctes sur l'ardoise : chaque jour a sa propre ligne.

Effacer l'ardoise : @CacheInvalidate et @CacheInvalidateAll

Une ardoise magique, ce n'est utile que si elle reflète la réalité. Si le chef décide en plein service de remplacer le ragoût de sanglier par autre chose, il faut un moyen d'effacer l'ancienne note.

@CacheInvalidate(cacheName = ARDOISE)
public void effacerMenuDuJour(String jour) {
    LOG.infof("🧽 Le tavernier efface %s de l'ardoise.", jour);
}

@CacheInvalidate cible une entrée précise (celle correspondant au paramètre jour) et l'efface de l'ardoise. Le prochain client qui demandera ce jour-là forcera une nouvelle descente à la cave, et donc une réinscription à jour.

Et pour les grands ménages de début de semaine, il y a l'option boule de feu :

@CacheInvalidateAll(cacheName = ARDOISE)
public void effacerTouteArdoise() {
    LOG.info("🧹 Le tavernier efface toute l'ardoise pour une nouvelle semaine.");
}

@CacheInvalidateAll ne prend aucun paramètre de clé : elle efface l'ardoise dans son intégralité, tous jours confondus. Pratique pour repartir sur une base propre, par exemple un lundi matin avant l'ouverture.

On notera que ces deux méthodes n'ont pas de logique métier propre, leur corps ne sert qu'à logger l'action. Toute la mécanique d'invalidation est portée par l'annotation elle-même.

Le comptoir : exposer l'ardoise aux aventuriers

MenuResource ne fait qu'orchestrer ces trois opérations derrière une API REST classique.
Rien de magique ici, c'est volontaire : la magie doit rester dans le service, pas se répandre dans la couche HTTP.

@GET
@Path("/{jour}")
public Response consulterArdoise(@PathParam("jour") String jour) {
    List<Plat> menu = menuService.obtenirMenuDuJour(jour);
    if (menu.isEmpty()) {
        return Response.status(Response.Status.NOT_FOUND)
                .entity("Le grand livre ne connaît pas le menu du jour : " + jour)
                .build();
    }
    return Response.ok(menu).build();
}

Trois routes, trois usages : un GET /menu/{jour} pour consulter (et déclencher l'effet cache hit/miss), un DELETE /menu/{jour} pour invalider un jour précis, et un DELETE /menu pour tout réinitialiser.
La documentation OpenAPI (@Operation, @APIResponse) profite d'ailleurs de cette occasion pour raconter elle-même la métaphore : un aventurier qui consulte Swagger UI comprend immédiatement pourquoi le premier appel met 200 ms et les suivants non.

Régler la taille et la mémoire de l'ardoise

Une ardoise, même magique, n'est pas infinie.
C'est dans application.properties que l'on définit ses limites, via le moteur de cache par défaut de Quarkus, Caffeine :

quarkus.cache.caffeine."ardoise-menu".maximum-size=100
quarkus.cache.caffeine."ardoise-menu".expire-after-write=10M

maximum-size fixe le nombre maximal d'entrées distinctes que l'ardoise peut contenir , ici 100 menus, ce qui est largement suffisant pour 5 jours de la semaine, mais qui illustre bien qu'au-delà de la limite, Caffeine applique une éviction LRU (Least Recently Used) : les entrées les moins récemment consultées sont effacées en premier pour faire de la place aux nouvelles.

expire-after-write ajoute un seconde garde-fou : même sans jamais atteindre la limite de taille, chaque entrée s'efface automatiquement 10 minutes après son inscription. Cela évite qu'un menu reste affiché sur l'ardoise pendant des jours si personne ne pense à appeler effacerMenuDuJour.
C'est un filet de sécurité contre l'oubli, y compris celui du tavernier lui-même.

La démo : deux appels, un monde de différence

Le meilleur moyen de constater l'effet de l'ardoise reste de regarder les logs. Et la bonne nouvelle, c'est que tout ce qu'il faut pour observer le phénomène est déjà en place dans le code qu'on vient de parcourir, pas besoin d'instrumenter quoi que ce soit en plus, à condition de savoir où regarder.

Trace n°1 : dans CaveRepository.consulterGrandLivre()

LOG.infof("📖 Le tavernier descend à la cave consulter le grand livre pour : %s", jour);

C'est la trace la plus parlante : elle ne s'affiche que lorsqu'il y a réellement un accès à la source de données. Si elle apparaît, c'est qu'on a eu un cache miss. Si elle est absente, c'est que l'ardoise a répondu seule.

Trace n°2 : dans MenuService.obtenirMenuDuJour()

LOG.infof("🍽️  L'ardoise ne connaît pas encore le menu de %s. Descente à la cave...", jour);

Cette trace confirme la précédente : comme elle se trouve dans le corps de la méthode annotée @CacheResult, elle ne s'exécute, elle aussi, qu'en cas de cache miss, par construction, @CacheResult court-circuite l'exécution de la méthode quand l'entrée est déjà sur l'ardoise.

Trace n°3 et 4 : dans effacerMenuDuJour() et effacerTouteArdoise()

LOG.infof("🧽 Le tavernier efface %s de l'ardoise.", jour);
LOG.info("🧹 Le tavernier efface toute l'ardoise pour une nouvelle semaine.");

Ces deux traces, elles, s'affichent systématiquement à chaque appel des routes DELETE, puisque l'invalidation n'a pas de notion de hit/miss, elle agit, point. Elles servent surtout de repère temporel dans les logs : "à partir de cette ligne, l'ardoise a été effacée, le prochain GET doit redescendre à la cave".

Pour que ces traces remontent bien dans la console, il faut s'assurer que le logger du package est au niveau INFO : c'est justement le rôle de cette ligne dans application.properties :

quarkus.log.category."fr.eletutour".level=INFO

Le scénario à rejouer

Avec ces quatre traces en tête, voici la séquence d'appels à faire pour démontrer le comportement de bout en bout :

Appel Résultat attendu dans les logs Temps de réponse
GET /menu/lundi (1er appel) Traces 📖 et 🍽️ visibles ~200 ms
GET /menu/lundi (2e appel) Aucune trace < 1 ms
GET /menu/lundi (3e, 4e... appel) Aucune trace < 1 ms
DELETE /menu/lundi Trace 🧽 visible < 1 ms
GET /menu/lundi (après suppression) Traces 📖 et 🍽️ de nouveau visibles ~200 ms
DELETE /menu Trace 🧹 visible < 1 ms
GET /menu/mardi (alors qu'on n'avait touché qu'à lundi) Traces 📖 et 🍽️ visibles ~200 ms

La dernière ligne du tableau vaut la peine d'être testée explicitement : elle prouve que chaque jour constitue bien une entrée de cache distincte, et que @CacheInvalidateAll n'a pas fait de favoritisme.

Premier appel sur /menu/lundi :

📖 Le tavernier descend à la cave consulter le grand livre pour : lundi
🍽️  L'ardoise ne connaît pas encore le menu de lundi. Descente à la cave...

(réponse en ~200 ms)

Deuxième appel, immédiatement après :

(silence radio)

(réponse en moins d'1 ms)

Aucune ligne de log, aucune descente à la cave : la méthode obtenirMenuDuJour n'a même pas été exécutée.
L'ardoise a fait tout le travail. Un DELETE /menu/lundi plus tard, et le cycle recommence, la prochaine requête redéclenche la descente à la cave, preuve que l'invalidation fonctionne comme prévu.

Quand ne pas utiliser le cache

Une ardoise magique, ça donne des idées : pourquoi ne pas en accrocher une au-dessus de chaque comptoir de la taverne ? Parce que le cache n'est pas un accélérateur universel. Il repose sur une hypothèse simple : pour une même demande, on s'attend à obtenir le même résultat pendant un certain temps. Dès que cette hypothèse ne tient plus, l'ardoise devient un problème plutôt qu'une solution.

Voici les situations où il vaut mieux laisser le tavernier descendre à la cave.

Quand la donnée change plus vite qu'on ne la consulte

Le menu du jour est un bon candidat au cache parce qu'il ne change, en moyenne, qu'une ou deux fois par service. Mais imaginons un tableau affichant le nombre de places restantes à table en temps réel : si on le mettait derrière un @CacheResult avec une expiration de dix minutes, des aventuriers continueraient à se voir annoncer une place libre... alors que la salle est pleine depuis longtemps.

Le cache est fait pour des données qui se répètent, pas pour des données qui évoluent en permanence.

Quand le résultat dépend d'un contexte que la clé ne capture pas

Imaginons que obtenirMenuDuJour se mette un jour à filtrer les plats selon les allergies du client connecté, sans que cette information ne fasse partie des paramètres de la méthode. Le premier aventurier allergique aux noisettes verrait son menu adapté et mis en cache... et le client suivant, parfaitement tolérant, hériterait du même menu tronqué.

La règle est simple : tout ce qui influence le résultat doit faire partie de la clé de cache. Dans le cas contraire, on risque de servir la mauvaise réponse au mauvais client.

Quand la donnée est propre à chaque utilisateur

Allons un peu plus loin. Si la taverne ajoute demain un système de comptes clients avec des additions personnalisées, mettre en cache « la note du client » avec une clé trop large (ou pire, sans inclure l'identité du client) pourrait conduire un aventurier à consulter l'addition d'un autre.

Dès qu'une donnée possède une dimension personnelle ou confidentielle, la composition de la clé mérite une attention particulière. Dans certains cas, il est même préférable de ne pas utiliser de cache du tout.

Quand la méthode a des effets de bord

@CacheResult peut complètement éviter l'exécution de la méthode lors d'un cache hit. Si obtenirMenuDuJour envoyait également une notification au chef pour l'informer qu'un menu a été consulté, cette notification disparaîtrait purement et simplement à partir du deuxième appel.

Le cache est conçu pour des méthodes qui lisent et renvoient une valeur. Il est beaucoup moins adapté à celles qui déclenchent des actions annexes.

Quand le calcul coûte moins cher que le cache

Parfois, le problème n'est pas la fraîcheur des données mais simplement l'absence de problème à résoudre. Si une méthode s'exécute en quelques microsecondes et ne sollicite ni base de données ni service externe, la mettre en cache apportera rarement un gain perceptible.

À l'inverse, cela ajoute de la complexité : consommation mémoire, gestion de l'expiration, invalidation et risque d'incohérence. Avant d'accrocher une ardoise au mur, il faut s'assurer que la descente à la cave est réellement coûteuse.

Quand l'objet à mettre en cache est volumineux

L'ardoise a une taille limitée, mais elle est surtout faite pour des notes courtes. Si chaque entrée représente plusieurs mégaoctets (un export PDF complet, une image ou une réponse d'API massive) la mémoire de l'application grimpera rapidement.

Dans ce cas, le bénéfice obtenu sur le temps de réponse peut être largement compensé par la pression exercée sur le garbage collector et par l'occupation mémoire. Le cache mémoire brille sur des objets relativement légers et fréquemment demandés, pas sur de grosses charges occasionnelles.

En définitive, la question à se poser reste la même que celle évoquée au début de l'article, mais à l'envers : est-ce que je risque de redonner une réponse obsolète, fausse ou inadaptée parce que j'ai voulu éviter une descente à la cave ?

Si la réponse est oui, mieux vaut un tavernier un peu essoufflé qu'une ardoise qui raconte n'importe quoi.

Pour la route

Si on devait résumer la philosophie du cache à la sauce Falling Whale : ne refaites jamais un travail dont vous connaissez déjà le résultat.
C'est aussi bête que ça, et c'est précisément ce qui rend quarkus-cache aussi agréable à utiliser, quelques annotations suffisent à transformer un tavernier épuisé en tavernier zen, sans toucher à la logique métier ni introduire de dépendance lourde à gérer soi-même.

Et si la taverne ouvrait une seconde salle ?

Tout ce qu'on vient de voir repose sur une hypothèse confortable : un seul tavernier, une seule ardoise. Mais imaginons que The Falling Whale connaisse un succès retentissant et doive ouvrir une seconde salle, avec un second tavernier, pour absorber l'afflux d'aventuriers. Chaque tavernier aurait alors sa propre ardoise, accrochée à son propre mur. Si le chef change le menu du lundi et qu'on invalide l'ardoise de la salle 1, celle de la salle 2 continuera tranquillement à afficher l'ancien menu, personne n'aura prévenu le second tavernier.

C'est exactement la limite d'un cache en mémoire locale comme Caffeine dès qu'une application tourne sur plusieurs instances : chaque instance a sa propre copie du cache, sans aucune synchronisation entre elles. Pour résoudre ce problème, Quarkus permet de remplacer le moteur Caffeine par un moteur de cache distribué, où toutes les instances partagent la même ardoise.
Le candidat le plus naturel pour ce rôle est Redis, via l'extension quarkus-redis-cache : on y gagne une ardoise unique, visible par tous les taverniers de tous les établissements, au prix d'un aller-retour réseau et d'un service supplémentaire à faire tourner.

De quoi nourrir une prochaine veillée au comptoir : transformer notre ardoise locale en grande ardoise commune, partagée par toute la guilde des taverniers.


Tout le code relatif à cet article est consultable ici :

GitHub - ErwanLT/quarkus-demo: Demo project for quarkus possibility
Demo project for quarkus possibility. Contribute to ErwanLT/quarkus-demo development by creating an account on GitHub.

Dernier