Cache avec Spring Boot : @Cacheable, Caffeine et données périmées

Publié le · Mis à jour le

L'idée en une phrase

Un cache garde en mémoire le résultat d'une opération lente (une requête en base, un appel à une API externe) pour le resservir instantanément la fois suivante. Avec Spring Boot, une annotation, @Cacheable, suffit. Mais un cache a toujours un prix : les données qu'il sert peuvent être périmées, et il consomme de la mémoire. Tout l'art consiste à choisir quoi mettre en cache et combien de temps.

Une image pour comprendre : les post-its du bibliothécaire

Un bibliothécaire reçoit sans arrêt la même question : « Quels sont les horaires d'ouverture du week-end ? ». À chaque fois, il descend aux archives chercher le règlement. Cinq minutes par réponse.

Au bout de la troisième fois, il note la réponse sur un post-it collé sur son bureau. La question suivante obtient une réponse immédiate.

Mais le post-it pose deux problèmes :

  • il peut devenir faux : si les horaires changent, le bibliothécaire continue de donner l'ancienne réponse, tant qu'il ne remplace pas le post-it ;
  • le bureau n'est pas infini : s'il colle un post-it pour chaque question jamais posée, il ne voit plus rien.

Un cache, c'est exactement ça : rapide, mais il faut décider quand jeter un post-it et combien en garder.

Quand mettre en cache ?

Un cache est utile quand les trois conditions sont réunies :

Condition Exemple
l'opération est lente ou coûteuse un appel à une API externe, une requête complexe
elle est souvent répétée avec les mêmes paramètres le même produit consulté des milliers de fois
le résultat change peu une fiche produit, une liste de pays, les taux de TVA

Et il vaut mieux s'en passer quand :

  • les données changent tout le temps (un stock, un solde de compte) : le cache servirait des valeurs fausses ;
  • chaque appel a des paramètres différents (une recherche libre) : le cache se remplit sans jamais resservir ;
  • l'opération est déjà rapide : le cache ajoute de la complexité pour rien.

Astuce : avant d'ajouter un cache, vérifie que l'opération est vraiment lente, et pourquoi. Une requête SQL lente à cause d'un index manquant se corrige avec un index, pas avec un cache.

Mettre en place un cache

Activer le cache

Ajoute le starter :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

Puis active le cache dans une classe de configuration :

@Configuration
@EnableCaching
public class CacheConfig {
}

@Cacheable : lire depuis le cache

@Service
public class ProduitService {

    @Cacheable("produits")
    public ProduitDto trouver(Long id) {
        // requête en base, ou appel à une API lente
    }
}

Ce qui se passe à chaque appel :

trouver(42)
   │
   ├─ le cache "produits" contient-il la clé 42 ?
   │     ├─ oui → renvoie la valeur du cache, SANS exécuter la méthode
   │     └─ non → exécute la méthode, range le résultat sous la clé 42, le renvoie

Le premier appel avec 42 est lent ; les suivants sont instantanés. Un appel avec 43 est de nouveau lent la première fois : chaque valeur de paramètre a sa propre entrée.

Le nom "produits" désigne un cache : une sorte de Map dédiée. Chaque méthode mise en cache a en général le sien.

La clé

Par défaut, la clé est construite à partir de tous les paramètres de la méthode. Pour une méthode à un seul paramètre, c'est la valeur du paramètre.

Tu peux choisir la clé avec key, dans une petite syntaxe d'expression (SpEL) où # désigne un paramètre :

@Cacheable(value = "prix", key = "#produitId")
public BigDecimal prix(Long produitId, Locale langue) { ... } // la langue n'entre pas dans la clé

⚠️ Sois prudent : si deux appels donnent la même clé alors que leurs résultats devraient être différents, le cache renverra le mauvais. Ici, c'est correct seulement si le prix ne dépend pas de la langue.

Garder le cache à jour

C'est le problème du post-it périmé. Quand une donnée change, l'entrée du cache doit être retirée ou remplacée.

@CacheEvict : retirer une entrée

@CacheEvict(value = "produits", key = "#id")
public void modifier(Long id, ModificationProduitDto dto) {
    // mise à jour en base
}

Après la modification, l'entrée id est retirée : le prochain trouver(id) ira chercher la nouvelle valeur.

