Dans le domaine de la conception de systèmes haute disponibilité, la "Stampede de Cache" (également connue sous le nom de "Troupeau Furieux" ou "Thundering Herd") est l'un des tueurs de performance les plus insidieux. Elle se produit lorsqu'une clé de cache très sollicitée expire simultanément, provoquant des milliers de requêtes concurrentes qui contournent le cache et atteignent la base de données backend ou le service amont. Cette soudaine augmentation de charge peut submerger votre infrastructure, entraînant une latence accrue, l'épuisement du pool de connexions à la base de données et des pannes de service potentielles.
Pour les développeurs intermédiaires à avancés, définir simplement une courte durée d'expiration n'est pas une solution viable en raison des exigences élevées de taux de réussite (hit-rate) des applications modernes. Nous devons plutôt mettre en œuvre des stratégies robustes pour atténuer ce risque. Dans cet article, nous explorerons deux techniques complémentaires : le verrouillage distribué et l'expiration probabiliste anticipée.
Anatomie d'une stampede de cache
Pour résoudre le problème, nous devons d'abord comprendre son mécanisme. Prenons un scénario où une page produit populaire a une TTL (Time-To-Live ou durée de vie) de cache de 60 secondes. À t=60s, la clé expire. Si 10 000 utilisateurs demandent cette page à la même milliseconde, les 10 000 threads vérifieront le cache, ne trouveront rien et passeront à la base de données. Même si vous disposez d'une base de données capable de gérer 1 000 requêtes par seconde, 10 000 requêtes concurrentes créeront un goulot d'étranglement, potentiellement faisant planter l'application.
L'objectif est de s'assurer qu'une seule requête est responsable de la régénération des données, tandis que les autres requêtes attendent ou reçoivent une version périmée.
Stratégie 1 : Verrouillage distribué avec Redlock
L'approche la plus simple consiste à acquérir un verrou distribué avant de tenter de régénérer le cache. Si le verrou est acquis, la requête génère les données et met à jour le cache. Si le verrou est déjà détenu, la requête attend brièvement ou retourne une valeur mise en cache périmée.
Lors de la mise en œuvre de cette approche dans Redis, il est crucial d'utiliser des opérations atomiques pour éviter les conditions de course. La commande SET avec les drapeaux NX (Set if Not eXists / Définir si n'existe pas) et EX (Expiration) est la méthode standard pour implémenter un verrou en toute sécurité.
import redis
def get_data_with_lock(redis_client, key, data_fetcher):
# Tentative d'acquisition du verrou avec NX et expiration pour éviter les verrous morts
# EX définit la TTL en secondes, assurant la libération du verrou si le processus plante
if redis_client.set(lock_key, "locked", nx=True, ex=5):
try:
# Un seul thread entre ici
data = data_fetcher()
redis_client.set(key, data, ex=60) # Définit la TTL du cache
return data
finally:
# Libération du verrou
redis_client.delete(lock_key)
else:
# Un autre thread actualise le cache
# Option A : Attendre et réessayer (bloquant)
# Option B : Retourner le cache périmé ou une erreur (non bloquant)
return redis_client.get(key)
Bien que efficace, cette approche introduit une latence pour tous les threads en attente. Dans les scénarios à haute concurrence, les threads bloquants peuvent consommer des ressources significatives. Une solution plus élégante pour les charges de travail à dominance de lecture est l'expiration probabiliste.
Stratégie 2 : Expiration probabiliste anticipée
Au lieu de laisser le cache expirer brusquement à t=60, nous pouvons utiliser une approche probabiliste. Nous définissons la TTL du cache sur une plage variable, de sorte que l'expiration est susceptible de se produire légèrement avant l'heure nominale. Cela répartit les demandes de régénération sur une fenêtre de temps, lissant ainsi la charge sur la base de données.
Nous pouvons également mettre en œuvre un mécanisme de "rafraîchissement paresseux" (lazy refresh). Lorsqu'une clé est accédée, nous vérifions si elle approche de l'expiration (par exemple, dans les 10 % restants de sa TTL). Si c'est le cas, nous lançons un thread ou une tâche en arrière-plan pour actualiser les données de manière asynchrone, tandis que la requête actuelle retourne les données existantes (légèrement périmées).
import time
import random
def get_data_probabilistic(redis_client, key, data_fetcher):
data = redis_client.get(key)
if not data:
# Cache miss : régénérer et définir
new_data = data_fetcher()
# Ajout d'une petite variation aléatoire à la TTL (par ex. 60s à 70s)
ttl = random.randint(60, 70)
redis_client.set(key, new_data, ex=ttl)
return new_data
# Cache hit : vérifier si nous devons rafraîchir anticipativement
ttl_remaining = redis_client.ttl(key)
# Si la TTL restante est inférieure à 10 % de la TTL maximale d'origine, rafraîchir de manière asynchrone
if ttl_remaining < 7:
# En production, utilisez ici une file de messages ou un exécuteur de tâches asynchrones
schedule_async_refresh(key, data_fetcher)
return data
def schedule_async_refresh(key, data_fetcher):
# Cette fonction doit s'exécuter dans un thread/processus séparé
# pour éviter de bloquer le thread de la requête principale
new_data = data_fetcher()
redis_client.set(key, new_data, ex=60)
Conclusion
Prévenir les stampedes de cache ne consiste pas à choisir une solution parfaite unique, mais à sélectionner l'outil adapté à vos modèles de trafic spécifiques. Le verrouillage distribué offre une forte cohérence mais peut introduire une latence. L'expiration probabiliste offre une haute disponibilité et une faible latence au prix d'une légère péremption des données.
Pour de nombreux systèmes de production, une approche hybride est la meilleure : utiliser des TTL courts avec une variation aléatoire (jitter) pour répartir les expirations, combinée à des rafraîchissements asynchrones en arrière-plan. En mettant en œuvre ces stratégies, vous garantissez que votre infrastructure reste résiliente sous les charges les plus lourdes, maintenant une expérience utilisateur fluide même lors des pics de trafic.