Cache avec Redis: TTL, Eviction et Cache Stampede

1. Aperçu

Redis est un choix populaire pour mettre en cache les données, réduire la charge de la base de données et accélérer les temps de réponse. Mais utiliser Redis n’est pas aussi simple qu’un set et get — vous devez comprendre comment fonctionne le TTL (Time To Live), comment Redis supprime les clés en cas de manque de mémoire (eviction), et surtout: comment éviter le cache stampede — une situation où des centaines de requêtes frappent simultanément la base de données lorsqu’une clé chaude expire.

Cet article couvre la configuration TTL basique, les politiques d’éviction de Redis, et les techniques de combat contre le stampede: mutex lock, expiration logique et coalescence des requêtes. Après lecture, vous saurez comment concevoir une couche de cache qui peut supporter une charge élevée sans surcharger la base de données en arrière-plan.

2. Configuration de Redis avec Spring Boot

D’abord, ajoutez la dépendance:

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

Configuration de connexion:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8

Le bean RedisTemplate est utilisé pour manipuler les clés/valeurs avec des types de données personnalisés:

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    return template;
}

GenericJackson2JsonRedisSerializer stocke les objets en JSON avec les informations de classe, ce qui aide à désérialiser correctement le type lors de la relecture — plus pratique que JdkSerializationRedisSerializer qui est difficile à lire avec redis-cli.

3. TTL — Time To Live

3.1. Définir le TTL lors de l’écriture d’une clé

@Service
public class ProductCacheService {

    private final RedisTemplate<String, Object> redisTemplate;

    public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheProduct(Long productId, Product product) {
        String key = "product:" + productId;
        redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    }
}

La ligne set(key, product, Duration.ofMinutes(10)) écrit la clé avec un TTL de 10 minutes. Après ce délai, Redis supprime automatiquement la clé — c’est ce qu’on appelle expiration passive combinée avec expiration active (Redis scanne aléatoirement les clés avec TTL chaque 100ms pour nettoyer plus tôt, en évitant que les clés expirées restent en RAM en attente d’être lues).

Vérifier le TTL restant:

redis-cli TTL product:123
(integer) 542

La valeur -1 signifie que la clé existe mais n’a pas de TTL (existe indéfiniment). La valeur -2 signifie que la clé n’existe pas.

3.2. TTL fixe vs TTL aléatoire (jitter)

Si vous mettez en cache des milliers de clés simultanément avec le même TTL, elles expireront toutes en même temps — causant une vague de “cache miss” groupées. La solution est d’ajouter du jitter: ajouter aléatoirement une petite durée au TTL.

public void cacheProductWithJitter(Long productId, Product product) {
    long baseSeconds = 600;
    long jitter = ThreadLocalRandom.current().nextLong(0, 60);
    redisTemplate.opsForValue()
        .set("product:" + productId, product, Duration.ofSeconds(baseSeconds + jitter));
}

Chaque clé expirera à un moment légèrement différent (600–660 secondes), évitant l’effet “thundering herd” lorsque de nombreuses clés expirent en même temps.

Note: Le jitter ne résout pas le cache stampede pour une seule clé chaude (par exemple, la page d’accueil, un produit très vendu). Ce problème nécessite les techniques de la section 5.

4. Politique d’éviction lorsque Redis manque de mémoire

Le TTL gère l’expiration des clés au fil du temps. Mais quand Redis atteint la limite maxmemory, il doit supprimer des clés selon une politique d’éviction, que la clé ait un TTL ou non.

Configuration dans redis.conf:

maxmemory 512mb
maxmemory-policy allkeys-lru

Les politiques communes:

Politique Signification
noeviction Ne supprime rien, retourne une erreur OOM lors de l’ajout. C’est le défaut de Redis.
allkeys-lru Supprime la clé la moins récemment utilisée (Least Recently Used), appliqué à toutes les clés.
volatile-lru Pareil que ci-dessus mais supprime uniquement les clés avec TTL.
allkeys-lfu Supprime la clé avec la fréquence d’accès la plus basse (Least Frequently Used).
volatile-ttl Préfère supprimer la clé avec le TTL le plus court.
volatile-random Supprime aléatoirement dans l’ensemble des clés avec TTL.

