Accueil / Articles

Cache Stampedes dans Node.js : pourquoi un cache Redis peut surcharger votre base de données

Découvrez pourquoi une configuration naïve de Redis avec cache-aside synchronise la charge sur votre base de données lorsque une clé fréquemment utilisée expire, et comment les verrous ainsi que le renouvellement en arrière-plan permettent d’y remédier.

Éditeur : REACTAPP.TOP7 min de lecture1648 mots

Sources: “I Thought Redis Would Make My API Faster.” It Didn’t.

Insérer Redis avant une requête lente constitue la solution classique pour gérer une base de données sous pression, et en temps normal cela semble être une victoire évidente. Cependant, en période de trafic maximal, une configuration simple basée sur le cache peut aggraver considérablement la situation : dès que une clé populaire expire, toutes les requêtes simultanées échouent en même temps, sollicitant fortement la base de données. Cet article explique ce type d’échec, connu sous le nom de « cache stampede », décrit comment il se propage à travers une API Node.js, et présente deux méthodes pour l’éviter : un verrou de régénération distribué et une pré-computation en arrière-plan.

Une solution efficace jusqu’au pic de trafic

La configuration habituelle est celle basée sur le cache. À chaque requête, l’API vérifie d’abord Redis ; en cas d’échec, elle interroge la base de données puis écrit le résultat dans Redis pour une utilisation ultérieure. Déployée en période de faible trafic, elle réduit immédiatement la latence.

Puis arrive l’affluence du lundi matin et l’API cesse de répondre. Le processus Node.js affiche une consommation CPU faible, donc le cycle d’événements n’est pas la cause probable. Cependant, la base de données principale est à 100 % de consommation CPU.

Redis n’est pas le goulot d’étranglement

Redis lui-même est rarement le problème. Il gère des centaines de milliers d’opérations par seconde avec une latence inférieure à un milliardième de seconde. Ce qui pose problème, c’est la relation entre l’application et le cache, plus précisément le comportement de l’application lorsque les données manquent.

Anatomie d’une ruée

Prenons un point de terminaison tel que GET /products/trending. La génération de sa réponse implique plusieurs jointures, sous-requêtes et quelques opérations d’agrégation en Node.js, ce qui prend environ 1,2 seconde. Le résultat est mis en cache pendant dix minutes.

En état stationnaire, c’est très efficace : sur dix mille requêtes, une seule atteint la base de données et les autres sont servies depuis Redis en environ 5 ms.

Supposons maintenant que la durée TTL de dix minutes s’épuise à 09:00:00. À 09:00:01, cinquante requêtes arrivent simultanément. Chacune d’elles échoue et déclenche la même requête de 1,2 seconde. La base de données doit alors exécuter cinquante requêtes lourdes en parallèle. Le pool de connexions s’épuise presque instantanément, et toutes les autres requêtes API nécessitant la base de données, même pour des données non liées, se retrouvent en file d’attente derrière ces cinquante.

Il est important de préciser ici que l’attente d’une connexion du pool ne bloque pas le cycle d’événements de Node.js, car ces attentes sont asynchrones. Ce qui se produit en réalité, c’est que les requêtes s’accumulent sous forme de promesses en attente et de sockets ouverts, ce qui fait que le processus semble inactif tandis que les utilisateurs rencontrent des délais d’exécution.

C’est ce qu’on appelle l’effet de ruée sur le cache, ou encore effet dogpile. Le cache n’a pas réduit la charge ; il a plutôt synchronisé un pic de charge sur un seul instant donné.

Pourquoi cela dégénère plutôt que de se rétablir

Une ruée n’est pas simplement une période de ralentissement. Elle se nourrit d’elle-même.

  • Les temps de requête explosent. Une requête qui prenait 1,2 seconde peut s’allonger à 25 secondes lorsque cinquante instances se disputent la CPU, les verrous et les opérations d’entrée/sortie.
  • Les instances API maintiennent des milliers de connexions HTTP ouvertes en attendant, consommant ainsi de la mémoire et des descripteurs de fichiers.
  • La CPU au niveau de l’API reste faible, ce qui fait qu’un mécanisme d’auto-échelle basé sur la CPU ne ajoutera jamais de capacité ; en outre, ajouter des instances API n’aurait pour effet que d’envoyer davantage de requêtes à la base de données.
  • De nouvelles requêtes continuent d’arriver sans être traitées, car les requêtes lentes n’ont pas encore écrit de valeur en retour. Finalement, la base de données cesse complètement de fonctionner en raison d’une épuisement de la mémoire ou de délais de connexion trop longs.

Prévenir les ruées

Ajouter un stockage plus rapide ne peut pas résoudre ce qui est en réalité un problème de coordination. La solution consiste à modifier les personnes autorisées à régénérer des données coûteuses, ainsi que le moment où cela peut se faire.

Modèle 1 : verrou de régénération distribué

Lorsqu’une API fonctionne sous plusieurs instances, la régénération doit être coordonnée entre toutes, ce qui nécessite un verrou stocké dans Redis. La première requête qui ne trouve pas les données en cache tente de prendre le verrou avant d’interroger la base de données. Toute autre requête qui échoue tant que le verrou est détenu s’éloigne de la base de données et effectue l’une des deux actions suivantes :

  • Attente et tentative répétée : elle attend brièvement, par exemple 50 ms, puis vérifie à nouveau Redis jusqu’à ce que la nouvelle valeur apparaisse.
  • Fourniture de données obsolètes : elle renvoie la valeur précédente, si le produit peut tolérer des résultats légèrement anciens.

