Redis : choisir cache-aside, write-through ou write-behind

1. Aperçu

Ajouter Redis devant la base de données est le moyen le plus rapide de réduire la latence et la charge du système. La difficulté vient d’ailleurs : vous devez décider comment écrire les données afin que le cache et la base de données ne divergent pas. Il y a trois stratégies populaires : cache-aside, write-through et write-behind.

Cet article implémente les trois avec Spring Boot 3 et Redis. Ensuite l’article les compare selon la cohérence, les performances et le risque de perte de données. À la fin, vous saurez quelle stratégie choisir pour chaque type de données.

2. Préparation

2.1. Dépendances

On utilise Spring Data Redis, Spring Data JPA avec H2 comme base de données exemple, et Testcontainers pour les tests :

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <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>
</dependencies>

Configurer la connexion Redis en local :

spring.data.redis.host=localhost
spring.data.redis.port=6379

2.2. Entity, repository et RedisTemplate

@Entity
public class Product {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;

    protected Product() {
    }

    public Product(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
    }

    // getters et setters
}

public interface ProductRepository extends JpaRepository<Product, Long> {
}

Spring Boot crée automatiquement StringRedisTemplate. Pour stocker Product en JSON, on déclare un RedisTemplate supplémentaire :

@Configuration
@EnableScheduling
public class RedisConfig {

    @Bean
    RedisTemplate<String, Product> productRedisTemplate(RedisConnectionFactory factory) {
        var template = new RedisTemplate<String, Product>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Product.class));
        return template;
    }
}

La clé a la forme product:{id}, la valeur est du JSON de Product. L’annotation @EnableScheduling est utilisée dans la partie write-behind.

Les trois stratégies doivent exécuter du code après le commit de la transaction. On groupe cela dans une classe helper :

public final class AfterCommit {

    public static void run(Runnable action) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            action.run();
            return;
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                action.run();
            }
        });
    }
}

Si on est dans une transaction, action ne s’exécute que si le commit réussit. Si la transaction est annulée, le cache n’est pas affecté.

3. Cache-aside (lazy loading)

Avec cache-aside, l’application gère elle-même le cache :

  • Lecture : chercher d’abord dans Redis. En cas d’absence, lire la base de données puis écrire le résultat dans Redis avec un TTL.
  • Écriture : mettre à jour la base de données, puis supprimer la clé dans Redis.
@Service
public class ProductCacheAsideService {

    private static final Duration TTL = Duration.ofMinutes(10);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;

    public ProductCacheAsideService(ProductRepository repository,
                                    RedisTemplate<String, Product> redis) {
        this.repository = repository;
        this.redis = redis;
    }

    public Optional<Product> findById(Long id) {
        Product cached = redis.opsForValue().get(key(id));
        if (cached != null) {
            return Optional.of(cached);
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> redis.opsForValue().set(key(id), p, TTL));
        return fromDb;
    }

    @Transactional
    public Product updatePrice(Long id, BigDecimal newPrice) {
        Product product = repository.findById(id).orElseThrow();
        product.setPrice(newPrice);
        AfterCommit.run(() -> redis.delete(key(id)));
        return product;
    }

    static String key(Long id) {
        return "product:" + id;
    }
}

Le comportement du code ci-dessus :

  • Le premier appel à findById(1L) lit la base de données et crée la clé product:1 avec un TTL de 10 minutes.
  • Les appels suivants dans ces 10 minutes retournent depuis Redis, sans toucher la base de données.
  • updatePrice modifie l’entité, le change tracking de JPA l’écrit dans la base de données au moment du commit, puis la clé est supprimée. La lecture suivante rechargera la nouvelle valeur.

Vérifier avec redis-cli :

redis-cli GET product:1
redis-cli TTL product:1
"{\"id\":1,\"name\":\"Clavier mécanique\",\"price\":1200000.00}"
(integer) 587

Note : Lors de l’écriture, supprimez la clé au lieu de set la nouvelle valeur. Supposons que deux requêtes mettent à jour en même temps. Si les deux font set, l’ordre d’écriture dans Redis peut être inverse de l’ordre de commit dans la base de données, et le cache garde l’ancienne valeur indéfiniment. Supprimer la clé garantit que la lecture suivante obtient toujours la version la plus récente. De plus, il faut supprimer après le commit. Si on supprime avant, une requête de lecture qui intervient entre les deux pourrait recharger les anciennes données dans le cache.

Même si on fait tout correctement, cache-aside a encore une petite fenêtre de concurrence : une requête lit les anciennes données de la base de données et les écrit dans le cache après que la clé vient d’être supprimée. TTL est le filet de sécurité final pour ce cas. Ne mettez jamais en cache sans définir un TTL.

Pour des données simples, vous pouvez utiliser @Cacheable et @CacheEvict de Spring Cache pour obtenir le même résultat avec moins de code. Leur essence reste cache-aside.

4. Write-through