Pour vider tout un cache (par exemple après un import qui modifie tous les produits) :

@CacheEvict(value = "produits", allEntries = true)
public void importerCatalogue(Path fichier) { ... }

@CachePut : remplacer une entrée

@CachePut(value = "produits", key = "#id")
public ProduitDto modifier(Long id, ModificationProduitDto dto) {
    // mise à jour en base
    return produitDto; // ✅ cette valeur remplace celle du cache
}

Contrairement à @Cacheable, la méthode est toujours exécutée, et son résultat met le cache à jour.

Annotation La méthode est exécutée ? Effet sur le cache
@Cacheable seulement si la clé est absente lit, et remplit si besoin
@CachePut toujours remplace l'entrée par le résultat
@CacheEvict toujours retire l'entrée (ou tout le cache)

Note : @CacheEvict est souvent plus sûr que @CachePut : il suffit de retirer l'entrée, le prochain appel ira chercher la vraie valeur. Avec @CachePut, il faut que la méthode renvoie exactement ce que @Cacheable aurait renvoyé.

Et si on oublie un endroit ?

Si le produit est modifié par une autre méthode, un traitement de nuit ou directement en base, sans @CacheEvict, le cache sert l'ancienne valeur indéfiniment. C'est pour ça qu'un cache doit toujours avoir une durée de vie, même avec des @CacheEvict bien placés : c'est le filet de sécurité.

Limiter la taille et la durée de vie

Le piège du cache par défaut

Sans autre configuration, Spring Boot utilise une simple ConcurrentHashMap : aucune taille maximale, aucune expiration. Chaque produit consulté y reste jusqu'au redémarrage. C'est une fuite mémoire (voir la fiche sur la mémoire en Java), et des données qui ne se mettent jamais à jour.

Ce cache par défaut convient pour un essai en local, pas pour une application en production.

Caffeine : un cache local avec des limites

Ajoute la bibliothèque Caffeine :

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Spring Boot la détecte et l'utilise automatiquement. Configure-la dans application.properties :

spring.cache.cache-names=produits,categories
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m,recordStats
Option Effet
maximumSize=1000 au plus 1 000 entrées par cache ; au-delà, les moins utilisées sont retirées
expireAfterWrite=10m une entrée est retirée 10 minutes après avoir été ajoutée
recordStats Caffeine compte les succès et les échecs, visibles dans les métriques

Note : quand spring.cache.cache-names est renseigné, seuls ces caches existent. Une méthode annotée @Cacheable("prix") alors que prix n'est pas dans la liste provoque une erreur (« Cannot find cache named 'prix' »). Pense à y ajouter chaque nouveau cache.

La durée de vie est un choix métier, pas technique. Pose-toi la question : « Combien de temps est-il acceptable d'afficher une ancienne valeur ? » Pour une liste de pays, une journée. Pour un prix affiché sur une boutique, quelques minutes. Pour un stock, aucune : pas de cache.

Les pièges

Le proxy

@Cacheable fonctionne grâce à un proxy, exactement comme @Transactional (voir la fiche sur les proxies Spring). Il est donc ignoré sans erreur si :

  • la méthode est appelée depuis la même classe (this.trouver(id)) ;
  • la méthode est private ou final ;
  • l'objet a été créé avec new.

C'est la première chose à vérifier quand un cache « ne marche pas ».

Mettre en cache des entités

@Cacheable("produits")
public Produit trouver(Long id) { // ❌ une entité JPA dans le cache
    return produitRepository.findById(id).orElseThrow();
}

Une fois dans le cache, l'entité est détachée (voir la fiche sur les entités JPA) : ses relations LAZY lancent une LazyInitializationException à la première lecture, et ses modifications ne sont plus enregistrées. Mets en cache des DTO, construits dans la transaction.

Modifier un objet sorti du cache

ProduitDto produit = produitService.trouver(42L);
produit.setPrix(BigDecimal.ZERO); // ❌ modifie l'objet DU cache, pour tout le monde

Le cache local renvoie le même objet à chaque appel, pas une copie. Une modification « juste pour cet écran » le modifie pour tous les appels suivants. Les records, qui ne se modifient pas, éliminent ce risque : c'est une raison de plus de les utiliser pour les DTO.