L’exemple ci-dessous utilise ioredis. Il lit d’abord la mémoire cache. En cas d’échec, il tente l’opération SET avec les paramètres NX et PX 5000, ce qui crée le verrou uniquement si personne d’autre ne le détient et le fait expirer après cinq secondes. Le processus gagnant exécute la requête, stocke le résultat pendant 600 secondes et libère le verrou dans un bloc finally ; tous les autres processus vérifient régulièrement jusqu’à ce que la valeur soit mise à jour.

// Example: Using Redis for a locking/syncing pattern (Conceptual)
import { Redis } from 'ioredis';
const redis = new Redis();


async function getCachedTrendingProducts() {
  const cacheKey = 'products:trending';
  const lockKey = 'lock:products:trending';
  // Attempt 1: Get data
  let data = await redis.get(cacheKey);
  if (data) return JSON.parse(data);
  // Attempt 2: Cache miss. Try to acquire the regeneration lock.
  const acquiredLock = await redis.set(lockKey, 'locked', 'NX', 'PX', 5000); // 5s lock
  if (acquiredLock) {
    // We hold the lock. We are the ONLY worker hitting the DB.
    try {
      // 1.2s Database Operation
      const dbData = await fetchTrendingProductsFromDB();
      // Store in Redis (10 min TTL)
      await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
      return dbData;
    } finally {
      // Release lock
      await redis.del(lockKey);
    }
  } else {
    // We did NOT get the lock. Someone else is already regenerating.
    // Wait and Retry (e.g., exponential backoff or simple polling)
    return pollRedisUntilPopulated(cacheKey);
  }
}

Le résultat est qu’au plus une requête coûteuse par clé atteint la base de données à la fois, quel que soit le nombre de requêtes arrivant simultanément.

Quelques améliorations méritent d’être envisagées en environnement de production :

  • L’expiration du verrou doit dépasser le temps maximal d’exécution d’une requête. Si sa régénération prend plus de cinq secondes, le verrou expire et un deuxième processus lance une requête identique.
  • La suppression inconditionnelle du verrou peut entraîner la perte d’un verrou acquis par un autre processus après l’expiration du vôtre. Le stockage d’un jeton unique en tant que valeur de verrou et sa suppression uniquement si ce dernier correspond encore, généralement à l’aide d’un petit script Lua, permet d’éviter ce problème.
  • pollRedisUntilPopulated nécessite un délai d’attente et une solution de secours, sinon les requêtes en attente peuvent rester bloquées en cas de panne du détenteur du verrou.
  • Dans un même processus Node.js, il est également possible de regrouper les échecs simultanés en partageant une seule promesse en cours d’exécution par clé, ce qui réduit le nombre de tentatives de verrouillage avant même que Redis ne soit impliqué.
  • Modèle 2 : calcul préalable en arrière-plan

    La méthode la plus fiable consiste à séparer la livraison des données de leur génération. Les gestionnaires de requêtes, qui suivent une voie synchrone, ne lisent que dans le cache et ne le reconstituent jamais. Si une valeur fait défaut, ils renvoient immédiatement un état vide ou « en chargement », ce qui est bien mieux que de garder une connexion occupée pendant plusieurs secondes.

    La régénération est effectuée par un processus en arrière-plan, tel qu’un job cron, un travailleur BullMQ ou un consommateur de capture de données modifiées, qui met à jour la clé avant son expiration. Dans l’exemple ci-dessous, un travailleur planifié toutes les neuf minutes reconstitue la liste des tendances et l’écrit à nouveau avec la même durée de vie de dix minutes, de sorte que la clé ne devient jamais inutilisée en conditions normales d’exploitation :

    // Separate background worker file
    // For example, running every 9 minutes via Cron or BullMQ
    async function regenerateTrendingProductsCache() {
      const cacheKey = 'products:trending';
      try {
        // Proactive DB fetch (1.2s)
        const dbData = await fetchTrendingProductsFromDB();
        // Refresh the 10-minute cache
        await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
        console.log('Cache proactively refreshed.');
      } catch (err) {
        console.error('Proactive refresh failed:', err);
      }
    }
    

    Puisque les requêtes entrantes trouvent toujours une clé prête à l’emploi, il n’y a aucun moment où elles pourraient toutes se tourner vers la base de données. Deux éléments garantissent cette sécurité : exécuter le renouvellement via un seul planificateur ou derrière un verrou afin que plusieurs instances ne le fassent pas en même temps, et déclencher une alerte en cas d’échec du renouvellement, car le bloc catch ne fait que enregistrer l’erreur et la clé expirera une minute plus tard. Concernant le planification, le guide sur les tâches en arrière-plan fiables avec BullMQ et Redis offre des informations plus détaillées.

    Choix entre eux

    Le verrouillage maintient le flux classique du cache-aside et convient aux données demandées de manière imprévisible. La pré-computation en arrière-plan est adaptée à un petit ensemble de clés connues, coûteuses et lues fréquemment, comme une liste de tendances ou un résumé du tableau de bord, et offre la latence la plus prévisible. Ajouter une légère variation aléatoire aux TTL aide également lorsque de nombreuses clés sont écrites en même temps et risqueraient sinon d’expirer ensemble.

    Points clés

    • Redis est rapide ; les problèmes surviennent de la manière dont l’application réagit à une absence dans le cache, et non du cache lui-même.
    • Le cache-aside naïf transforme une expiration unique en un pic synchronisé sur la dépendance la plus lente, et l’échec peut être masqué par une faible consommation CPU de l’API.
    • Un verrou distribué garantit qu’un seul appelant génère une clé ; il faut gérer avec soin l’expiration du verrou, sa propriété et les délais d’attente.
  • Le renouvellement en arrière-plan élimine complètement la régénération du chemin de requête et constitue l’option la plus fiable pour les clés fréquemment utilisées et coûteuses.
  • Considérez le cacheage comme une décision architecturale visant à protéger la base de données sous charge, et non comme un moyen rapide d’améliorer les performances.