Accueil / Articles / Création de systèmes fiables pour les tâches en arrière-plan avec BullMQ et Redis

Création de systèmes fiables pour les tâches en arrière-plan avec BullMQ et Redis

Apprenez à concevoir des pipelines de tâches en arrière-plan résilients avec Node.js, en utilisant BullMQ et Redis, en abordant les tentatives répétées, la concurrence, l’idempotence et le suivi.

2933 mots

Envoyer un e-mail de confirmation, générer un rapport, traiter un paiement — de nombreuses tâches en arrière-plan n’ont pas besoin d’être terminées avant de pouvoir répondre à un utilisateur. Ce guide explique comment créer des systèmes de tâches en arrière-plan fiables en utilisant BullMQ en combinaison avec Redis.

Imaginez un système en arrière-plan où presque toutes les tâches s’effectuent directement au sein du cycle de la requête HTTP. Il faut envoyer un e-mail ? Faites-le sur place. Il faut générer un PDF ? De même. Il faut traiter des données en arrière-plan ? Faites-le également directement.

Cette approche fonctionne bien au début. Puis elle cesse de fonctionner.

L’API commence à ralentir. Les requêtes commencent à expirer. Et si un service externe tombe en panne, toute la requête peut échouer avec lui.

C’est là que les tâches en arrière-plan deviennent utiles.

Au lieu de forcer l’API à terminer chaque étape avant de répondre, vous pouvez mettre le travail en file d’attente et laisser un processus dédié s’en occuper séparément.

Une excellente option dans l’écosystème Node.js est BullMQ, soutenu par Redis pour le stockage. Voici comment les éléments s’assemblent.

1. Qu’est-ce qu’un travail en arrière-plan ?

Prenons un flux de création de compte typique. Lorsqu’une personne crée un compte, l’API peut avoir besoin de :

  • Créer le profil utilisateur
  • Envoyer un e-mail de bienvenue
  • Générer un PDF de bienvenue
  • Envoyer une notification
  • Mettre à jour un autre système interne

Client
  ↓
API
  ↓
Create User
  ↓
Send Email
  ↓
Generate PDF
  ↓
Send Notification
  ↓
Response

Mais cela force l’utilisateur à attendre la fin de chacune de ces étapes.

Une approche plus appropriée serait plutôt la suivante :

Client
  ↓
API
  ↓
Create User
  ↓
Add Job to Queue
  ↓
Response

Et séparément :

Queue
  ↓
Worker
  ↓
Send Email
  ↓
Done

Puisque l’API n’a plus besoin d’achever toutes les tâches avant de répondre, elle le fait beaucoup plus rapidement.

2. Pourquoi avons-nous besoin d’une file d’attente ?

Supposons qu’envoyer un e-mail prenne 1 seconde, générer un PDF prenne 2 secondes, et appeler une autre API prenne 1 seconde. Votre point d’entrée pourrait rester bloqué pendant plusieurs secondes avant d’envoyer une réponse — ce qui est une mauvaise expérience pour l’utilisateur.

Pire encore, que se passe-t-il si le fournisseur d’e-mails est inaccessible ? La demande pourrait échouer même si la création de l’utilisateur a réussi. Il s’agit d’une dépendance inutile entre deux problématiques sans rapport.

Une file d’attente brise ce couplage :

┌──────────────┐
                    │   Node API   │
                    └──────┬───────┘
                           ↓
                     Add Job
                           ↓
                    ┌──────────────┐
                    │    Redis     │
                    │    Queue     │
                    └──────┬───────┘
                           ↓
                    ┌──────────────┐
                    │    Worker    │
                    └──────┬───────┘
                           ↓
                Email / PDF / API / etc.

Avec cette séparation, l’API et la tâche en arrière-plan ont chacune une responsabilité distincte.

3. Qu’est-ce que BullMQ ?

BullMQ est une bibliothèque de files d’attente pour Node.js qui s’appuie sur Redis pour stocker et coordonner les tâches. Son architecture, à un niveau général, ressemble à ceci :

Producer
   ↓
Queue
   ↓
Worker
   ↓
Job Processing

Le producteur est ce qui crée les tâches. La file d’attente les stocke. Le travailleur est ce qui les traite réellement.

Par exemple :

await emailQueue.add("welcome-email", {
  userId: user.id,
  email: user.email
});

Fondamentalement, l’API dit ceci :

"Voici du travail qui doit être effectué."