Avec write-through, chaque écriture met à jour à la fois la base de données et le cache de manière synchrone. La requête ne retourne que lorsque les deux sont terminées. La partie lecture reste similaire à cache-aside, mais le taux de hit est plus élevé car les données venant d’être écrites sont déjà dans Redis.

@Service
public class ProductWriteThroughService {

    private static final Duration TTL = Duration.ofMinutes(30);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;

    public ProductWriteThroughService(ProductRepository repository,
                                      RedisTemplate<String, Product> redis) {
        this.repository = repository;
        this.redis = redis;
    }

    @Transactional
    public Product save(Product product) {
        Product saved = repository.saveAndFlush(product);
        AfterCommit.run(() -> redis.opsForValue()
                .set(ProductCacheAsideService.key(saved.getId()), saved, TTL));
        return saved;
    }
}

La ligne saveAndFlush écrit dans la base de données immédiatement pour avoir l’id et détecter les erreurs de contrainte tôt. Après le commit, l’enregistrement est poussé dans Redis. Quand un produit venant d’être créé est relu, le résultat est un cache hit dès la première fois.

Avantages de write-through :

  • Le cache est toujours “chaud” avec les données venant d’être écrites, pas de cache miss initial.
  • Convient aux données souvent lues immédiatement après leur écriture, par exemple les profils Customer ou les paniers.

Inconvénients :

  • Chaque écriture coûte un aller-retour supplémentaire vers Redis, donc la latence d’écriture augmente.
  • Le cache est rempli par des enregistrements que personne ne lit. TTL aide à nettoyer, mais consomme quand même de la mémoire.
  • Il y a toujours une course quand plusieurs writers font set en même temps, comme mentionné à la section 3. Pour les données mises à jour simultanément plusieurs fois, vous devez ajouter une version, par exemple utiliser @Version et écrire par-dessus uniquement si la version est plus nouvelle, ou revenir à la suppression de clé.

Note : Si Redis échoue après que la base de données a déjà commité, le cache manquera la version la plus récente. Attrapez l’exception et supprimez la clé comme solution de secours. Ne laissez pas la requête échouer alors que la base de données a écrit avec succès.

5. Write-behind (write-back)

Avec write-behind, l’application écrit uniquement dans Redis puis revient immédiatement. Un processus d’arrière-plan collecte les modifications et les écrit dans la base de données par lots. La base de données n’est plus sur le chemin critique de la requête, donc l’écriture est très rapide. En échange, Redis devient temporairement le lieu de conservation des données principales.

@Service
public class ProductWriteBehindService {

    private static final String DIRTY_SET = "products:dirty";
    private static final Logger log = LoggerFactory.getLogger(ProductWriteBehindService.class);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;
    private final StringRedisTemplate stringRedis;

    public ProductWriteBehindService(ProductRepository repository,
                                     RedisTemplate<String, Product> redis,
                                     StringRedisTemplate stringRedis) {
        this.repository = repository;
        this.redis = redis;
        this.stringRedis = stringRedis;
    }

    public void updatePrice(Long id, BigDecimal newPrice) {
        String key = ProductCacheAsideService.key(id);
        Product product = Optional.ofNullable(redis.opsForValue().get(key))
                .or(() -> repository.findById(id))
                .orElseThrow();
        product.setPrice(newPrice);
        redis.opsForValue().set(key, product);              // pas de TTL
        stringRedis.opsForSet().add(DIRTY_SET, id.toString());
    }

    @Scheduled(fixedDelay = 5000)
    public void flush() {
        String id;
        while ((id = stringRedis.opsForSet().pop(DIRTY_SET)) != null) {
            Product product = redis.opsForValue().get(ProductCacheAsideService.key(Long.valueOf(id)));
            if (product == null) {
                continue;
            }
            try {
                repository.save(product);
            } catch (RuntimeException ex) {
                log.warn("Vidage échoué pour le produit {}, réessai plus tard", id, ex);
                stringRedis.opsForSet().add(DIRTY_SET, id);
                break;
            }
        }
    }
}

Le flux de traitement est le suivant :

  • updatePrice met à jour l’enregistrement dans Redis et ajoute l’id au set products:dirty. La requête se termine en quelques millisecondes.
  • Toutes les 5 secondes, flush() extrait chaque id avec SPOP et écrit l’enregistrement correspondant dans la base de données.
  • Si un produit est mis à jour 100 fois en 5 secondes, la base de données ne reçoit qu’une seule écriture. C’est le plus grand avantage de write-behind.
  • Quand l’écriture échoue, l’id est remis dans le set pour être réessayé ultérieurement.

C’est le modèle le plus risqué des trois stratégies. Quelques points obligatoires à gérer :

  • Perte de données en cas de panne Redis. Vous devez activer AOF avec appendfsync everysec ou always, et exécuter Redis avec une réplique. Même ainsi, vous pourriez perdre environ 1 seconde de données.
  • Éviction. Les clés dirty n’ont pas de TTL. Définissez maxmemory-policy sur noeviction ou volatile-*, sinon Redis pourrait supprimer les données qui n’ont pas eu le temps d’être vidées.
  • Plusieurs instances. Grâce à SPOP qui est atomique, deux instances ne peuvent pas extraire le même id. Cependant, si une instance meurt juste après SPOP et avant save, ce changement reste coincé dans Redis jusqu’à la prochaine mise à jour. Pour être plus certain, utilisez Redis Streams avec un consumer group et XACK.
  • Les requêtes sur la base de données voient des données obsolètes. Les rapports, d’autres jobs ou d’autres services qui lisent directement la base de données seront retardés de quelques secondes par rapport à la réalité.

