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.
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
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
- Concevoir des backends de chat en temps réel : chambres, persistence et scaling — Apprenez à concevoir un backend de chat en temps réel en utilisant Socket.IO, PostgreSQL et Redis, en abordant les chambres, l’ordre de persistence des messages, la présence des utilisateurs et le scaling multi-serveur.
- Fondements du cacheage avec Redis : patterns, dangers et concepts pour les interviews — Découvrez le fonctionnement du cacheage Redis dans les applications Node.js, des méthodes de stockage en marge du cache et des TTL aux mécanismes de protection contre les surcharges, aux politiques d’éviction et aux questions fréquentes lors des entretiens.