Elle n’a pas besoin d’exécuter ce travail elle-même.

4. Création d’une file d’attente

Voici à quoi ressemble une configuration minimale de BullMQ :

import { Queue } from "bullmq";
const connection = {
  host: "localhost",
  port: 6379
};const emailQueue = new Queue("email", {
  connection
});

À partir de là, vous pouvez y ajouter des tâches :

await emailQueue.add("welcome-email", {
  userId: "123",
  email: "user@example.com"
});

Redis gère en arrière-plan le stockage de tout l’état lié à la file d’attente. Conceptuellement, vous pouvez l’envisager de cette manière :

email queue
Job 1
Job 2
Job 3
Job 4
Job 5

Le travailleur récupère ensuite ces tâches et les exécute.

5. Création d’un travailleur

Le travailleur est l’élément qui effectue réellement le travail :

import { Worker } from "bullmq";
const worker = new Worker(
  "email",
  async (job) => {
    console.log("Processing:", job.name);    await sendWelcomeEmail(
      job.data.email
    );
  },
  {
    connection
  }
);

En résumé, le flux s’organise maintenant de la manière suivante :

API
 ↓
emailQueue.add()
 ↓
Redis
 ↓
Worker
 ↓
sendWelcomeEmail()

L’API n’a pas besoin d’attendre la fin de l’envoi d’un e-mail — et c’est là l’avantage principal des tâches en arrière-plan.

6. Que se passe-t-il si une tâche échoue ?

C’est précisément ici que la file d’attente montre son véritable avantage par rapport à une simple appel de service.

Imaginons cette configuration :

API
 ↓
Email Service
 ↓
ERROR

Lorsque vous appelez directement une API, vous êtes contraint de prendre une décision immédiate concernant l’échec.

Une file d’attente vous offre une autre option : la tâche peut simplement être réessayée.

Voici un exemple :

await emailQueue.add(
  "welcome-email",
  {
    email: "user@example.com"
  },
  {
    attempts: 3
  }
);

Avec cette configuration, la tâche peut effectuer plusieurs tentatives avant d’abandonner.

Visuellement, le flux se présente comme suit :

Attempt 1
   ↓
Failed
   ↓
Attempt 2
   ↓
Failed
   ↓
Attempt 3
   ↓
Success

Cette approche est extrêmement utile lorsqu’on travaille avec des services tiers peu fiables.

Néanmoins, les tentatives de réessai ne doivent pas être illimitées ni effectuées sans réflexion.

Évitez une configuration où une tâche continue de réessayer indéfiniment sans limite.

7. Réessai avec retardement

Supposons qu’un service externe tombe temporairement en panne.

Il faut éviter une situation comme celle-ci :

FAIL
RETRY IMMEDIATELY
FAIL
RETRY IMMEDIATELY
FAIL
RETRY IMMEDIATELY

Essayer sans cesse de contacter un service en difficulté peut en réalité aggraver les choses.

La solution consiste à introduire un retardement entre chaque tentative.

Par exemple :

await emailQueue.add(
  "welcome-email",
  {
    email: "user@example.com"
  },
  {
    attempts: 5,
    backoff: {
      type: "exponential",
      delay: 5000
    }
  }
);

C’est à peu près ainsi que cela se présente conceptuellement :

Attempt 1 → Fail
       ↓
     5 sec
       ↓
Attempt 2 → Fail
       ↓
    10 sec
       ↓
Attempt 3 → Fail
       ↓
    20 sec
       ↓
Attempt 4 → Success

Le moment précis dépend de la manière dont vous configurez la stratégie de réessai et de retardement.

Mais l’idée de base reste la même :

Donnez aux échecs temporaires le temps de se rétablir avant de tenter à nouveau.

8. Tâches différées

Toutes les tâches n’ont pas besoin d’être exécutées dès leur création.

Par exemple :

Envoyer un rappel 24 heures après l’inscription.

BullMQ vous permet de planifier l’exécution d’une tâche à un moment ultérieur.

await emailQueue.add(
  "reminder",
  {
    userId: "123"
  },
  {
    delay: 24 * 60 * 60 * 1000
  }
);

Conceptuellement :

Create Job
    ↓
Wait 24 hours
    ↓
Worker processes job

Cette approche se retrouve dans des situations telles que :

  • E-mails de rappel
  • Notifications planifiées
  • Expiration d’une période d’essai
  • Rappels de paiement
  • Messages de suivi

9. Plusieurs travailleurs

