Comment ajouter du cache à une application Spring Boot

 

Le cache est l’un des leviers les plus efficaces pour améliorer la latence et réduire la charge d’une application. Spring Boot fournit une abstraction de cache très puissante, compatible avec plusieurs moteurs (Caffeine, Redis, Ehcache, Hazelcast).

Dans cet article :

  • Pourquoi utiliser un cache et quand l’éviter
  • L’abstraction Spring Cache et ses annotations
  • Configurer Caffeine (en mémoire) et Redis (partagé)
  • Définir des clés, conditions et TTL
  • Stratégies d’invalidation et tests
  • Monitoring avec Actuator et Micrometer
  • Pièges courants et bonnes pratiques

Pré-requis : Java 17 ou plus récent et Spring Boot 3.x. Les exemples utilisent Spring Boot 3.3.


Pourquoi mettre du cache ?

  • Diminuer la latence des endpoints et batchs.
  • Réduire la charge CPU/IO de services internes ou bases de données.
  • Lisser les pics de trafic et améliorer la résilience.
  • Faire des économies d’infrastructure.

Attention : le cache n’est pas un substitut à un modèle de données ou d’indexation correct. Il complète une conception saine.


Panorama de l’abstraction Spring Cache

L’API Spring Cache fournit :

  • Des annotations déclaratives : @EnableCaching, @Cacheable, @CacheEvict, @CachePut, @Caching.
  • Un mécanisme de génération de clé (SpEL) et de conditions (condition, unless).
  • Une intégration transparente avec différents CacheManager (Caffeine, Redis, Ehcache).

Le code métier reste identique : seul le backend change via la configuration.


Démarrage rapide

Dépendances Maven

<dependencies>
  <!-- API cache Spring -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
    <version>3.3.5</version>
  </dependency>

  <!-- Choisissez un moteur -->
  <!-- Caffeine (en mémoire, très rapide, TTL/size policy) -->
  <dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
  </dependency>

  <!-- Ou Redis (partagé, scalable) -->
  <!--
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>3.3.5</version>
  </dependency>
  -->
</dependencies>

Gradle (Kotlin DSL) :

dependencies {
  implementation("org.springframework.boot:spring-boot-starter-cache:3.3.5")
  implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
  // implementation("org.springframework.boot:spring-boot-starter-data-redis:3.3.5")
}

Activer le cache

Dans votre classe d’application (ou une classe de config) :

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@EnableCaching
@SpringBootApplication
public class Application { }

Première méthode cachée

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class PriceService {

  // Cache "prices" par défaut ; clé générée à partir des arguments (SpEL)
  @Cacheable(cacheNames = "prices", key = "#productId")
  public Price getPrice(String productId) {
    return fetchPriceFromSlowApi(productId); // appel coûteux
  }
}
  • Premier appel : MISS, exécution réelle et mise en cache.
  • Appels suivants avec la même clé : HIT.

Choisir un moteur de cache

  • Caffeine : en mémoire, ultra-rapide, TTL/size/expire-after-write/access, très simple en mono-process.
  • Redis : partagé (cluster/containers), persistant en mémoire, TTL par entrée, idéal multi-réplicas.
  • Ehcache, Hazelcast, Infinispan : alternatives JVM, parfois distribuées, selon vos contraintes.

Commencez simple : Caffeine local en dev/POC, puis passez à Redis en prod multi-instances.


Configuration Caffeine

application.yml :

spring:
  cache:
    cache-names: [prices, products]
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m,recordStats

Déclarer un CacheManager explicite (optionnel si vous utilisez la propriété ci-dessus) :

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
public class CacheConfig {

  @Bean
  public CacheManager cacheManager() {
    CaffeineCacheManager mgr = new CaffeineCacheManager("prices", "products");
    mgr.setCaffeine(Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .recordStats());
    return mgr;
  }
}

Récupérer des stats (Micrometer) :

management:
  endpoints.web.exposure.include: ["metrics", "health"]

Puis consultez /actuator/metrics/cache.gets, etc.


Configuration Redis (prod multi-instances)

Dépendances : spring-boot-starter-data-redis (Lettuce par défaut).

application.yml :

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis
    cache-names: [prices, products]

Configurer TTL par cache et sérialisation :

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.cache.*;

import java.time.Duration;
import java.util.Map;

@Configuration
public class RedisCacheConfig {

  @Bean
  public RedisCacheManager redisCacheManager(RedisConnectionFactory cf) {
    GenericJackson2JsonRedisSerializer json = new GenericJackson2JsonRedisSerializer();

    RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
        .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(json))
        .entryTtl(Duration.ofMinutes(10)); // TTL par défaut

    Map<String, RedisCacheConfiguration> configs = Map.of(
        "prices", defaultConfig.entryTtl(Duration.ofMinutes(15)),
        "products", defaultConfig.entryTtl(Duration.ofMinutes(5))
    );