Mettre en cache l'absence de résultat

Par défaut, un résultat null est aussi mis en cache. Si un produit n'existe pas encore mais qu'il est créé juste après, le cache continuera de répondre « rien » jusqu'à son expiration. Pour ne pas garder les résultats vides :

@Cacheable(value = "produits", unless = "#result == null")
public ProduitDto trouverOuNull(Long id) { ... }

Si la méthode lance une exception (« produit introuvable »), rien n'est mis en cache : le problème ne se pose pas.

Plusieurs instances de l'application

Caffeine est un cache local : chaque instance de l'application a le sien, dans sa propre mémoire. En production, l'application tourne souvent sur plusieurs serveurs :

requête de modification → serveur 1 : @CacheEvict → cache du serveur 1 à jour ✅
requête de lecture      → serveur 2 : son cache n'a pas été vidé → ancienne valeur ❌

Le serveur 2 servira l'ancienne valeur jusqu'à l'expiration de son entrée. Deux solutions :

  • une durée de vie courte, si quelques minutes d'écart sont acceptables : la solution la plus simple ;
  • un cache partagé, comme Redis, utilisé par toutes les instances. Spring Boot le gère avec les mêmes annotations : seule la configuration change (spring-boot-starter-data-redis, spring.cache.type=redis, spring.cache.redis.time-to-live=10m). Les objets doivent alors pouvoir être sérialisés pour être envoyés à Redis.

Le choix d'un cache partagé est une décision d'architecture : demande à l'équipe ce qui est en place.

Vérifier que le cache est utile

Un cache qui ne resert jamais rien ne fait que consommer de la mémoire. Avec recordStats dans la configuration de Caffeine et Spring Boot Actuator, les métriques du cache remontent dans Grafana (voir la fiche sur les métriques) :

Mesure Lecture
taux de succès (hit) élevé le cache évite beaucoup d'appels lents : il est utile
taux de succès faible les clés sont presque toujours différentes : le cache ne sert à rien
beaucoup d'évictions maximumSize est trop petit : les entrées sont retirées avant d'être réutilisées

L'adresse /actuator/caches liste aussi les caches existants.

Le cache dans les tests

Un cache actif dans les tests peut cacher des bugs : un test lit une valeur mise en cache par le test précédent. Pour le désactiver dans les tests :

# src/test/resources/application.properties
spring.cache.type=none

Les annotations restent en place, mais n'ont plus d'effet. Pour tester le cache lui-même (vérifier qu'une méthode n'est appelée qu'une fois), écris un test dédié qui l'active.

Les bonnes habitudes

  1. Mesure avant de mettre en cache : l'opération est-elle vraiment lente, et souvent répétée ?
  2. Jamais le cache par défaut en production : Caffeine (ou Redis), avec une taille maximale et une durée de vie.
  3. Choisis la durée de vie en te demandant combien de temps une ancienne valeur est acceptable.
  4. @CacheEvict à chaque modification, et la durée de vie comme filet de sécurité.
  5. Des DTO immuables (records) dans le cache, jamais d'entités.
  6. Les règles du proxy : méthode publique, appelée depuis une autre classe.
  7. Pas de cache sur des données qui changent tout le temps (stock, solde).
  8. Pense aux autres instances : un cache local n'est vidé que sur le serveur qui a reçu la modification.
  9. Surveille le taux de succès : un cache qui ne resert rien est à supprimer.

À retenir

  • Un cache garde le résultat d'une opération lente pour le resservir instantanément, au prix de données parfois périmées.
  • @Cacheable lit et remplit le cache ; @CacheEvict retire une entrée ; @CachePut la remplace.
  • Le cache par défaut de Spring Boot n'a ni taille maximale ni expiration : en production, utilise Caffeine avec maximumSize et expireAfterWrite.
  • Mets en cache des DTO immuables, pas des entités.
  • @Cacheable passe par un proxy : il est ignoré sur un appel interne ou une méthode private.
  • Un cache local est propre à chaque instance de l'application ; pour un cache commun, il faut un cache partagé comme Redis.

Avis sur l'article

Pas encore de note : soyez le premier à donner votre avis.

Les avis sont publiés après validation par le modérateur.

Votre note