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: redis spé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 forme products::<key>.
  • key = "#id" est une expression SpEL qui récupère le paramètre id comme 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 RedisCacheConfiguration est la configuration par défaut pour tous les caches. Spring Boot l’utilise automatiquement. Chaque entrée a un TTL de 10 minutes, les valeurs null ne sont pas stockées, et la valeur est sérialisée en JSON.
  • RedisCacheManagerBuilderCustomizer remplace la configuration spécifique pour le cache products. Ce cache a un TTL de 30 minutes et utilise un sérialiseur avec un type spécifique Product.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és spring.cache.redis.time-to-live, spring.cache.redis.key-prefix… dans application.yml seront 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 :

  • @CachePut exé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 retourne void, le cache n’aura pas les bonnes données.
  • @CacheEvict supprime 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, ajoutez beforeInvocation = true.
  • allEntries = true supprime tout le cache products. 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 :

  • condition est é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é.
  • unless est é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 = true pré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.
  • @BeforeEach nettoie le cache et le compteur, aidant les tests à être indépendants les uns des autres. Le conteneur est static donc 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.

spring-bootrediscachespring-cachetestcontainers