Imaginons maintenant un système qui reçoit des milliers de tâches par minute.

Un seul processus travailleur peut ne pas suffire.

Redis Queue
                     ↓
          ┌──────────┼──────────┐
          ↓          ↓          ↓
       Worker 1   Worker 2   Worker 3
          ↓          ↓          ↓
        Jobs       Jobs       Jobs

Chacun récupère les tâches de la file d’attente de manière indépendante.

Par exemple, donnons :

1000 email jobs

On peut voir quelque chose comme ceci :

Worker 1 → Job 1, 4, 7...
Worker 2 → Job 2, 5, 8...
Worker 3 → Job 3, 6, 9...

Ajouter plus d’ouvriers est un moyen d’améliorer le débit.

Mais soyez prudent :

Augmenter le nombre d’ouvriers ne garantit pas automatiquement de meilleurs résultats.

Votre base de données, votre fournisseur d’e-mails, votre CPU, votre mémoire et tous les services intermédiaires ont chacun leurs propres limites de capacité.

10. Concurrency

En plus de lancer plusieurs processus d’ouvriers, BullMQ vous permet également de configurer le nombre de tâches que chaque ouvrier peut gérer en même temps.

Par exemple :

const worker = new Worker(
  "email",
  async (job) => {
    await sendEmail(job.data.email);
  },
  {
    connection,
    concurrency: 5
  }
);

Cela permet à un ouvrier de traiter plusieurs tâches en parallèle.

Conceptuellement :

Worker
 ├── Job 1
 ├── Job 2
 ├── Job 3
 ├── Job 4
 └── Job 5

Une plus grande concurrence peut augmenter le débit.

Mais ne montez pas simplement la concurrence à 100 sans y réfléchir au préalable.

Si chaque tâche accède à votre base de données, une forte concurrence pourrait facilement la surcharger.

Les paramètres de concurrence doivent être ajustés en fonction de ce que votre charge de travail peut réellement gérer.

11. Limitation des débits

Parfois, le goulot d’étranglement n’est pas du tout dans votre propre système — c’est le service tiers dont vous dépendez.

Supposons que votre fournisseur d’e-mails limite le nombre de requêtes par seconde à une valeur fixe.

Si vous avez soudainement :

10,000 jobs

vous ne voulez pas les envoyer toutes en même temps.

Une file d’attente peut ralentir le rythme de traitement des tâches.

L’architecture qui en résulte ressemble à ceci :

10,000 Jobs
     ↓
Queue
     ↓
Rate Limit
     ↓
Worker
     ↓
External API

C’est bien plus sûr que d’envoyer des milliers de requêtes simultanées à un fournisseur.

12. L’idempotence des tâches est importante

Ce concept suivant est l’une des idées les plus cruciales dans le traitement des tâches en arrière-plan.

Considérons une tâche de traitement de paiement :

Process Payment

Le travailleur la met en exécution.

Le paiement est effectué avec succès.

Mais juste avant que le travailleur ne la marque comme terminée, le processus plante.

La file d’attente, en faisant exactement ce pour quoi elle a été conçue, réessaie la tâche.

Sans mesures de protection, vous pourriez finir par facturer le client une seconde fois.

C’est un problème réel et coûteux.

Pour l’éviter, les tâches doivent être conçues de manière à être idempotentes chaque fois que c’est possible.

En pratique, cela signifie que l’exécution de la même tâche deux fois ne doit pas entraîner d’effet secondaire indésiré et duplicatif.

Une approche courante consiste à s’appuyer sur une référence de paiement unique :

payment:order_123

Ensuite, avant d’effectuer toute action, vérifiez :

Has this payment already been completed?
       ↓
     Yes → Don't charge again
       ↓
      No → Process payment

BullMQ ne dispose pas de mécanisme intégré pour cela.

C’est au code de votre application d’assurer l’idempotence.

13. Les tâches échouées nécessitent une stratégie

Tous les échecs ne sont pas identiques, et tous ne méritent pas d’être réessayés.

Examinons quelques exemples :

Invalid email
Invalid user ID
Missing database record
Invalid payment information

Réexécuter ces tâches cinq fois ne résoudra rien.

Il est utile de classer les échecs en deux catégories :

Échecs temporaires

Cela inclut des situations telles que :

  • Un délai d’attente réseau
  • Une dépendance temporairement indisponible
  • Une connexion à la base de données interrompue

Ces sont les types de problèmes pour lesquels essayer à nouveau plus tard a effectivement du sens.