Pour un use case de cache pur, allkeys-lru ou allkeys-lfu est un choix sûr car Redis libérera automatiquement la mémoire au lieu de retourner une erreur. Si vous utilisez Redis à la fois comme cache et comme lieu de stockage de données importantes qui ne doivent pas être perdues (comme les tokens de session), séparez les instances ou utilisez volatile-lru pour ne supprimer que les clés marquées avec TTL.

Note: noeviction (le défaut) causera une erreur OOM command not allowed lorsque Redis est plein. Si vous utilisez Redis comme cache, définissez toujours une maxmemory-policy appropriée, ne laissez pas la valeur par défaut.

Vérifier la configuration actuelle:

redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"

5. Qu’est-ce que le Cache Stampede

Le cache stampede (aussi appelé dogpile effect) se produit quand une clé chaude — lue par des centaines/milliers de requêtes chaque seconde — expire soudainement. Toutes les requêtes manquent le cache presque simultanément, se précipitent sur la base de données pour reconstruire les données, surchargeant la base de données pendant quelques centaines de millisecondes.

public Product getProduct(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }
    // La clé vient d'expirer -> des centaines de threads entrent ici en même temps
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    return product;
}

Avec une clé de produit très vendu, le code ci-dessus est tout à fait logique en temps normal, mais au moment où le TTL expire, productRepository.findById peut être appelé des centaines de fois simultanément pour le même id.

5.1. Solution: Distributed lock (mutex)

Permettre à une seule requête de reconstruire le cache, les autres attendent ou utilisent l’ancienne valeur.

public Product getProductSafely(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }

    String lockKey = "lock:product:" + id;
    Boolean acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

    if (Boolean.TRUE.equals(acquired)) {
        try {
            Product product = productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
            redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
            return product;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // N'a pas pu acquérir le verrou -> attend un peu puis réessaie de lire le cache
        return waitAndRetry(key, id);
    }
}

private Product waitAndRetry(String key, Long id) {
    for (int i = 0; i < 10; i++) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        Product cached = (Product) redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }
    }
    // Temps d'attente écoulé, fallback en appelant directement la DB
    return productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
}

setIfAbsent équivaut à la commande SET lockKey 1 NX EX 5 dans Redis — atomique, seul un thread réussit à créer le verrou. Les threads qui n’acquièrent pas le verrou vont interroger le cache toutes les 100ms au lieu d’appeler directement la base de données.

5.2. Solution: Expiration logique (sans TTL réel)

Au lieu de laisser Redis supprimer la clé, vous stockez le moment de l’expiration logique dans la valeur, et ne laissez jamais la clé disparaître réellement. Quand une donnée est détectée comme “expirée logiquement”, un thread reconstruit en arrière-plan, tandis que les autres threads retournent toujours l’ancienne donnée immédiatement (stale-while-revalidate).

public record CachedValue<T>(T data, Instant logicalExpireAt) {}

public Product getProductWithLogicalTtl(Long id) {
    String key = "product:" + id;
    CachedValue<Product> cached =
        (CachedValue<Product>) redisTemplate.opsForValue().get(key);

    if (cached == null) {
        return rebuildAndCache(id, key);
    }

    if (Instant.now().isAfter(cached.logicalExpireAt())) {
        String lockKey = "lock:product:" + id;
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
        if (Boolean.TRUE.equals(acquired)) {
            // Reconstruction asynchrone, retour de l'ancienne donnée immédiatement pour la requête actuelle
            CompletableFuture.runAsync(() -> {
                try {
                    rebuildAndCache(id, key);
                } finally {
                    redisTemplate.delete(lockKey);
                }
            });
        }
    }
    return cached.data(); // retourne toujours les données, même si elles peuvent être un peu anciennes
}

private Product rebuildAndCache(Long id, String key) {
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    CachedValue<Product> value = new CachedValue<>(product, Instant.now().plusSeconds(600));
    // TTL physique plus long que le TTL logique pour éviter que la clé disparaisse pendant la reconstruction
    redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(15));
    return product;
}