    return RedisCacheManager.builder(cf)
        .cacheDefaults(defaultConfig)
        .withInitialCacheConfigurations(configs)
        .build();
  }
}

Remarques :

  • Les clés sont des String ; les valeurs sérialisées en JSON (lisibles, évolutives).
  • Adaptez le TTL à la volatilité métier de la donnée.

Annotations essentielles et SpEL

  • @Cacheable(cacheNames, key, unless, condition) : lit/écrit si absence.
  • @CachePut : force l’écriture sans court-circuiter l’exécution.
  • @CacheEvict(cacheNames, key, allEntries) : supprime ; utile après une écriture.
  • @Caching : combiner plusieurs annotations.

Exemples :

// Clé composite avec SpEL
@Cacheable(cacheNames = "productByShop", key = "#shopId + ':' + #productId")
public Product getProduct(String shopId, String productId) { ... }

// Conditionner le cache
@Cacheable(cacheNames = "prices", key = "#id", condition = "#id != null", unless = "#result == null")
public Price price(String id) { ... }

// Invalidation ciblée après update
@CacheEvict(cacheNames = "prices", key = "#p.id")
public Price updatePrice(Price p) { return repo.save(p); }

// Invalidation massive (ex: job de purge)
@CacheEvict(cacheNames = {"prices", "products"}, allEntries = true)
public void clearAllCaches() {}

Astuce : définissez des clés stables et explicites ; évitez celles sensibles aux variations (locales, ordre de paramètres).


Stratégie d’invalidation

La cohérence est clé. Quelques approches :

  • Invalidation au plus près des mutations : utilisez @CacheEvict dans les services qui écrivent.
  • Écouter des événements domain (DDD) : @TransactionalEventListener pour évincer après commit.
  • TTL raisonnable pour limiter la dérive en cas d’oubli d’invalidation.
  • Préremplissage (warmup) des caches les plus chauds au démarrage ou via un job.

Exemple avec événement :

public record PriceChangedEvent(String productId) {}

@Service
public class PriceWriter {
  private final ApplicationEventPublisher publisher;
  public PriceWriter(ApplicationEventPublisher publisher) { this.publisher = publisher; }

  @Transactional
  public void updatePrice(Price p) {
    repo.save(p);
    publisher.publishEvent(new PriceChangedEvent(p.id()));
  }
}

@Component
public class PriceCacheInvalidator {
  @CacheEvict(cacheNames = "prices", key = "#event.productId")
  @TransactionalEventListener
  public void onPriceChanged(PriceChangedEvent event) {}
}

Tests du cache

Test unitaire du key SpEL et du comportement : utilisez un CacheManager réel (Caffeine en mémoire) via @SpringBootTest ou @DataJpaTest + import de config. Vidangez le cache entre scénarios si nécessaire.

@SpringBootTest
class PriceServiceTest {

  @Autowired PriceService service;
  @Autowired CacheManager cacheManager;

  @Test
  void cached_method_hits_cache() {
    String id = "A-42";
    service.getPrice(id); // MISS
    service.getPrice(id); // HIT

    var cache = cacheManager.getCache("prices");
    assertThat(cache).isNotNull();
    assertThat(cache.get(id)).isNotNull();
  }
}

Pour Redis en test : utilisez Testcontainers Redis ou un Redis éphémère.


Monitoring et métriques

Avec Actuator + Micrometer :

  • cache.gets, cache.puts, cache.evictions, cache.size, latences.
  • Export Prometheus/Grafana pour visualiser le taux de HIT (visez 80% ou plus sur les chemins chauds).
<!-- pom.xml -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: ["health", "metrics", "prometheus"]

Pièges courants et bonnes pratiques

  • Ne cachez pas des données hautement sensibles dans un cache partagé sans chiffrement.
  • Attention à la cardinalité des clés (ex: vary non contrôlé) qui peut entraîner une explosion mémoire.
  • Évitez de mettre en cache des erreurs ou null sans TTL réduit ou garde-fou (unless).
  • Pour les applications multi-instances, évitez le cache en mémoire seul ; préférez Redis.
  • Pensez au versioning de schéma de vos objets mis en cache (compatibilité JSON lors des déploiements progressifs).
  • Définissez une politique de TTL par type de donnée, documentée et mesurable.

Conclusion

La mise en place du cache avec Spring Boot apporte des gains concrets et rapides.

Points clés à retenir :

  • Commencez simple avec Caffeine en local et mesurez les gains sur 2-3 méthodes coûteuses
  • En production multi-instances, migrez vers Redis pour un cache partagé et consistant
  • Mettez en place une stratégie d’invalidation au plus près des écritures avec @CacheEvict
  • Définissez des TTL adaptés à la volatilité de chaque type de donnée
  • Surveillez les taux de hit/miss et la taille des caches via les métriques Actuator
  • Restez vigilant sur la cardinalité des clés et la sécurité des données sensibles

Pour aller plus loin

Voir aussi