Spring Framework
Le cache déclaratif avec l'abstraction de cache de Spring
Intermédiaire9 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
L'abstraction de cache de Spring vous permet de mémoïser le résultat de méthodes coûteuses avec une simple annotation, tout en branchant derrière n'importe quel stockage de cache. Ce guide explique ce que font réellement les annotations à l'exécution, comment les clés sont construites, comment choisir et configurer un CacheManager, et pourquoi la difficulté du cache n'est pas de stocker des valeurs mais de les invalider — avec les patterns qui gardent les données en cache correctes.
Ce que vous allez apprendre
- Activer le cache et appliquer @Cacheable, @CachePut, @CacheEvict et @Caching avec la bonne sémantique
- Maîtriser les clés de cache avec SpEL et un KeyGenerator personnalisé, et utiliser correctement condition et unless
- Configurer un CacheManager adossé à ConcurrentMap, Caffeine, JCache ou Redis, et régler l'expiration au bon endroit
- Expliquer pourquoi le cache passe par un proxy et ce que cela implique pour l'auto-invocation et les méthodes private
- Concevoir une stratégie d'invalidation qui garde les données en cache cohérentes avec la source de vérité
La mise en cache est l'optimisation la plus rapide dont disposent la plupart des applications, et la source de certains de leurs bugs les plus déroutants. L'abstraction de cache de Spring, dans le module spring-context, vous offre la mémoïsation des résultats de méthodes par annotations et sépare ce que l'on met en cache de l'endroit où c'est stocké. La mécanique est simple ; la discipline — choisir les clés, l'expiration et l'invalidation — est là où se trouvent à la fois la valeur et le risque. Ce guide cible Spring Framework 6.x.
Ce qu'est l'abstraction
L'abstraction repose sur deux interfaces. Un Cache est un stockage clé-valeur nommé, avec get, put, evict et clear. Un CacheManager résout les caches par leur nom et les crée à la demande ou à partir d'une liste configurée. Spring fournit des implémentations pour ConcurrentHashMap, Caffeine et JCache (JSR-107) ; Spring Data Redis ajoute RedisCacheManager ; Hazelcast, Infinispan et d'autres fournissent les leurs.
Les annotations sont appliquées par un proxy AOP, exactement comme @Transactional. @EnableCaching enregistre l'intercepteur ainsi qu'un bean post-processor qui crée un proxy pour chaque bean portant des annotations de cache. Chaque appel à une méthode annotée via le proxy consulte le cache avant, après ou à la place de l'appel à la vraie méthode.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
CacheManager cacheManager() {
var manager = new CaffeineCacheManager("products", "priceLists"); // spring-context-support
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats());
return manager;
}
}
Déclarer les caches dès le départ, comme ici, vaut mieux que de laisser le manager les créer dynamiquement : une faute de frappe dans le nom d'un @Cacheable fait alors échouer le démarrage au lieu de créer en silence un cache non configuré.
Les annotations
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) { this.repository = repository; }
@Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
public Product find(long id) {
return repository.findById(id).orElse(null); // runs only on a cache miss
}
@CachePut(cacheNames = "products", key = "#result.id")
public Product update(long id, ProductUpdate change) {
Product saved = repository.save(repository.require(id).apply(change));
return saved; // always runs; result replaces the cached entry
}
@CacheEvict(cacheNames = "products", key = "#id")
public void delete(long id) {
repository.deleteById(id); // runs, then the entry is removed
}
@Caching(evict = {
@CacheEvict(cacheNames = "products", allEntries = true),
@CacheEvict(cacheNames = "priceLists", allEntries = true)
})
public void importCatalogue(Path file) { … }
}
| Annotation | Avant l'appel | Après l'appel |
|---|---|---|
@Cacheable | Cherche la clé ; en cas de hit, renvoie la valeur en cache et n'exécute pas la méthode | En cas de miss, stocke le résultat |
@CachePut | Rien | Stocke toujours le résultat sous la clé |
@CacheEvict | Rien (sauf avec beforeInvocation = true) | Supprime la clé, ou tout le contenu avec allEntries = true |
@Caching | Regroupe plusieurs des annotations ci-dessus sur une même méthode | — |
@CacheConfig | Valeurs par défaut au niveau de la classe pour cacheNames, keyGenerator, cacheManager | — |
La distinction entre @Cacheable et @CachePut compte plus qu'il n'y paraît. @Cacheable sert aux lectures : la méthode n'est qu'un repli quand le cache est froid. @CachePut sert aux écritures qui produisent aussi la nouvelle valeur : la méthode s'exécute toujours et le cache est rafraîchi avec ce qu'elle renvoie. Mettre les deux sur une même méthode est contradictoire, et la documentation de référence le déconseille explicitement.
@CacheEvict(beforeInvocation = true) évince même si la méthode lève une exception ; par défaut, l'éviction n'a lieu qu'après un retour réussi, ce qui convient pour « supprimer puis évincer » et ne convient pas pour les workflows « évincer puis reconstruire ».
Les clés
Une entrée de cache ne vaut que ce que vaut sa clé. Par défaut, SimpleKeyGenerator utilise le paramètre lui-même comme clé s'il y en a exactement un, SimpleKey.EMPTY s'il n'y en a aucun, et sinon un SimpleKey qui enveloppe tous les paramètres. Chaque paramètre doit donc avoir un equals et un hashCode corrects — un long ou un String conviennent, un objet métier qui n'a pas redéfini equals est une clé qui ne correspond jamais.
L'attribut key accepte une expression Spring Expression Language (SpEL) évaluée sur la méthode :
| Expression | Signification |
|---|---|
#id ou #p0 | Le paramètre nommé id (nécessite -parameters à la compilation, ce qu'activent les plugins de build de Spring Boot) ou le premier paramètre |
#customer.id + ':' + #currency | Une clé composite construite à partir de deux paramètres |
#result.id | La valeur de retour — valable uniquement dans @CachePut et unless |
#root.methodName | Le nom de la méthode invoquée ; existent aussi #root.target, #root.args |
T(java.time.LocalDate).now() | Un appel statique — pratique pour des regroupements par jour, dangereux ailleurs |
Pour une règle de clé partagée par de nombreuses méthodes, implémentez KeyGenerator et référencez-le par son nom de bean :
@Component("tenantKeyGenerator")
public class TenantKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
return TenantContext.currentTenant() + ":" + method.getName() + ":" + Arrays.deepToString(params);
}
}
@Cacheable(cacheNames = "reports", keyGenerator = "tenantKeyGenerator")
public Report monthly(YearMonth month) { … }
C'est en oubliant le tenant, la locale ou la devise dans la clé qu'un utilisateur finit par voir les données d'un autre. Tout ce qui modifie le résultat de la méthode sans être un paramètre doit faire partie de la clé, ou bien la méthode ne doit pas être mise en cache.
Les conditions
condition est évaluée avant l'appel et peut faire référence aux paramètres ; si elle est fausse, le cache n'est ni lu ni écrit. unless est évaluée après l'appel et peut faire référence à #result ; si elle est vraie, le résultat n'est pas stocké (mais un hit en cache est quand même renvoyé). Utilisez condition pour ne pas mettre en cache des valeurs d'entrée qui ont peu de chances de se répéter — condition = "#query.length() > 3" — et unless pour éviter de mettre en cache des null, des erreurs ou des payloads trop volumineux.
sync = true sur @Cacheable fait attendre les appelants concurrents qui partagent la même clé le temps d'un unique calcul, au lieu qu'ils sollicitent tous le backend en même temps — le problème de cache stampede. C'est pris en charge par les managers Caffeine et JCache ; avec cette option, unless et plusieurs noms de cache ne sont pas autorisés sur la même annotation.
Choisir un cache manager
| Manager | Où vivent les données | Expiration / taille | À utiliser quand |
|---|---|---|---|
ConcurrentMapCacheManager | Tas de la JVM | Aucune | Tests, prototypes |
CaffeineCacheManager | Tas de la JVM | Taille, durée, poids ; éviction quasi optimale | Caches mono-instance ou par nœud ; le choix par défaut |
JCacheCacheManager | Selon le fournisseur (Ehcache 3, Hazelcast, …) | Politique du fournisseur | Configuration standardisée ou déploiement JCache existant |
RedisCacheManager (Spring Data Redis) | Redis | TTL par cache | Plusieurs instances doivent partager un même cache ; survivre aux redémarrages |
Un cache local est rapide — de l'ordre de la nanoseconde — mais chaque instance de l'application en détient sa propre copie : une éviction sur un nœud ne vide pas les autres. Un cache distribué est cohérent entre les nœuds, mais chaque lecture coûte un aller-retour réseau et chaque valeur doit être sérialisable. Beaucoup de systèmes combinent les deux : une couche locale à durée de vie courte devant Redis. Le CompositeCacheManager de Spring peut chaîner des managers pour les lectures, mais il n'implémente pas de write-through à deux niveaux ; cette logique vous revient, ou revient au fournisseur.
Avec Redis, choisissez le sérialiseur délibérément. La sérialisation JDK est celle par défaut et elle est fragile d'un déploiement à l'autre ; un sérialiseur JSON avec des informations de type explicites est le choix habituel. Quel que soit votre choix, les classes mises en cache font désormais partie de votre surface de compatibilité — modifiez un champ, et les anciennes entrées ne se désérialisent plus jusqu'à leur expiration.
Stratégie d'invalidation
Le fameux problème difficile. Trois approches, par ordre croissant de coût et de justesse :
- Expiration temporelle uniquement. Vous acceptez des données périmées dans la limite du TTL. Correct pour les données de référence, les taux de change, tout ce qui tolère un décalage borné. C'est le plus simple et le plus robuste, car aucune coordination n'est nécessaire.
- Éviction explicite à l'écriture. Chaque méthode qui modifie les données porte un
@CacheEvictou un@CachePutpour les clés concernées. C'est précis, mais il faut identifier chaque chemin d'écriture, y compris les mises à jour en masse, les jobs batch et le SQL direct — et, avec plusieurs instances, il faut un cache partagé entre les nœuds pour que cela fonctionne. - Invalidation pilotée par des événements. Les écritures publient un événement métier ; des listeners évincent sur chaque nœud ou dans le cache partagé. Cela gère plusieurs instances avec des caches locaux et découple le code qui écrit de toute connaissance du cache.
Combinez 1 avec 2 ou 3 : un TTL en guise de filet de sécurité rattrape le chemin d'écriture que vous auriez oublié. Évincer avec allEntries = true à chaque écriture est l'instrument brutal, mais souvent le bon choix pour de petits caches aux relations de clés complexes.
Cache et transactions interagissent d'une façon qu'il est facile de mal gérer. @EnableCaching et @EnableTransactionManagement fixent tous deux par défaut l'ordre de leur intercepteur à Ordered.LOWEST_PRECEDENCE : quand une même méthode porte à la fois @Transactional et @CacheEvict, rien ne garantit quel advice s'exécute le plus à l'extérieur. Si l'éviction a lieu avant le commit, un lecteur concurrent peut interroger la base entre l'éviction et le commit, voir l'ancienne ligne et repeupler le cache avec une donnée périmée qui survivra jusqu'à la prochaine éviction. Deux corrections possibles : donner à l'une des annotations @Enable… un order explicite pour que l'éviction soit l'advice extérieur et s'exécute après le commit, ou envelopper le manager dans un TransactionAwareCacheManagerProxy (Spring Data Redis propose transactionAware() sur son builder), qui reporte put et evict à la phase post-commit de la transaction et les abandonne en cas de rollback. La seconde option est le choix par défaut le plus sûr dès que caches et transactions se mélangent.
Erreurs courantes
Auto-invocation. find(id) appelée depuis une autre méthode de ProductService contourne le proxy, et donc le cache. Déplacez la méthode mise en cache dans un autre bean ou injectez le proxy.
Des clés qui ignorent le contexte. Tenant, locale, devise, permissions de l'utilisateur — si cela influe sur le résultat sans figurer dans la clé, les utilisateurs verront les données des autres.
Mettre des objets mutables dans un cache local. Chaque appelant reçoit la même instance ; la modification faite par l'un est silencieusement visible par tous. Mettez en cache des valeurs immuables ou des copies défensives.
Un ConcurrentMapCacheManager non borné en production. Aucune limite de taille, aucune expiration : c'est une fuite mémoire avec un joli nom.
@Cacheable sur une méthode à effets de bord. En cas de hit, la méthode ne s'exécute pas, donc l'effet de bord n'a pas lieu. Ne mettez en cache que des lectures pures.
Tester le cache sur un objet créé avec un simple new. Rien n'est mis en cache. Utilisez un contexte de test Spring avec @EnableCaching et vérifiez cacheManager.getCache("products").get(id).
Ignorer les métriques. Un cache avec un taux de hit de 5 % est un coût sans bénéfice. Le recordStats() de Caffeine et les métriques de cache de Spring Boot exposent les taux de hit : surveillez-les.
Points clés à retenir
- L'abstraction de cache, c'est
CacheetCacheManager, plus des annotations appliquées par un proxy AOP une fois@EnableCachingen place. @Cacheablesaute la méthode en cas de hit,@CachePuts'exécute toujours et rafraîchit,@CacheEvictsupprime ;@Cachingles combine.- Les clés reprennent par défaut les paramètres ; rendez-les explicites avec SpEL ou un
KeyGenerator, et incluez chaque entrée qui influe sur le résultat. - L'expiration et la taille se configurent au niveau du fournisseur, pas dans les annotations ; Caffeine est le choix standard en local, Redis le choix standard en partagé.
- L'invalidation est le vrai travail de conception : associez éviction explicite ou événements à un TTL comme filet de sécurité, et surveillez l'ordre par rapport aux transactions.
- Toutes les mises en garde de l'AOP s'appliquent — l'auto-invocation, les objets hors proxy et les méthodes à effets de bord neutralisent le cache sans bruit.
Sources et lectures complémentaires
Questions fréquentes
- L'abstraction de cache de Spring gère-t-elle une durée de vie (TTL) ?
- Pas directement. L'abstraction définit put, get et evict ; l'expiration, la taille maximale et la politique d'éviction sont des propriétés du stockage sous-jacent. Configurez-les au niveau du fournisseur de cache : expireAfterWrite pour Caffeine, entryTtl dans la configuration de cache Redis, une politique d'expiration JCache. ConcurrentMapCacheManager n'a aucune expiration : les entrées vivent jusqu'à leur éviction ou l'arrêt de la JVM.
- Pourquoi ma méthode @Cacheable est-elle quand même exécutée à chaque appel ?
- Trois causes habituelles : l'appel vient d'une autre méthode de la même classe (l'auto-invocation contourne le proxy), la méthode n'est pas public alors que le proxy est basé sur une interface, ou @EnableCaching manque et aucun proxy n'a été créé. Plus rarement, une expression condition ou unless filtre tout, ou la clé change à chaque appel parce qu'un argument n'a pas d'equals/hashCode correct.
- Les résultats null sont-ils mis en cache ?
- Par défaut oui, si le stockage accepte les valeurs null — Spring enveloppe null pour que ConcurrentMap et Caffeine puissent le conserver. C'est utile pour mettre en cache un « introuvable », mais surprenant quand la valeur n'est null que temporairement. Ajoutez unless = "#result == null" pour ne pas mettre les null en cache. Redis sérialise un marqueur null ; le comportement des fournisseurs JCache varie.
- Faut-il mettre en cache au niveau du repository ou du service ?
- Mettez en cache l'opération dont le résultat est coûteux et stable — en général une méthode de service qui compose plusieurs requêtes ou un appel distant, avec l'identifiant métier comme clé. Mettre en cache chaque appel de repository produit souvent une multitude de petits caches à l'invalidation floue. Quelle que soit la couche choisie, placez les annotations @CacheEvict à côté des écritures sur les mêmes données, pour voir les deux côtés ensemble.