Le point clé: le TTL physique sur Redis (15 minutes) est toujours plus long que le TTL logique (10 minutes). La clé n’est jamais supprimée soudainement par Redis — elle est simplement marquée comme “ancienne” et reconstruite en arrière-plan. Les requêtes n’attendent jamais la base de données, même juste après expiration logique.

6. Tester le comportement anti-stampede

Écrivez un test confirmant qu’il y a un seul appel de base de données quand plusieurs threads manquent le cache:

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
class ProductCacheServiceTest {

    @Autowired
    private ProductCacheService cacheService;

    @MockBean
    private ProductRepository productRepository;

    @Test
    void seulUnThreadDoitAppelerLaBaseDeDonneesAuCacheMiss() throws InterruptedException {
        Long productId = 42L;
        Product product = new Product(productId, "Clavier mécanique");
        given(productRepository.findById(productId)).willReturn(Optional.of(product));

        int threadCount = 50;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                cacheService.getProductSafely(productId);
                latch.countDown();
            });
        }
        latch.await(5, TimeUnit.SECONDS);
        executor.shutdown();

        verify(productRepository, times(1)).findById(productId);
    }
}
@Test
void sanVerrouLaBaseDeDonneesSuppeleeMultiplesFois() {
    Long productId = 99L;
    Product product = new Product(productId, "Clavier mécanique");
    given(productRepository.findById(productId)).willReturn(Optional.of(product));

    List<Product> results = IntStream.range(0, 50)
        .parallel()
        .mapToObj(i -> cacheService.getProductUnsafe(productId))
        .toList();

    assertThat(results).hasSize(50);
    verify(productRepository, atLeast(2)).findById(productId); // preuve du stampede
}

Le premier test utilise getProductSafely (avec verrou) et s’attend à ce que findById soit appelé une seule fois même si 50 threads s’exécutent. Le deuxième test utilise la version sans verrou pour illustrer l’inverse — vous verrez findById appelé plusieurs fois, prouvant que le stampede se produit réellement sans traitement.

7. Erreurs courantes

  • Oublier de définir le TTL: la clé existe à jamais, Redis se remplit graduellement de RAM, obligeant à dépendre entièrement de la politique d’éviction — facile de supprimer accidentellement une clé nécessaire.
  • TTL trop court pour une clé chaude: augmente la fréquence des cache miss, augmente le risque de stampede. Pour les clés à fort trafic, privilégiez l’expiration logique au lieu d’un TTL physique court.
  • Verrou sans TTL: si l’instance tenant le verrou se bloque à mi-chemin, le verrou existe à jamais, les autres threads attendent indéfiniment. Toujours définir un TTL pour le verrou (setIfAbsent avec Duration).
  • Utiliser noeviction pour le cache Redis: l’application lèvera une erreur quand Redis est plein au lieu de nettoyer automatiquement. Cela n’a du sens que si Redis stocke des données ne devant pas être perdues.
  • Sérialisation d’objet incohérente: modifier un champ dans la classe Product sans gérer la versioning peut causer une erreur de désérialisation du vieux cache. Vous devriez ajouter un mécanisme de cache-busting (modifier le préfixe de clé) quand vous changez le schéma.

8. Conclusion

Le TTL aide les données en cache à se rafraîchir au fil du temps, mais vous avez besoin du jitter pour éviter que de nombreuses clés expirent en même temps. La politique d’éviction détermine comment Redis gère le manque de mémoire — choisissez allkeys-lru/allkeys-lfu pour un cache pur, évitez la valeur par défaut noeviction. Pour les clés chaudes, le TTL seul ne suffit pas: vous avez besoin d’un verrou mutex ou d’une expiration logique pour vous assurer qu’un seul thread reconstruit le cache, le reste lisant les anciennes données au lieu de tous converger vers la base de données. En appliquant correctement ces trois techniques, votre couche de cache supportera une charge beaucoup mieux qu’un simple set/get.

rediscachespring-bootperformancejava