Caching : cache-aside, write-through et TTL
1. Aperçu général
Le cache aide à réduire la charge sur la base de données et à raccourcir le temps de réponse. En contrepartie, vous devez accepter que les données puissent être obsolètes. Cet article présente deux stratégies de lecture/écriture de cache populaires : cache-aside et write-through. L’article explique également comment utiliser TTL (time-to-live) pour limiter l’obsolescence des données.
Après la lecture, vous serez capable d’implémenter les deux stratégies en Java pur, de passer aux annotations de Spring Cache, de configurer le TTL pour Caffeine et Redis, et d’éviter quelques pièges courants.
2. Concepts fondamentaux
Avant de nous plonger dans le code, clarifions quelques termes :
- Cache hit : les données sont disponibles dans le cache et sont retournées immédiatement.
- Cache miss : le cache ne contient pas les données, il faut lire depuis la source (base de données, autre service).
- Stale data : les données dans le cache ne correspondent plus à la base de données.
- TTL : la durée de vie d’une entrée. Passé le TTL, l’entrée est considérée comme invalide.
Toute stratégie de cache tourne autour d’une question : quand les données changent, qui est responsable de mettre à jour le cache, et quand le faire ?
Nous utilisons un domaine simple dans tout l’article :
public record Product(long id, String name, BigDecimal price) {}
public interface ProductRepository {
Optional<Product> findById(long id);
Product save(Product product);
}
3. Un cache avec TTL écrit à la main
Pour bien comprendre le mécanisme, écrivons un petit cache avec TTL. Le temps est obtenu via un Supplier<Instant>, ce qui permet aux tests de « sauter » dans le temps.
public class TtlCache<K, V> {
private record Entry<V>(V value, Instant expiresAt) {}
private final Map<K, Entry<V>> store = new ConcurrentHashMap<>();
private final Duration ttl;
private final Supplier<Instant> now;
public TtlCache(Duration ttl, Supplier<Instant> now) {
this.ttl = ttl;
this.now = now;
}
public Optional<V> get(K key) {
Entry<V> entry = store.get(key);
if (entry == null) {
return Optional.empty();
}
if (now.get().isAfter(entry.expiresAt())) {
store.remove(key, entry);
return Optional.empty();
}
return Optional.of(entry.value());
}
public void put(K key, V value) {
store.put(key, new Entry<>(value, now.get().plus(ttl)));
}
public void evict(K key) {
store.remove(key);
}
}
Comment cette classe fonctionne :
putenregistre la valeur avec le moment d’expirationexpiresAt = now + ttl.getvérifie l’expiration. Si l’entrée a expiré, elle est supprimée et la fonction retourneOptional.empty(), ce qui signifie un cache miss.store.remove(key, entry)ne supprime que l’entrée qui vient d’être lue. De cette façon, si un autre thread vient d’exécuter unputavec une nouvelle valeur, cette valeur ne sera pas supprimée par erreur.
Cette classe est suffisante à des fins de démonstration. En production, vous devriez utiliser Caffeine ou Redis (section 6).
4. Cache-aside
4.1. Idée
Avec cache-aside (aussi appelé lazy loading), l’application gère elle-même le cache :
- Lecture : cherchez d’abord dans le cache. En cas de miss, lisez depuis la base de données, écrivez le résultat dans le cache, puis retournez-le.
- Écriture : mettez à jour la base de données d’abord, puis supprimez l’entrée du cache. La prochaine lecture rechargera les nouvelles données.
public class ProductService {
private final ProductRepository repository;
private final TtlCache<Long, Product> cache;
public ProductService(ProductRepository repository, TtlCache<Long, Product> cache) {
this.repository = repository;
this.cache = cache;
}
public Optional<Product> findById(long id) {
Optional<Product> cached = cache.get(id);
if (cached.isPresent()) {
return cached; // cache hit
}
Optional<Product> fromDb = repository.findById(id);
fromDb.ifPresent(p -> cache.put(id, p)); // charger dans le cache
return fromDb;
}
public Product updatePrice(long id, BigDecimal newPrice) {
Product current = repository.findById(id).orElseThrow();
Product saved = repository.save(new Product(id, current.name(), newPrice));
cache.evict(id); // supprimer après la fin de l'écriture en BD
return saved;
}
}
Explication :
findByIdn’appellerepositoryqu’en cas de cache miss. Si le produit n’existe pas, nous ne mettons rien en cache.updatePriceécrit en base de données d’abord, puis exécuteevict. Cet ordre est important, la section 8 en explique la raison.- Nous choisissons de supprimer l’entrée plutôt que de la remplacer par une nouvelle valeur. Cette approche est plus simple car le cache n’est alimenté que d’un seul endroit : le chemin de lecture.
4.2. Quand l’utiliser
Cache-aside convient quand :
- Les lectures sont beaucoup plus fréquentes que les écritures, par exemple les catalogues de produits ou les profils d’utilisateurs.
- Vous ne voulez mettre en cache que les données réellement accédées.
- Le système doit continuer à fonctionner si le cache tombe. À ce moment, chaque requête va directement à la base de données : plus lent, mais sans erreur.
Inconvénient : la première requête après chaque miss est toujours lente. De plus, si un autre processus écrit directement dans la base de données sans supprimer le cache, les données resteront obsolètes jusqu’à l’expiration du TTL.
5. Write-through
5.1. Idée
Avec write-through, chaque opération d’écriture passe par la couche cache. Cette couche écrit de manière synchrone dans la base de données, puis met à jour le cache avec la nouvelle valeur. Quand la commande d’écriture retourne, le cache et la base de données sont synchronisés.
public class WriteThroughProductStore {
private final ProductRepository repository;
private final TtlCache<Long, Product> cache;
public WriteThroughProductStore(ProductRepository repository, TtlCache<Long, Product> cache) {
this.repository = repository;
this.cache = cache;
}
public Product save(Product product) {
Product saved = repository.save(product); // 1. écrire en BD (de manière synchrone)
cache.put(saved.id(), saved); // 2. mettre à jour le cache
return saved;
}
public Optional<Product> findById(long id) {
return cache.get(id).or(() -> {
Optional<Product> fromDb = repository.findById(id);
fromDb.ifPresent(p -> cache.put(id, p));
return fromDb;
});
}
}
Différences par rapport à cache-aside :
saveremplace le cache par la valeur qui vient d’être enregistrée plutôt que de la supprimer. La prochaine lecture sera un cache hit.- Nous n’écrivons dans le cache que si
repository.saveréussit. Si la base de données lève une exception, le cache n’est pas touché, donc il n’y a aucun risque qu’il contienne des données jamais enregistrées. findByIda toujours besoin de la branche fallback, car l’entrée peut avoir expiré ou ne jamais avoir été écrite viasave.
5.2. Quand l’utiliser
Write-through convient quand les données viennent d’être écrites et sont relues immédiatement après, par exemple un panier ou les paramètres d’un utilisateur. Le prix à payer :
- Chaque écriture est un peu plus lente car elle doit être écrite à deux endroits.
- Le cache peut contenir beaucoup de données que personne ne lit. Vous devriez le combiner avec un TTL et une limite de taille.
Remarque : Ne confondez pas write-through avec write-behind (write-back). Write-behind écrit d’abord dans le cache, puis dans la base de données de manière asynchrone. C’est plus rapide, mais si le nœud cache meurt avant de pouvoir faire un flush, vous perdez les données. N’utilisez write-behind que si vous acceptez ce risque.
6. Utiliser Spring Cache
Écrire à la main aide à comprendre le mécanisme, mais dans un projet réel, vous devriez utiliser Spring Cache avec Caffeine (cache local) ou Redis (cache distribué). Ajoutez la dépendance :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
Activez le cache avec @EnableCaching :
@SpringBootApplication
@EnableCaching
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
6.1. Cache-aside avec @Cacheable et @CacheEvict
@Service
public class CatalogService {
private final ProductRepository repository;
public CatalogService(ProductRepository repository) {
this.repository = repository;
}
@Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
public Optional<Product> findById(long id) {
return repository.findById(id);
}
@CacheEvict(cacheNames = "products", key = "#product.id")
public Product update(Product product) {
return repository.save(product);
}
}
@Cacheableest cache-aside sous forme déclarative. Spring vérifie le cache d’abord, en cas de miss il appelle la méthode puis enregistre le résultat.- Avec le type de retour
Optional, Spring met en cache la valeur à l’intérieur.unless = "#result == null"empêche la mise en cache du cas où rien n’est trouvé. @CacheEvicts’exécute par défaut après que la méthode retourne avec succès. C’est exactement l’ordre « écrire en BD puis supprimer du cache ».
6.2. Write-through avec @CachePut
@CachePut(cacheNames = "products", key = "#result.id")
public Product save(Product product) {
return repository.save(product);
}
@CachePut exécute toujours la méthode, puis écrit la valeur retournée dans le cache. C’est équivalent à WriteThroughProductStore.save de la section 5.
Remarque : Les annotations de cache fonctionnent via un proxy. Si vous appelez
this.findById(...)depuis une autre méthode dans la même classe, le cache est complètement contourné. Appelez via un autre bean à la place.
7. Configuration du TTL
7.1. Caffeine
spring:
cache:
type: caffeine
cache-names: products
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m
expireAfterWrite=10m: l’entrée expire 10 minutes après la dernière écriture.maximumSize=10000: quand la limite est dépassée, Caffeine supprime les entrées les moins utilisées. Définissez toujours cette limite pour éviter que le cache consomme toute la heap.
7.2. Redis
Ajoutez spring-boot-starter-data-redis, puis configurez :
spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=shop::
Redis supprime automatiquement les clés quand le TTL expire. Ce cache est partagé entre plusieurs instances de l’application.
7.3. Quel TTL choisir ?
Il n’y a pas de chiffre qui convient à tous les cas. Demandez-vous : Pendant combien de temps les données peuvent-elles être obsolètes au maximum ?
Taux de change, inventaire : quelques secondes à 1 minute
Prix des produits, catalogue : 5 à 30 minutes
Configuration, liste des provinces/villes : quelques heures à 1 jour
Un TTL court donne des données plus fraîches mais un taux de hit plus faible. Un TTL long c’est l’inverse. Même si vous effectuez des suppressions proactives, vous devriez toujours définir un TTL comme filet de sécurité pour les suppressions manquées.
7.4. Ajouter du jitter pour éviter l’expiration simultanée
Supposons que vous chargiez 10 000 produits en même temps avec un TTL de 10 minutes. Ils expireront tous en même temps, et la base de données recevra une rafale de requêtes. Ce phénomène s’appelle cache stampede. Le remède est d’ajouter un délai aléatoire au TTL :
public void put(K key, V value) {
long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 60);
Instant expiresAt = now.get().plus(ttl).plusSeconds(jitterSeconds);
store.put(key, new Entry<>(value, expiresAt));
}
Avec Spring + Caffeine, vous pouvez ajouter @Cacheable(..., sync = true). Quand plusieurs threads manquent la même clé, seul un thread est appelé vers la base de données, les autres threads attendent le résultat.
8. Écrire des tests
Nous utilisons un repository factice pour compter le nombre d’accès à la base de données, et une horloge contrôlable :
class ProductServiceTest {
static class CountingRepository implements ProductRepository {
final Map<Long, Product> data = new HashMap<>();
int findCalls = 0;
public Optional<Product> findById(long id) {
findCalls++;
return Optional.ofNullable(data.get(id));
}
public Product save(Product p) {
data.put(p.id(), p);
return p;
}
}
AtomicReference<Instant> clock = new AtomicReference<>(Instant.parse("2026-01-01T00:00:00Z"));
CountingRepository repository = new CountingRepository();
ProductService service = new ProductService(
repository, new TtlCache<>(Duration.ofMinutes(10), clock::get));
@BeforeEach
void setUp() {
repository.save(new Product(1L, "Nón lá", new BigDecimal("50000")));
}
@Test
void secondReadIsServedFromCache() {
service.findById(1L);
service.findById(1L);
assertThat(repository.findCalls).isEqualTo(1);
}
@Test
void entryExpiresAfterTtl() {
service.findById(1L);
clock.set(clock.get().plus(Duration.ofMinutes(11)));
service.findById(1L);
assertThat(repository.findCalls).isEqualTo(2);
}
@Test
void updateEvictsStaleValue() {
service.findById(1L);
service.updatePrice(1L, new BigDecimal("45000"));
assertThat(service.findById(1L))
.get()
.extracting(Product::price)
.isEqualTo(new BigDecimal("45000"));
}
}
secondReadIsServedFromCache: la deuxième lecture ne touche pas la base de données.entryExpiresAfterTtl: après avoir avancé l’horloge de plus de 10 minutes, le cache miss et la base de données est appelée à nouveau.updateEvictsStaleValue: après la mise à jour, le lecteur voit le nouveau prix, pas l’ancien prix du cache.
9. Erreurs courantes
Supprimer le cache avant d’écrire en base de données. Considérez ce scénario : le thread A supprime le cache. Le thread B lit, rate le cache, récupère la vieille valeur de la base de données et l’écrit dans le cache. Ensuite, le thread A écrit la nouvelle valeur dans la base de données. Le résultat est que le cache conserve l’ancienne valeur jusqu’à son expiration. Écrivez d’abord en base de données, supprimez ensuite le cache.
Cache à l’intérieur d’une transaction non validée. Si vous exécutez @CachePut dans une méthode @Transactional et que la transaction est annulée, le cache contiendra des données qui n’existent pas dans la base de données. Placez l’opération de cache en dehors de la transaction, ou utilisez TransactionAwareCacheManagerProxy.
Cache local sur plusieurs instances. Caffeine réside dans la JVM de chaque instance. @CacheEvict sur l’instance A ne peut pas supprimer le cache de l’instance B. Quand vous exécutez plusieurs instances et avez besoin de cohérence, utilisez Redis ou acceptez l’obsolescence avec un TTL court.
Cache d’objets mutables. Caffeine retourne la référence exacte qui se trouve dans le cache. Si votre code modifie cet objet, vous modifiez aussi le cache. Utilisez des record ou des objets immuables comme dans l’exemple de cet article.
Remarque : Ne mettez pas en cache lorsque les données changent constamment et qu’une cohérence absolue est requise, par exemple le solde d’un compte ou le nombre de places libres au moment du paiement. Dans ces cas, lire directement la base de données avec les verrous appropriés est plus sûr.
10. Conclusion
L’article a comparé les deux principales stratégies de cache :
- Cache-aside : l’application charge elle-même le cache en cas de miss et le supprime après l’écriture. C’est une approche simple et résiliente, adaptée à la plupart des cas de lecture intensive.
- Write-through : chaque opération d’écriture met à jour de manière synchrone la base de données et le cache, en échange de données fraîches immédiatement après l’écriture.
Quelle que soit la stratégie choisie, vous avez toujours besoin d’un TTL comme filet de sécurité, vous devriez limiter la taille du cache et ajouter du jitter pour éviter les stampedes. Commencez par @Cacheable + Caffeine. Quand votre application est déployée sur plusieurs instances, passez à Redis, et ayez toujours des tests prouvant que le cache réduit réellement le nombre d’accès à la base de données.