Échecs permanents

Cela inclut des situations telles que :

  • Des données d’entrée incorrectes
  • Un resource référencé qui n’existe plus
  • Violation d’une règle métier
  • Dans ces cas, tenter à nouveau est inutile — la tâche doit être directement orientée vers un mécanisme de gestion des erreurs.

    Une configuration de file d’attente bien conçue ne se contente pas de suivre une règle générale :

    Retry everything
    

    Elle suit plutôt un flux plus délibéré :

    Understand why it failed
           ↓
    Temporary?
       /       \
     YES        NO
     ↓           ↓
    Retry       Handle failure
    

    14. Gestion des tâches échouées ou non traitées

    Même avec le plus grand soin, certaines tâches échoueront de manière impossible à corriger par des tentatives répétées. Il est nécessaire d’avoir une visibilité sur ces tâches afin qu’elles ne disparaissent pas simplement.

    Par exemple, vous pourriez tomber sur quelque chose comme ceci :

    Failed Jobs
    ──────────────
    Job 101 → Email invalid
    Job 102 → Payment failed
    Job 103 → API timeout
    

    Une fois que vous pouvez voir ces échecs, vous avez plusieurs options :

    • Enregistrer l’échec pour une révision ultérieure
    • Informer votre équipe
    • Permettre à quelqu’un de tenter manuellement la tâche
    • Corriger les données erronées à l’origine du problème
    • Routez la tâche vers un flux de travail dédié à la gestion des pannes

    La manière exacte de le mettre en place dépend des besoins de votre système. Ce qui importe le plus, c’est un principe unique :

    Les tâches échouées ne doivent jamais disparaître sans laisser de trace.

    15. File d’attente vs tâche Cron

    Il est facile de confondre ces deux concepts, mais ils résolvent des problèmes différents.

    La fonction d’une tâche Cron est de spécifier :

    "Exécuter cette tâche à un moment donné."

    La fonction d’une file d’attente est de spécifier :

    "Traiter cette unité de travail."

    Dans la pratique, ces deux outils fonctionnent souvent bien ensemble. Par exemple :

    Cron
     ↓
    Find users whose trial expires today
     ↓
    Create jobs
     ↓
    Queue
     ↓
    Workers
     ↓
    Send emails
    

    Cela permet de séparer la logique de planification de la logique de traitement. C’est généralement une conception plus propre que d’avoir un seul processus Cron qui tente de gérer tout le travail lui-même.

    16. Événements de file d’attente et surveillance

    Lorsque vous mettez cela en production, vous avez besoin de pouvoir voir ce qui se passe réellement à l’intérieur de la file d’attente.

    Les métriques à suivre incluent :

    • Les tâches en attente d’exécution
    • Les tâches actuellement en cours de traitement
    • Les tâches qui se sont terminées avec succès
    • Les tâches qui ont échoué
    • Le temps nécessaire au traitement
    • Le nombre de tentatives effectuées
    • La taille totale de la file d’attente

    Imaginez un tableau de bord qui affiche soudainement quelque chose comme ceci :

    Waiting Jobs
    
    Normal: 50
    Current: 25,000
    

    Ce genre de variation est un signe d’alerte. Cela pourrait signifier :

    • Vos processus ont cessé de fonctionner
    • Une API externe ralentit le système
    • Votre base de données est sous forte charge
    • Le trafic a fortement augmenté
    • Un déploiement récent a introduit une erreur

    Si vous ne surveillez pas votre file d’attente, ces problèmes peuvent s’accumuler de manière invisible jusqu’à ce que les utilisateurs commencent à remarquer qu’il y a un problème.

    17. Ne mettez pas tout dans une file d’attente

    Le fait que BullMQ soit disponible ne signifie pas que chaque opération doive être traitée en arrière-plan.

    Prenons par exemple :

    GET /profile
    

    Ici, l’utilisateur attend immédiatement les données de son profil. Reporter cela à une file d’attente en arrière-plan n’aurait aucun sens — cela ne ferait qu’ajouter un retard inutile.

    Une file d’attente est utile lorsque :

    • Le travail met du temps à être terminé
    • Le travail peut être exécuté de manière asynchrone
    • Le travail pourrait nécessiter une tentative supplémentaire
    • Le travail consomme beaucoup de ressources
    • Le travail dépend de services externes qui ne sont pas entièrement fiables
    • Le résultat n’a pas besoin d’ faire partie de la réponse immédiate

    Une question utile à se poser est :

    L’utilisateur a-t-il vraiment besoin de ce résultat avant que vous ne renvoyiez la réponse HTTP ?

    Si ce n’est pas le cas, il vaut la peine d’envisager de déplacer cette tâche dans un job en arrière-plan.

    18. Une architecture de style production

    En combinant tout cela, une configuration typique ressemble à ceci :

    Client
                           ↓
                      Node.js API
                           ↓
                    ┌──────┴──────┐
                    ↓             ↓
                PostgreSQL      Redis
                                  ↓
                                Queue
                                  ↓
                       ┌──────────┼──────────┐
                       ↓          ↓          ↓
                    Worker 1   Worker 2   Worker 3
                       ↓          ↓          ↓
                    Email      PDF       Notifications
    

    La couche API s’occupe de tout ce qui doit être exécuté immédiatement. PostgreSQL (ou votre base de données de choix) stocke vos données métier durables. Redis prend en charge l’infrastructure de file d’attente ainsi que d’autres charges de travail à courte durée de vie pour lesquelles il est adapté. Les workers s’occupent de tout ce qui peut se faire de manière asynchrone.

    Ce partage des responsabilités rend l’ensemble du système beaucoup plus facile à scaler.

    19. Erreurs à éviter

    Erreur 1 : Faire tout le travail à l’intérieur de la requête HTTP

    Cela conduit à des APIs qui sont à la fois lentes et fragiles.

    Erreur 2 : Réessayer sans limite

    Certaines pannes ne se résoudront tout simplement pas, peu importe le nombre de tentatives.

    Erreur 3 : Omettre l’idempotence

    Si une tâche s’exécute deux fois, cela peut provoquer des effets secondaires redondants que vous n’aviez pas prévus.

    Erreur 4 : Permettre une concurrence illimitée

    Sans limites, vous risquez de submerger les systèmes sur lesquels dépendent vos tâches.

    Erreur 5 : Omettre la surveillance

    Une file d’attente qui continue de croître sans contrôle représente un problème opérationnel prêt à apparaître.

    Erreur 6 : Utiliser Redis comme système de registre

    L’état de la file d’attente et les données commerciales essentielles servent à des fins différentes et ne doivent pas être confondues.

    Erreur 7 : Rendre tout asynchrone

    Certaines opérations nécessitent réellement d’être terminées avant que vous puissiez envoyer une réponse.

    20. Un modèle mental amélioré

    Au préalable de comprendre les files d’attente, l’instinct naturel est de concevoir le traitement des requêtes de cette manière :

    Request
       ↓
    Do everything
       ↓
    Response
    

    Un modèle plus utile ressemble plutôt à ceci :

    Request
       ↓
    Do what must happen immediately
       ↓
    Queue what can happen later
       ↓
    Response
    

    Puis vient :

    Queue
       ↓
    Worker
       ↓
    Process
       ↓
    Retry if appropriate
       ↓
    Complete / Fail
    

    Ce changement — séparer ce qui doit se faire immédiatement de ce qui peut l’être plus tard — constitue l’idée fondamentale derrière tout cela.

    Conclusion finale

    BullMQ n’est utile pas simplement parce qu’il s’agit d’une bibliothèque Node.js largement utilisée. Il est utile parce que le traitement des tâches en arrière-plan répond à un besoin architectural réel.

    Si une tâche est :

    • Lente
    • Quelque chose qui peut être réessayé
    • Quelque chose qui ne doit pas bloquer la réponse
    • Dépendante d’un service externe
    • Gourmande en ressources

    alors elle ne devrait probablement pas faire partie du cycle de requête HTTP.

    Une file d’attente donne à ce travail un endroit où s’exécuter. Redis fournit l’infrastructure de base. BullMQ gère la gestion des tâches. Les travailleurs effectuent le traitement réel. Les tentatives de relance permettent de gérer les pannes temporaires. Les paramètres de concurrence maintiennent le débit sous contrôle. La surveillance vous informe lorsque quelque chose ne va pas. Et une conception minutieuse au niveau de l’application garantit que les tâches peuvent s’exécuter en toute sécurité plusieurs fois si cela s’avère nécessaire.

    La leçon principale ici est la suivante :

    Tout ne doit pas être résolu au sein du cycle demande-réponse.

    Parfois, la bonne réponse à envoyer est simplement :

    « J’ai accepté le travail. Nous nous occuperons du reste. »

    Lectures complémentaires