Note : N’utilisez pas write-behind pour les soldes de compte, les commandes Order, les paiements via PaymentService, ou toute donnée dont la perte d’un enregistrement serait un incident. C’est approprié pour les vues, les j’aime, les positions de lecture, last-seen : les données qui sont écrites très souvent et acceptent une légère perte.

6. Test hành vi cache-aside

Le test ci-dessous utilise Redis réel s’exécutant dans Testcontainers. @ServiceConnection configure automatiquement l’hôte et le port pour Spring Boot :

@SpringBootTest
@Testcontainers
class ProductCacheAsideServiceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redisContainer =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired
    ProductCacheAsideService service;

    @Autowired
    ProductRepository repository;

    @Autowired
    RedisTemplate<String, Product> redis;

    @Test
    void shouldCacheOnReadAndEvictAfterUpdate() {
        Product saved = repository.save(new Product("Clavier mécanique", new BigDecimal("1200000")));
        String key = ProductCacheAsideService.key(saved.getId());

        service.findById(saved.getId());
        assertThat(redis.hasKey(key)).isTrue();

        service.updatePrice(saved.getId(), new BigDecimal("990000"));
        assertThat(redis.hasKey(key)).isFalse();

        Product reloaded = service.findById(saved.getId()).orElseThrow();
        assertThat(reloaded.getPrice()).isEqualByComparingTo("990000");
        assertThat(redis.hasKey(key)).isTrue();
    }
}

Le test vérifie le cycle de vie complet de la clé : créée lors de la lecture, supprimée après la mise à jour, puis rechargée avec la nouvelle valeur. La classe de test n’est pas annotée avec @Transactional. Si elle l’était, la transaction du test serait annulée, le callback afterCommit ne s’exécuterait jamais, et la deuxième assertion échouerait.

7. Comparaison et comment choisir

Critère Cache-aside Write-through Write-behind
Latence de lecture initiale Élevée (miss) Faible Faible
Latence d’écriture Faible Moyenne (écrire 2 endroits) Très faible
Cohérence avec DB Bonne (TTL comme filet de sécurité) Bonne Éventuelle, retard de quelques secondes
Risque de perte de données Non Non Oui
Complexité Faible Moyenne Élevée
Réduction de charge d’écriture DB Non Non Oui (regroupement par lot)

Quelques suggestions pour choisir :

  • Par défaut, utilisez cache-aside. C’est simple, tolérant aux pannes (si Redis meurt, on peut toujours lire la base de données), et suffisant pour la plupart des données à forte lecture et faible écriture comme les catalogues de produits ou les configurations.
  • Choisissez write-through quand les données sont souvent lues immédiatement après leur écriture et que vous voulez éviter le miss initial. En pratique, beaucoup de systèmes combinent : écriture write-through, lecture cache-aside.
  • Ne choisissez write-behind que lorsque la base de données est vraiment un goulot d’étranglement en écriture et que les données acceptent la perte. Si vous n’avez pas de mesures prouvant que la base de données est engorgée, ne payez pas le prix de cette complexité.
  • Vous n’avez pas besoin de choisir une stratégie pour tout le système. Chaque type de données peut utiliser une stratégie différente.

Quelques erreurs courantes avec les trois stratégies :

  • Cache stampede (ruée vers le cache). Une clé chaude expire, des milliers de requêtes manquent simultanément et se précipitent vers la base de données. Peut être atténué par un TTL avec jitter aléatoire, ou un verrou utilisant SET key value NX PX pour qu’une seule requête recharge.
  • Cache penetration (pénétration du cache). Les requêtes posent continuellement des questions sur un id inexistant, donc chaque fois c’est un miss. Mettez en cache le résultat vide avec un TTL court.
  • Changer la structure de classe et oublier de nettoyer le cache. L’ancien JSON ne peut pas être désérialisé dans la nouvelle classe. Ajoutez une version à la clé, par exemple product:v2:{id}.

8. Conclusion

L’article a implémenté trois stratégies de cache avec Redis et Spring Boot 3. Cache-aside charge les données à la demande et supprime la clé après le commit. Write-through synchronise les deux endroits lors de l’écriture. Write-behind sacrifie la durabilité des données pour une vitesse d’écriture élevée.

Règle en bref : commencez par cache-aside et toujours définir un TTL. Montez à write-through quand vous avez besoin d’un cache chaud. N’utilisez write-behind que pour les données acceptant la perte, avec AOF, une politique d’éviction appropriée et un mécanisme de retry clair.

rediscachespring-bootjavaperformance