Cache avec @Cacheable et Redis dans Spring Boot
1. Aperçu
De nombreuses applications exécutent à nouveau et à nouveau la même requête lente. Par exemple, récupérer les informations produit, la configuration ou le taux de change. Spring Cache Abstraction résout ce problème avec quelques annotations. Redis joue le rôle d’un endroit où stocker le cache utilisé en commun par plusieurs instances.
Après avoir lu cet article, vous pourrez faire les choses suivantes :
- Configurer Redis comme fournisseur de cache.
- Utiliser
@Cacheable,@CachePut,@CacheEvict. - Définir le TTL individuellement pour chaque cache et stocker les données au format JSON.
- Tester le comportement du cache avec JUnit 5 et Testcontainers.
2. Dépendances Maven
Vous avez besoin de deux starters : l’un pour Spring Cache, l’autre pour Redis.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Pour les tests, vous devez ajouter Testcontainers. Spring Boot gère déjà la version pour ces dépendances :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
3. Configurer la connexion Redis
Déclarez l’adresse Redis et choisissez Redis comme fournisseur de cache dans application.yml :
spring:
data:
redis:
host: localhost
port: 6379
cache:
type: redis
Signification de chaque propriété :
spring.data.redis.*configure la connexion. Spring Boot utilise le client Lettuce par défaut.spring.cache.type: redisspécifie explicitement le fournisseur. Si Redis est sur le classpath, Spring Boot le choisit aussi automatiquement. Le spécifier explicitement aide à éviter la confusion quand il y a plusieurs fournisseurs sur le classpath.
Vous pouvez lancer rapidement un Redis local avec Docker :
docker run -d --name redis -p 6379:6379 redis:7-alpine
4. Exemple basique avec @Cacheable
4.1. Modèle et repository simulant une requête lente
Nous utilisons un simple record pour le produit :
public record Product(Long id, String name, BigDecimal price) {}
Le repository ci-dessous simule une base de données lente. Il compte également le nombre d’appels pour nous permettre de vérifier le cache :
@Repository
public class ProductRepository {
private final Map<Long, Product> store = new ConcurrentHashMap<>(Map.of(
1L, new Product(1L, "Nón lá Huế", new BigDecimal("150000")),
2L, new Product(2L, "Áo bà ba", new BigDecimal("320000"))
));
private final AtomicInteger queryCount = new AtomicInteger();
public Optional<Product> findById(Long id) {
queryCount.incrementAndGet();
sleep(1000); // simuler une requête prenant 1 seconde
return Optional.ofNullable(store.get(id));
}
public Product save(Product product) {
store.put(product.id(), product);
return product;
}
public void deleteById(Long id) {
store.remove(id);
}
public int getQueryCount() { return queryCount.get(); }
public void resetQueryCount() { queryCount.set(0); }
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
4.2. Activer le cache et annoter la méthode
Activez le mécanisme de cache avec @EnableCaching. Mettez cette annotation sur une classe @Configuration (nous l’écrirons à la section 5) ou sur la classe main. Ensuite, annotez la méthode qui a besoin du cache :
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(cacheNames = "products", key = "#id")
public Product findById(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
}
Le flux de traitement est le suivant :
cacheNames = "products"est le nom du cache. Sur Redis, la clé par défaut a la formeproducts::<key>.key = "#id"est une expression SpEL qui récupère le paramètreidcomme clé. Si la méthode n’a qu’un seul paramètre, vous pouvez omettre cet attribut.- Au premier appel, Spring ne voit pas la clé dans le cache. Il exécute la méthode, puis sauvegarde le résultat dans Redis.
- Aux appels suivants avec le même
id, Spring retourne le résultat de Redis. La méthode n’est pas exécutée. - Si la méthode lève une exception (
ProductNotFoundException), Spring n’enregistre rien dans le cache.
Appelez findById(1L) deux fois puis vérifiez Redis :
redis-cli KEYS '*'
redis-cli TTL products::1
1) "products::1"
(integer) 1798
Le premier appel prend environ 1 seconde. Le deuxième appel ne prend que quelques millisecondes. Le TTL reste d’environ 1800 secondes car nous configurerons 30 minutes pour le cache products à la section suivante.
5. Configurer RedisCacheManager : TTL et sérialiseur
Par défaut, RedisCacheManager utilise la sérialisation JDK. Cette approche a trois inconvénients :
- La classe doit implémenter
Serializable. - Les données dans Redis sont binaires, donc difficiles à lire.
- Les données s’abîment facilement quand vous modifiez la structure de la classe.
De plus, par défaut, le cache n’a pas de TTL, ce qui signifie que les entrées vivent éternellement. Corrigeons ces deux points :
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheConfiguration defaultCacheConfig() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
}
@Bean
public RedisCacheManagerBuilderCustomizer productCacheCustomizer() {
return builder -> builder.withCacheConfiguration("products",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new Jackson2JsonRedisSerializer<>(Product.class))));
}
}
Explication de chaque bean :
- Le bean
RedisCacheConfigurationest la configuration par défaut pour tous les caches. Spring Boot l’utilise automatiquement. Chaque entrée a un TTL de 10 minutes, les valeursnullne sont pas stockées, et la valeur est sérialisée en JSON. RedisCacheManagerBuilderCustomizerremplace la configuration spécifique pour le cacheproducts. Ce cache a un TTL de 30 minutes et utilise un sérialiseur avec un type spécifiqueProduct.class.
Pourquoi products utilise Jackson2JsonRedisSerializer<>(Product.class) ? GenericJackson2JsonRedisSerializer doit stocker les informations de type (@class) dans le JSON. Avec une classe final comme record, ce mécanisme revient facilement à LinkedHashMap au lieu du bon type, et vous rencontrerez ClassCastException. Un sérialiseur avec un type spécifique évite complètement ce problème.
Les données dans Redis sont maintenant directement lisibles :
redis-cli GET products::1
"{\"id\":1,\"name\":\"Nón lá Huế\",\"price\":150000}"
Remarque : Quand vous déclarez vous-même le bean
RedisCacheConfiguration, les propriétésspring.cache.redis.time-to-live,spring.cache.redis.key-prefix… dansapplication.ymlseront ignorées. Choisissez l’une des deux approches de configuration, ne les mélangez pas.
6. Mettre à jour et supprimer le cache : @CachePut et @CacheEvict
Le cache n’est utile que si les données qu’il contient sont correctes. Quand un produit change, vous devez mettre à jour ou supprimer l’entrée correspondante.
@CachePut(cacheNames = "products", key = "#product.id()")
public Product update(Product product) {
return productRepository.save(product);
}
@CacheEvict(cacheNames = "products", key = "#id")
public void delete(Long id) {
productRepository.deleteById(id);
}
@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() {
// exemple : appel après import en masse de produits
}
Différence entre les annotations :
@CachePutexécute toujours la méthode, puis écrit le résultat retourné dans le cache avec la clé#product.id(). Par conséquent, la méthode doit retourner l’objet exact à mettre en cache. Si elle retournevoid, le cache n’aura pas les bonnes données.@CacheEvictsupprime l’entrée selon la clé après que la méthode s’exécute avec succès. Si vous avez besoin de supprimer avant l’exécution de la méthode, ajoutezbeforeInvocation = true.allEntries = truesupprime tout le cacheproducts. Avec Redis, cette opération recherche les clés selon un pattern. Vous ne devriez l’utiliser que si c’est vraiment nécessaire.
7. Contrôler la mise en cache : condition, unless, sync
@Cacheable a quelques attributs supplémentaires utiles :
@Cacheable(cacheNames = "productSearch",
key = "#keyword.toLowerCase()",
condition = "#keyword.length() >= 3",
unless = "#result.isEmpty()")
public List<Product> search(String keyword) {
// requête lente...
return List.of();
}
@Cacheable(cacheNames = "products", key = "#id", sync = true)
public Product findHotProduct(Long id) {
return findFromDatabase(id); // méthode privée dans la même classe, lire directement la base de données
}
Signification de chaque attribut :
conditionest évaluée avant l’exécution de la méthode. Ici, un mot-clé de moins de 3 caractères ne sera pas recherché et aucun cache ne sera non plus stocké.unlessest évaluée après l’exécution de la méthode et peut utiliser#result. Ici, une liste vide ne sera pas stockée.key = "#keyword.toLowerCase()"aide « Nón » et « nón » à utiliser la même entrée.sync = trueprévient le cache stampede. Quand plusieurs threads manquent la même clé, seul un thread exécute la méthode, les autres threads attendent le résultat. Ce mécanisme synchronise seulement au sein d’une JVM, pas un verrou distribué entre les instances.
Le cache productSearch n’a pas de configuration spécifique donc utilise la configuration par défaut de la section 5, c’est-à-dire un TTL de 10 minutes et GenericJackson2JsonRedisSerializer. Avec la valeur List<Product> contenant des record, vous devriez déclarer un sérialiseur avec un type spécifique comme pour products. Ainsi vous éviterez l’erreur ClassCastException mentionnée à la section 5.
8. Tester le comportement du cache
Le moyen le plus fiable est de tester avec du vrai Redis via Testcontainers. L’annotation @ServiceConnection configure automatiquement l’hôte et le port pour Spring Boot, vous n’avez pas besoin de modifier application.yml.
@SpringBootTest
@Testcontainers
class ProductServiceCacheTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Autowired
private CacheManager cacheManager;
@BeforeEach
void setUp() {
cacheManager.getCache("products").clear();
productRepository.resetQueryCount();
}
@Test
void whenFindByIdCalledTwice_thenRepositoryIsQueriedOnce() {
Product first = productService.findById(1L);
Product second = productService.findById(1L);
assertThat(first).isEqualTo(second);
assertThat(productRepository.getQueryCount()).isEqualTo(1);
assertThat(cacheManager.getCache("products").get(1L, Product.class))
.isEqualTo(first);
}
@Test
void whenProductUpdated_thenCacheReturnsNewValue() {
productService.findById(2L);
productService.update(new Product(2L, "Áo bà ba lụa", new BigDecimal("450000")));
Product cached = productService.findById(2L);
assertThat(cached.name()).isEqualTo("Áo bà ba lụa");
assertThat(productRepository.getQueryCount()).isEqualTo(1);
}
}
Ces deux tests vérifient les points suivants :
- Le premier test : appel deux fois mais le repository n’est appelé qu’une fois. L’entrée est aussi réellement dans le cache avec le bon type
Product. Cela confirme que le sérialiseur fonctionne correctement. - Le deuxième test :
@CachePutécrit la nouvelle valeur, donc la lecture suivante la récupère du cache sans appeler une fois de plus le repository. @BeforeEachnettoie le cache et le compteur, aidant les tests à être indépendants les uns des autres. Le conteneur eststaticdonc ne démarre qu’une fois pour la classe entière.
9. Erreurs courantes et quand ne pas utiliser
Invocation directe au sein de la même classe. Spring Cache fonctionne via un proxy. Si une méthode dans ProductService appelle this.findById(id), l’appel va directement à l’objet, non pas par le proxy, donc le cache n’est pas appliqué. C’est aussi la raison pour laquelle l’exemple findHotProduct à la section 7 lit directement la base de données au lieu d’appeler findById. Les annotations sur une méthode private n’ont pas non plus d’effet. Le correctif est de déplacer la méthode nécessitant le cache vers un autre bean.
Mettre en cache la valeur null quand null est désactivé. Avec disableCachingNullValues(), une méthode retournant null causera une erreur :
java.lang.IllegalArgumentException: Cache 'products' does not allow 'null' values;
Avoid storing null via '@Cacheable(unless="#result == null")' ...
Vous avez deux façons de gérer cela : ajouter unless = "#result == null", ou lancer une exception comme dans l’exemple à la section 4.
Modifier la structure d’une classe. Vous ajoutez ou renommez un champ dans Product, mais Redis contient toujours du JSON de l’ancien type. Le résultat peut être une erreur de désérialisation ou des données manquant un champ. Vous devriez renommer le cache (par exemple products-v2) ou nettoyer le cache lors du déploiement.
Mettre en cache une entité JPA. L’entité a un proxy lazy et des relations bidirectionnelles, c’est très facile d’avoir des erreurs lors de la sérialisation. Mettez en cache un DTO ou un record.
Remarque : Vous ne devriez pas utiliser le cache quand les données requièrent une cohérence absolue, par exemple le solde du compte ou le stock au moment du paiement. Vous ne devriez pas non plus l’utiliser quand les données changent constamment, car le taux de hit sera faible et vous n’économiserez qu’un appel réseau vers Redis. Si vous n’avez qu’une seule instance et les données sont petites, un cache en mémoire comme Caffeine est souvent plus rapide et plus simple que Redis.
10. Conclusion
Vous avez configuré Redis comme fournisseur de cache pour Spring Boot. Vous utilisez @Cacheable pour lire, @CachePut pour mettre à jour, @CacheEvict pour supprimer les entrées. Vous avez également défini le TTL et un sérialiseur JSON individuellement pour chaque cache, puis vérifié le comportement avec des tests utilisant du vrai Redis via Testcontainers.
Trois choses à retenir :
- Toujours définir un TTL.
- Mettre en cache un DTO au lieu d’une entité.
- Faire attention à l’invocation directe au sein de la même classe.
Faire correctement ces trois choses vous aidera à éviter la plupart des problèmes lors du déploiement du cache en production.