Maintenir la cohérence entre Redis et la base de données
1. Aperçu
Placer Redis devant la base de données aide à réduire la charge et à accélérer les lectures. En contrepartie, vous avez deux endroits qui stockent la même donnée, et ils peuvent diverger. L’utilisateur change le prix d’un produit, mais la page de détail affiche toujours l’ancien prix pendant 10 minutes. C’est une erreur de cohérence typique.
Cet article utilise le modèle cache-aside : à la lecture, on prend d’abord le cache, à l’écriture, on met à jour la base de données puis on supprime le cache après le commit de la transaction. Nous examinerons les race conditions restantes et comment les réduire. À la fin de l’article, vous trouverez un test d’intégration avec un vrai Redis exécuté via Testcontainers.
2. Pourquoi le cache et la base de données divergent
La base de données et Redis ne partagent pas une transaction. Il n’existe aucun moyen d’écrire dans les deux de façon atomique. L’ordre des opérations et le moment où elles se produisent déterminent si les données divergent ou non.
Trois erreurs les plus courantes :
- Mettre à jour le cache au lieu de le supprimer. Deux requêtes d’écriture concurrentes pourraient écrire dans la base de données dans l’ordre A→B, mais dans le cache dans l’ordre B→A. Le cache conserve la valeur de A indéfiniment, ou jusqu’à l’expiration du TTL.
- Supprimer le cache avant le commit. Une requête de lecture s’insère entre la suppression du cache et le commit. Cette requête lit l’ancienne valeur de la base de données puis la recharge dans le cache.
- La suppression du cache échoue et personne ne le sait. Dépassement d’attente Redis, exception avalée, et le cache conserve les anciennes données.
Les sections suivantes traitent chacune de ces erreurs successivement.
3. Mise en place du projet
Ajoutez les dépendances pour Redis, JPA et une base de données pour tester :
<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>
Configurez la connexion Redis :
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=500ms
Le court délai d’attente est intentionnel. Si Redis est lent, vous voulez que la requête bascule rapidement vers la base de données, sans rester bloquée pendant plusieurs secondes.
Entité et repository :
@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; }
public Long getId() { return id; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
}
public interface ProductRepository extends JpaRepository<Product, Long> {}
Au lieu de mettre en cache directement l’entité, nous mettons en cache un enregistrement immuable :
public record ProductView(Long id, String name, BigDecimal price) {
static ProductView from(Product p) {
return new ProductView(p.getId(), p.getName(), p.getPrice());
}
}
Cette approche évite les erreurs de sérialisation du proxy Hibernate. Elle maintient aussi le format du cache indépendant de la structure de la table.
4. Lire selon cache-aside
@Service
public class ProductService {
private static final Duration TTL = Duration.ofMinutes(10);
private final ProductRepository repository;
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
private final ApplicationEventPublisher events;
public ProductService(ProductRepository repository, StringRedisTemplate redis,
ObjectMapper mapper, ApplicationEventPublisher events) {
this.repository = repository;
this.redis = redis;
this.mapper = mapper;
this.events = events;
}
static String key(Long id) { return "product:" + id; }
public ProductView findById(Long id) {
String cached = redis.opsForValue().get(key(id));
if (cached != null) {
return read(cached);
}
ProductView view = repository.findById(id)
.map(ProductView::from)
.orElseThrow(() -> new NoSuchElementException("Product " + id));
redis.opsForValue().set(key(id), write(view), TTL);
return view;
}
private ProductView read(String json) {
try { return mapper.readValue(json, ProductView.class); }
catch (JsonProcessingException e) { throw new IllegalStateException(e); }
}
private String write(ProductView view) {
try { return mapper.writeValueAsString(view); }
catch (JsonProcessingException e) { throw new IllegalStateException(e); }
}
}
Le flux de traitement est le suivant :
redis.opsForValue().get(...)essaie de récupérer du cache. S’il existe, le retourner immédiatement. C’est un cache hit.- Si non (cache miss), le service lit de la base de données et écrit dans Redis avec un TTL de 10 minutes.
- TTL est un filet de sécurité. Tous les mécanismes dans les sections suivantes peuvent échouer. TTL garantit que les données incorrectes n’existent pas plus de 10 minutes.
Au premier appel avec id = 1, Redis contient :
127.0.0.1:6379> GET product:1
"{\"id\":1,\"name\":\"Ban phim co\",\"price\":1200000}"
127.0.0.1:6379> TTL product:1
(integer) 598
5. Écrire : mettre à jour la base de données, puis supprimer le cache après commit
5.1. Supprimer au lieu de mettre à jour
Quand le prix change, on supprime la clé au lieu d’écrire le nouveau prix dans le cache. La prochaine lecture rechargera les données de la base de données, qui est la source de vérité. Supprimer est une opération idempotente : supprimer deux fois donne toujours le même résultat. L’ordre entre les commandes de suppression n’a pas d’importance non plus. Grâce à cela, l’erreur « écriture hors ordre » de la section 2 disparaît.
5.2. Attendre le commit de la transaction
Si vous appelez redis.delete(...) à l’intérieur d’une méthode @Transactional, la commande delete s’exécute avant le commit. Voici un scénario qui peut se produire :
T1 (écriture) T2 (lecture)
UPDATE price = 900 (pas encore committée)
DEL product:1
GET product:1 -> miss
SELECT -> price = 1200 (ancienne valeur)
SET product:1 = 1200
COMMIT
=> le cache conserve 1200 jusqu'à l'expiration du TTL
La solution est de publier un événement et de le gérer dans la phase AFTER_COMMIT :
public record ProductChangedEvent(Long productId) {}
Ajoutez une méthode d’écriture à ProductService :
@Transactional
public void updatePrice(Long id, BigDecimal newPrice) {
Product product = repository.findById(id).orElseThrow();
product.setPrice(newPrice);
events.publishEvent(new ProductChangedEvent(id));
}
Le listener est responsable de la suppression du cache :
@Component
public class ProductCacheEvictor {
private static final Logger log = LoggerFactory.getLogger(ProductCacheEvictor.class);
private final StringRedisTemplate redis;
public ProductCacheEvictor(StringRedisTemplate redis) {
this.redis = redis;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductChanged(ProductChangedEvent event) {
evict(ProductService.key(event.productId()));
}
void evict(String key) {
try {
redis.delete(key);
} catch (RuntimeException e) {
log.warn("Impossible de supprimer le cache {}, on comptera sur le TTL", key, e);
}
}
}
Quelques points importants :
@TransactionalEventListener(phase = AFTER_COMMIT)ne s’exécute que si la transaction commit avec succès. Si la transaction se déroule en arrière, le cache ne sera pas supprimé. C’est correct, car les données n’ont pas changé.- L’erreur lors de la suppression du cache ne casse pas la requête d’écriture. La base de données a déjà committée, lever une exception maintenant fait seulement croire au client que l’opération a échoué.
- Le journal d’avertissement vous aide à surveiller la fréquence des erreurs. Entre-temps, TTL limite la durée pendant laquelle les anciennes données existent.
Note : Si
publishEventest appelée à un endroit sans transaction,@TransactionalEventListenerpar défaut ignorera l’événement et le cache ne sera jamais supprimé. Assurez-vous que la méthode d’écriture a@Transactional. Si nécessaire, définissezfallbackExecution = true.
6. Race conditions restantes et double suppression retardée
Même avec la suppression après commit, il existe toujours un scénario rare :
T2 (lecture) T1 (écriture)
GET product:1 -> miss
SELECT -> price = 1200
UPDATE price = 900, COMMIT
DEL product:1
SET product:1 = 1200 <- écriture tardivement
T2 lit l’ancienne valeur avant que T1 ne commit, mais écrit dans le cache après que T1 l’a déjà supprimé. Ce scénario nécessite que T2 soit lent au moment exact, par exemple en raison d’une pause GC ou d’une interruption réseau. C’est rare mais c’est possible.
Un moyen simple d’atténuer ce problème est la double suppression retardée : supprimer une fois de plus après un court intervalle.
@Component
public class DelayedCacheEvictor {
private final ProductCacheEvictor evictor;
private final TaskScheduler scheduler;
public DelayedCacheEvictor(ProductCacheEvictor evictor, TaskScheduler scheduler) {
this.evictor = evictor;
this.scheduler = scheduler;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void scheduleSecondDelete(ProductChangedEvent event) {
String key = ProductService.key(event.productId());
scheduler.schedule(() -> evictor.evict(key),
Instant.now().plusMillis(500));
}
}
Spring Boot configure automatiquement un TaskScheduler quand vous activez @EnableScheduling sur une classe de configuration. La deuxième suppression après 500 ms balayera l’ancienne valeur que T2 a écrite tardivement. Choisissez un délai plus long que le temps de lecture typique de la base de données pour vous.
Note : La double suppression retardée ne rétrécit que la fenêtre d’erreur, elle ne l’élimine pas complètement. L’horaire vit en mémoire, il est donc perdu si l’application redémarre. Ne le traitez pas comme une garantie absolue.
7. Quand la suppression du cache doit certainement réussir
Avec des données sensibles comme le solde ou l’inventaire, « s’appuyer sur TTL » peut ne pas être acceptable. Il existe deux directions populaires :
- Transaction outbox : écrivez une ligne
cache_invalidationdans la base de données elle-même, dans la même transaction que la modification métier. Un travail périodique lit ce tableau, supprime la clé dans Redis, et ne marque la ligne comme terminée que quand la suppression réussit. Puisque la ligne outbox commit avec les données, l’événement d’invalidation du cache n’est jamais perdu. - Change Data Capture (CDC) : un processus lit le binlog/WAL de la base de données et supprime le cache correspondant. Cette approche sépare complètement la logique du cache du code métier, mais l’infrastructure devient plus complexe.
Les deux offrent une cohérence finale. Ils garantissent que le cache sera finalement correct, mais pas correct immédiatement.
8. Tester avec un vrai Redis
Mocker StringRedisTemplate ne peut pas capturer les erreurs concernant le timing du commit. Testez avec un vrai Redis via Testcontainers :
<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>
@SpringBootTest
@Testcontainers
class ProductServiceIT {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redisContainer =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired ProductService productService;
@Autowired ProductRepository repository;
@Autowired StringRedisTemplate redis;
@Test
void updatePrice_evictsCache_andNextReadReturnsNewPrice() {
Long id = repository.save(new Product("Ban phim co", new BigDecimal("1200000"))).getId();
productService.findById(id); // charger en cache
assertThat(redis.hasKey(ProductService.key(id))).isTrue();
productService.updatePrice(id, new BigDecimal("900000"));
assertThat(redis.hasKey(ProductService.key(id))).isFalse();
assertThat(productService.findById(id).price())
.isEqualByComparingTo("900000");
}
}
Ce test vérifie trois choses :
@ServiceConnection(name = "redis")pointe automatiquementspring.data.redis.*vers le conteneur, donc vous n’avez pas besoin de configuration supplémentaire.- Après
updatePrice, la clé n’est plus dans Redis. Cela prouve que le listenerAFTER_COMMITa fonctionné. - La prochaine lecture retourne le nouveau prix de la base de données.
Note : N’ annotez pas cette méthode de test avec
@Transactional. Spring déroulera la transaction du test à la fin, doncAFTER_COMMITne sera jamais déclenché et l’assertion échouera.
Résultat lors de l’exécution :
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
9. Quand ne pas utiliser cette approche
- Les données exigent une cohérence forte, comme le solde du compte utilisé pour décider s’il faut autoriser une transaction. Ne les lisez pas depuis le cache. Lisez directement de la base de données avec les verrous appropriés.
- Les données sont écrites plus qu’elles ne sont lues. Le cache est constamment invalidé donc le taux de hit est faible. Vous payez le coût de la complexité pour presque rien.
- La clé est composée de nombreuses entités, comme la liste « top 10 produits ». Difficile de savoir quelle clé supprimer quand un produit change. Pour ce type de clé, un TTL court est souvent plus simple et plus sûr que l’invalidation précise.
- Plusieurs services écrivent la même table. Les événements de votre application ne voient pas les modifications des autres services. Ici CDC a plus de sens.
10. Conclusion
Garder Redis et la base de données cohérents ne signifie pas les rendre toujours parfaitement synchronisés. L’objectif est de rendre la fenêtre de divergence petite et bornée. La formule de base comprend quatre étapes :
- Lire selon cache-aside, toujours définir TTL.
- Écrire dans la base de données, puis supprimer la clé, ne pas la mettre à jour.
- Supprimer uniquement après commit, en utilisant
@TransactionalEventListener(phase = AFTER_COMMIT). - Ajouter une double suppression retardée, outbox ou CDC selon le niveau de sensibilité des données.
Enfin, testez avec un vrai Redis et ne laissez pas @Transactional sur le test masquer le comportement post-commit.