Qu’est-ce que le traitement par lots (batch processing) ?
Le traitement par lots consiste à prendre des tâches trop lentes, trop fragiles ou trop irrégulières pour être exécutées pendant qu’un utilisateur attend, et à les confier à un processus distinct qui les exécute selon son propre calendrier. La requête de l’utilisateur fait le strict minimum — valider l’entrée, écrire une ligne, mettre un job en file d’attente — puis s’arrête. Tout le reste se passe en arrière-plan.
Ce nom provient des jobs de l’ère des mainframes qui traitaient une pile d’enregistrements en une seule exécution, et le concept n’a pas changé. Au lieu d’une bande magnétique, vous avez une queue. Au lieu d’une fenêtre d’exécution nocturne, vous avez des workers qui consomment les tâches en continu. Ce qui reste constant, c’est la séparation : l’élément qui accepte le travail et celui qui l’exécute sont différents, avec un buffer situé entre les deux.
Cette séparation est tout l’intérêt de la méthode. Elle permet au chemin de requête de rester rapide et prévisible, tandis que le chemin lent prend le temps nécessaire, effectue des tentatives de récupération (retries) en cas d’échec et évolue selon une courbe de montée en charge différente.
Pourquoi les tâches lourdes n’ont pas leur place dans une requête
Une requête HTTP synchrone est un mauvais endroit pour des opérations lentes, pour trois raisons qui s’accumulent.
Premièrement, les timeouts. Les proxys, les load balancers et les clients imposent tous des délais d’expiration. Une requête qui génère un PDF de 200 pages, appelle une API tierce instable et envoie un e-mail peut facilement les dépasser. Lorsqu’elle le fait, le client voit une erreur alors même que le travail a pu être effectué à moitié sur le serveur.
Deuxièmement, le blocage de l’event loop. Node exécute JavaScript sur un seul thread. Une tâche synchrone gourmande en CPU — traitement d’image, analyse d’un gros JSON, boucle cryptographique — empêche toutes les autres requêtes d’être servies pendant son exécution. Même le travail asynchrone mobilise des ressources : une connexion à la base de données ouverte, un descripteur de fichier, de la mémoire pour la réponse.
Troisièmement, les tentatives de rejeu (retries) sont impossibles. Si le fournisseur d’e-mails renvoie une erreur 503, un gestionnaire intégré n’a pas de bonnes options. Il peut faire échouer l’ensemble de la requête et forcer l’utilisateur à réessayer, ou ignorer l’erreur et perdre l’e-mail. Une file d’attente apporte une troisième réponse à cet échec : réessayer plus tard, automatiquement, sans que l’utilisateur ne s’en aperçoive.
app.post("/reports", async (req, res) => {
const report = await db.report.create({ data: { userId: req.user.id } });
await reportsQueue.add("generate", { reportId: report.id });
res.status(202).json({ id: report.id, status: "queued" });
});
Le statut 202 Accepted est la réponse honnête dans ce cas : le serveur a accepté la requête mais n’a pas terminé le travail. C’est la structure type de tout endpoint de traitement par lot bien conçu.
L’anatomie d’un job
Un job est un petit enregistrement auto-descriptif. La plupart des files d’attente stockent quelque chose comme ceci :
{
"id": "welcome:user_42",
"name": "welcome",
"data": { "userId": "user_42", "to": "[email protected]", "template": "welcome" },
"status": "queued",
"attemptsMade": 0,
"maxAttempts": 5,
"runAt": 1760000000000,
"createdAt": 1759999100000
}
Chaque champ a son utilité. L’id rend le job adressable et, lorsqu’il est dérivé du travail plutôt que généré aléatoirement, il vous offre la déduplication gratuitement. Le name route le job vers un handler. Le payload contient tout ce dont le worker a besoin — et rien de plus, car un payload qui pointe vers une ligne de base de données est plus léger et plus actuel qu’un payload qui la copierait. Le status suit le job tout au long de son cycle de vie. attemptsMade et maxAttempts gèrent les tentatives de retry. runAt permet de planifier des tâches différées ou récurrentes.
Le payload doit être un instantané de l’intention, et non un objet vivant. Si un utilisateur met à jour son e-mail entre l’envoi dans la file et l’exécution, le job doit toujours envoyer l’e-mail à l’adresse avec laquelle il a été créé. Cependant, stocker l’intégralité de l’enregistrement utilisateur est une erreur : cela surcharge la file d’attente et les données deviennent rapidement obsolètes. Stockez les ids et les quelques valeurs qui définissent le travail à effectuer.
Payloads de jobs et versioning
Une file d’attente est une interface persistante entre deux déploiements. Un producteur exécutant la version 1 du code peut écrire un job que devra lire un worker exécutant la version 2. C’est le même problème de compatibilité qu’avec une API, et il est facile de l’ignorer jusqu’à ce qu’un déploiement ne casse un backlog.
Deux habitudes permettent de garder les payloads compatibles. Premièrement, ajoutez des champs plutôt que de les renommer ou de les supprimer, et donnez aux nouveaux champs une valeur par défaut cohérente dans le handler. Un worker qui tolère l’absence de locale peut traiter des jobs mis en file d’attente avant que le champ n’existe. Deuxièmement, insérez une version dans le payload lorsque la structure peut changer de manière significative :
await queue.add("import", { version: 2, importId, mapping });
Le handler peut alors bifurquer selon version et sait exactement comment interpréter le reste. Cela ne coûte presque rien et transforme toute une catégorie d’incidents post-déploiement en une simple table de correspondance.
Gardez vos payloads légers. Une file d’attente stocke chaque job en attente ; ainsi, un payload qui intègre un objet volumineux se multiplie sur des milliers de lignes et ralentit chaque scan. Référencez les données par leur id et laissez le worker les récupérer. La seule exception est une valeur qui doit être figée au moment de la mise en file d’attente — le destinataire d’un e-mail, le prix communiqué à un client — laquelle a sa place dans le payload précisément parce qu’elle ne doit pas changer.
Files d’attente, cron et déclencheurs événementiels
Toutes les tâches de fond n’ont pas besoin d’une file d’attente, et choisir le mauvais déclencheur peut complexifier un problème pourtant simple.
Le Cron exécute un gestionnaire selon un calendrier précis : tous les soirs à 02h00, tous les lundis à 09h00. C’est l’outil idéal pour les rapprochements périodiques, la génération de rapports et le nettoyage. Sa faiblesse est qu’il n’a aucune notion d’unité de travail. Une tâche cron qui prend plus de temps que son intervalle s’exécutera en parallèle avec elle-même, et une exécution manquée est tout simplement perdue.
Les déclencheurs événementiels réagissent à un événement qui vient de se produire : l’arrivée d’un webhook, l’insertion d’une ligne en base de données, le dépôt d’un fichier dans le stockage. Ils sont immédiats et naturels, mais n’offrent ni mise en tampon (buffering), ni mécanisme de tentative (retry), ni gestion de la contre-pression (backpressure). Un gestionnaire de webhook qui effectue un travail conséquent n’est en réalité qu’une requête synchrone déguisée.
Les files d’attente se situent entre les deux. Un job est une unité de travail durable et rejouable, qui peut être planifiée pour maintenant ou pour plus tard. La plupart des systèmes en production utilisent les trois : le cron met en file d’attente un job de diffusion (fan-out), les événements ajoutent des jobs en réponse aux actions des utilisateurs, et les workers vident la file d’attente. La règle d’or est que le cron et les événements décident quand travailler, et que la file d’attente décide comment travailler.
Producteurs, workers et la file d’attente
Le système se compose de trois rôles, et les maintenir distincts permet d’en assurer la maintenabilité.
Le producteur est tout code qui ajoute une tâche. Il connaît le nom de la tâche et la structure du payload, et rien d’autre. Il doit être rapide et son appel doit être idempotent — si le producteur lui-même tente un nouvel essai après un timeout, vous ne voulez pas vous retrouver avec deux tâches.
La file d’attente (queue) est un stockage durable et ordonné. Redis avec BullMQ, Amazon SQS, RabbitMQ et Google Cloud Tasks remplissent tous ce rôle. La file d’attente persiste les tâches entre les redémarrages, les distribue de manière atomique, suit les tentatives et met de côté les tâches ayant échoué après épuisement des essais. Vous pouvez également en construire une sur une table de base de données, ce qui est un début raisonnable quand le volume est faible, mais devient un handicap dès que ce n’est plus le cas.
Le worker est un processus de longue durée qui récupère les tâches et exécute les handlers. Il est séparé de l’API pour de bonnes raisons : il peut être déployé sur du matériel optimisé pour le CPU, être mis à l’échelle en fonction de la profondeur de la file d’attente, et être redémarré sans perdre de requêtes. Dans BullMQ, cette séparation est explicite :
import { Worker } from "bullmq";
import { connection } from "./queue.js";
const worker = new Worker(
"emails",
async (job) => {
await sendEmail(job.data);
},
{ connection, concurrency: 10 },
);
Un seul processus peut héberger plusieurs workers pour différentes files d’attente, et une seule file d’attente peut être servie par de nombreux processus worker. La file d’attente est le seul état partagé, ce qui est précisément la raison pour laquelle elle s’adapte si proprement à une mise à l’échelle horizontale.
Choisir un broker
Les trois brokers que vous rencontrerez le plus souvent se situent à différents points d’un spectre allant des fonctionnalités à la lourdeur opérationnelle.
Redis avec BullMQ est le choix par défaut pour les équipes Node.js. Redis est probablement déjà présent dans votre stack, le client est mature, et BullMQ ajoute les jobs différés, les jobs répétables, les priorités, le rate limiting, les retries et une UI. Le bémol est que Redis est principalement un store en mémoire, donc la durabilité dépend de votre configuration de persistance. Un job acquitté mais pas encore écrit sur disque peut être perdu si l’instance s’arrête.
Amazon SQS est entièrement managé et offre un débit pratiquement illimité. Vous bénéficiez de la durabilité et de la disponibilité sans rien avoir à gérer, avec un paiement à la requête. En contrepartie, vous perdez en ergonomie : la livraison différée est plafonnée, il n’y a pas de scheduler intégré pour les jobs de type cron, et l’API est plus bas niveau qu’un framework de jobs.
RabbitMQ est le plus flexible. Les exchanges et les routing keys permettent à un seul message d’être diffusé vers plusieurs consommateurs, et les acquittements par message, les priorités ainsi que les dead-letter exchanges sont des fonctionnalités natives. Il est plus lourd à exploiter que Redis et possède une courbe d’apprentissage plus raide, mais il est idéal pour le routage complexe.
Le conseil le plus honnête est de commencer avec ce que vous utilisez déjà. Une queue sur Redis est bien meilleure que pas de queue du tout parce que vous attendiez d’évaluer les différents brokers. Changez lorsque vous serez réellement confronté à une limitation spécifique — qu’il s’agisse de durabilité, de débit ou de routage.
Idempotence et livraison « at-least-once »
Le fait le plus important à retenir concernant les files d’attente de tâches (job queues) est que la livraison est at-least-once (au moins une fois), et non exactement une fois. Un worker peut planter après avoir effectué le travail mais avant de l’avoir acquitté, et la file d’attente redistribuera alors la tâche. Une micro-coupure réseau peut faire disparaître un acquittement. Le travail est alors exécuté à nouveau.
Ce n’est pas un bug à contourner ; c’est le contrat. Une livraison « exactement une fois » à travers un réseau est pratiquement impossible, donc les files d’attente choisissent le « at-least-once » et vous transfèrent la responsabilité. Votre handler doit être idempotent : l’exécuter deux fois doit produire le même état final que de l’exécuter une seule fois.
Il existe trois techniques pratiques.
L’idempotence naturelle. Certaines opérations sont déjà sûres à répéter. Définir le statut d’un utilisateur sur active deux fois revient au même qu’une seule fois. Supprimer une ligne par son id une seconde fois est une opération neutre (no-op). Privilégiez ces approches quand c’est possible.
Une clé de déduplication. Écrivez un marqueur indexé par le travail avant de l’exécuter, et ignorez l’étape si le marqueur existe déjà. Une contrainte d’unicité ou un SET NX Redis rend la vérification atomique, empêchant ainsi deux workers concurrents de réussir simultanément.
export async function handleCharge(job) {
const key = `charged:${job.data.orderId}`;
const inserted = await connection.set(key, "1", "NX", "EX", 86_400);
if (inserted === null) return { skipped: true };
await stripe.charges.create(
{ amount: job.data.amount, source: job.data.token },
{ idempotencyKey: job.data.orderId },
);
}
Les clés d’idempotence des fournisseurs. Les passerelles de paiement et nombreuses autres API acceptent une clé d’idempotence. Transmettez l’id stable de la tâche et le fournisseur retournera le résultat original au lieu de facturer deux fois. Combinez toujours cela avec votre propre déduplication, car la clé ne protège que l’appel API, pas la logique environnante.
Remarquez que la clé de déduplication est dérivée du travail — l’id de la commande — plutôt que de l’id de la tâche. C’est délibéré : cela rend le handler sûr même si le producteur envoie deux fois le même travail logique dans la file.
Tentatives de reconnexion avec backoff exponentiel et jitter
Les pannes transitoires sont normales. Une base de données bascule sur un serveur de secours, une API vous limite (rate-limiting), un conteneur est redéployé. Réessayer est la bonne réponse, mais réessayer immédiatement ne l’est pas.
Le backoff exponentiel augmente le délai à chaque tentative : environ 2s, 4s, 8s, 16s, 32s. Cela laisse le temps à une dépendance en difficulté de récupérer au lieu d’être bombardée de requêtes. Le jitter ajoute un montant aléatoire à chaque délai pour éviter que de nombreux jobs ayant échoué simultanément ne recommencent tous en même temps, ce qui recréerait le pic de charge ayant causé la panne.
La plupart des files d’attente supportent cela de manière déclarative. Dans BullMQ, il s’agit d’une propriété du job :
await queue.add(
"sync",
{ accountId },
{
attempts: 5,
backoff: { type: "exponential", delay: 2_000 },
},
);
Cela produit des délais d’environ 2s, 4s, 8s, 16s et 32s, avec le jitter propre à la file d’attente. Lorsque vous implémentez le backoff manuellement, ajoutez vous-même le jitter et plafonnez le délai pour éviter qu’un job ne reste en sommeil pendant une journée entière :
function nextDelay(attempt: number, base = 1_000, cap = 60_000) {
const exponential = Math.min(cap, base * 2 ** attempt);
const jitter = Math.random() * exponential * 0.5;
return Math.round(exponential + jitter);
}
Définissez une limite de tentatives et décidez délibérément de ce qui se passe lorsqu’elle est atteinte. Certains jobs devraient réessayer indéfiniment à basse fréquence — une tâche de réconciliation, par exemple — mais la plupart devraient s’arrêter et signaler l’erreur.
Files d’attente de lettres mortes (DLQ) et messages toxiques
Un message toxique (poison message) est une tâche qui échoue systématiquement à chaque exécution : données malformées, bug dans le handler, ligne de référence manquante. Comme elle échoue toujours, elle consomme un slot de worker à chaque tentative et peut paralyser le traitement des tâches saines. La tenter de relancer indéfiniment est pire que de ne pas la relancer du tout.
La solution est la dead-letter queue (DLQ). Une fois la limite de tentatives atteinte, la file d’attente déplace la tâche — payload, tentatives et dernière erreur — vers une zone de rétention séparée. Les workers n’y touchent jamais, elle ne peut donc rien paralyser, et un opérateur peut l’inspecter, corriger la cause et la rejouer.
const worker = new Worker("emails", handler, { connection });
worker.on("failed", async (job, err) => {
if (job && job.attemptsMade >= (job.opts.attempts ?? 1)) {
await deadLetter.add("failed-email", {
payload: job.data,
error: err.message,
failedAt: new Date().toISOString(),
});
await alerting.notify(`job ${job.id} exhausted retries`);
}
});
Considérez la DLQ comme une interface opérationnelle, et non comme un cimetière. Configurez des alertes lorsque sa profondeur augmente, créez-lui un tableau de bord et mettez en place un flux de rejeu. Une DLQ que personne ne consulte est l’endroit idéal pour que les bugs se cachent.
Concurrence, limitation de débit et backpressure
La concurrence d’un worker correspond au nombre de jobs qu’il traite simultanément. L’augmenter accroît le débit jusqu’à ce que le worker manque de CPU, de connexions à la base de données ou de mémoire, moment où la situation se dégrade. La valeur idéale est le nombre le plus élevé permettant de maintenir chaque dépendance confortablement en dessous de sa limite.
La concurrence est également la première ligne de défense contre le phénomène de thundering herd. Si une API en aval autorise 50 requêtes par seconde, un worker avec une concurrence de 200 va la saturer. De nombreuses files d’attente proposent un rate limiter précisément pour cela :
const worker = new Worker("sync", handler, {
connection,
concurrency: 10,
limiter: { max: 50, duration: 1_000 },
});
La backpressure est ce qui empêche la file d’attente elle-même de croître indéfiniment. Si les producteurs ajoutent des jobs plus rapidement que les workers ne peuvent les traiter, la file devient un backlog permanent et sa latence se compte en heures. Les options incluent la mise en pause des producteurs lorsqu’un seuil de profondeur est franchi, le rejet des jobs de faible priorité et le scaling automatique des workers. Une file d’attente qui ne fait que croître est une panne qui n’a pas encore été remarquée.
Séparez vos files d’attente par type de charge de travail. Un import nocturne lent et une réinitialisation de mot de passe urgente ne devraient pas partager la même file.
Regrouper les écritures en base de données
La base de données est généralement le goulot d’étranglement d’un traitement par lots (batch job), et la cause la plus fréquente est l’interaction avec celle-ci ligne par ligne. Chaque aller-retour engendre un coût fixe — réseau, analyse, planification — qui éclipse le coût de l’insertion de la ligne elle-même. Une boucle d’insertions uniques passe la majeure partie de son temps en attente.
Un multi-row insert permet de transférer les mêmes données en une seule instruction :
const values = chunk
.map((_, n) => `($${n * 3 + 1}, $${n * 3 + 2}, $${n * 3 + 3})`)
.join(",");
await pool.query(
`INSERT INTO orders (user_id, status, total_cents)
VALUES ${values}
ON CONFLICT (external_id) DO UPDATE
SET status = EXCLUDED.status`,
chunk.flatMap((r) => [r.userId, r.status, r.totalCents]),
);
La clause ON CONFLICT ... DO UPDATE transforme l’insertion en un upsert, ce qui rend l’écriture groupée idempotente. Relancer le job met à jour les lignes existantes au lieu de créer des doublons, rendant ainsi toute tentative de reprise (retry) sûre.
Deux mises en garde. Limitez la taille des lots (chunks) — quelques centaines à quelques milliers de lignes — car une instruction paramétrée a une limite sur le nombre de paramètres, et une instruction très volumineuse maintient les verrous et la mémoire plus longtemps. De plus, enveloppez un lot dans une transaction si les lignes doivent être insérées ensemble, mais gardez la transaction courte pour ne pas bloquer les autres écritures.
Pour des charges très importantes, un chemin de chargement massif dédié tel que le COPY de Postgres est encore plus rapide, comme détaillé dans le guide PostgreSQL.
Le découpage (chunking) de grands jeux de données
Un job de traitement par lot qui doit traiter « tous les utilisateurs » ne peut pas tous les charger en mémoire. La solution consiste à découper (chunk) le travail : traiter une page limitée, valider les modifications, puis récupérer la suivante. Cela permet de maintenir une consommation mémoire stable et permet au job de reprendre là où il s’était arrêté.
La pagination par keyset est la méthode la plus robuste pour y parvenir. Au lieu de OFFSET, qui ralentit à mesure que le volume augmente et peut sauter ou répéter des lignes lorsque les données changent, vous mémorisez la dernière clé rencontrée :
let cursor: string | null = null;
while (true) {
const batch = await pool.query(
`SELECT id, email FROM users
WHERE ($1::text IS NULL OR id > $1)
ORDER BY id
LIMIT 1000`,
[cursor],
);
if (batch.rowCount === 0) break;
await processBatch(batch.rows);
cursor = batch.rows[batch.rows.length - 1].id;
}
Chaque chunk est indépendant ; ainsi, un crash en cours d’exécution n’entraîne la perte que du chunk actuel, et le job peut être repris en passant le dernier curseur. Cela s’associe naturellement avec une file d’attente : placez un job dans la queue par chunk, afin qu’un échec unique ne force pas le redémarrage de l’intégralité du processus.
Planification de tâches récurrentes
Le travail récurrent — résumés quotidiens, nettoyage, rapprochement — est mieux exprimé sous forme de tâche répétable plutôt que par une entrée cron appelant un endpoint HTTP. La file d’attente gère alors le planning, la protection contre les chevauchements et la politique de tentative (retry).
await reportsQueue.add(
"daily-digest",
{ region: "eu" },
{
repeat: { pattern: "0 7 * * *" },
jobId: "daily-digest:eu",
attempts: 3,
},
);
La stabilité de jobId est cruciale : elle empêche le scheduler d’empiler une nouvelle copie si une tâche est encore en cours, et permet à chaque instance de l’application d’enregistrer le même planning sans créer de doublons. Un verrou distribué (distributed lock) autour du travail effectif reste recommandé pour les tâches qui ne doivent jamais s’exécuter deux fois simultanément.
Privilégiez l’UTC pour les plannings et rendez la tâche consciente des fuseaux horaires lors du formatage des sorties. Un résumé envoyé à 07:00 UTC n’est pas la même chose qu’un résumé à 07:00 heure locale, et cette différence se traduit par un ticket de support à chaque changement d’heure.
Observabilité
Un job d’arrière-plan est invisible à moins que vous ne le rendiez visible. Quatre signaux couvrent la majeure partie de vos besoins.
- Profondeur de la file d’attente (Queue depth) — combien de jobs sont en attente. Une profondeur croissante signifie que les workers ne suivent plus, ce qui est le premier signal d’alerte d’une panne.
- Durée du job — un histogramme par nom de job. Un p95 qui augmente avec le temps signale le ralentissement d’une dépendance.
- Taux d’échec — nombre d’échecs par minute, ventilés par nom de job. Un pic après un déploiement pointe directement vers la modification effectuée.
- Âge du job en attente le plus ancien — la profondeur indique combien, l’âge indique la gravité. Dix mille jobs traités en une seconde ne posent aucun problème ; dix jobs qui attendent depuis une heure, si.
Ajoutez un correlation id à chaque payload de job et incluez-le dans les logs, afin qu’un job puisse être tracé depuis la requête qui l’a créé jusqu’à chaque tentative de retry. Sans cela, déboguer l’échec d’un worker revient à faire des grep sur des timestamps et à deviner.
await queue.add("import", { importId, correlationId: req.id });
Exposez les métriques sur le même dashboard que votre API, et configurez des alertes sur la profondeur et l’âge du job le plus ancien plutôt que sur les échecs individuels, qui sont attendus.
Arrêt progressif (Graceful shutdown)
Un worker interrompu en plein milieu d’une tâche laisse celle-ci dans un état ambigu. La file d’attente finira par la redistribuer, ce qui est correct mais inefficace, et un arrêt brutal peut interrompre une transaction de base de données au pire moment.
Gérez SIGTERM et fermez le worker délibérément :
process.on("SIGTERM", async () => {
await worker.close();
await connection.quit();
process.exit(0);
});
worker.close() arrête d’accepter de nouvelles tâches et attend que celles en cours se terminent. Associez cela à un délai de grâce lors du déploiement, suffisamment long pour la tâche la plus lente, et limitez les timeouts des tâches pour qu’aucune ne puisse dépasser ce délai. Si une tâche est réellement longue, implémentez des points de contrôle (checkpoints) de progression afin qu’elle puisse reprendre là où elle s’était arrêtée plutôt que de redémarrer.
La même discipline s’applique à la connexion : fermez le pool de base de données et le client du broker pour que le processus se termine proprement au lieu de rester bloqué sur des sockets ouvertes.
Bonnes pratiques
- Limitez vos gestionnaires de requêtes à une écriture et un enfilement ; retournez
202lorsque le travail est différé. - Rendez chaque gestionnaire idempotent, car la livraison est garantie “au moins une fois” (at-least-once).
- Dérivez les clés de déduplication à partir du travail lui-même, et non d’un identifiant de tâche aléatoire.
- Utilisez un backoff exponentiel avec jitter et plafonnez le délai.
- Définissez une limite de tentatives et routez les tâches épuisées vers une dead-letter queue.
- Séparez les files d’attente par type de charge de travail pour éviter que les tâches lentes ne bloquent les tâches urgentes.
- Limitez la concurrence en fonction de ce que votre base de données et vos API en aval peuvent supporter.
- Regroupez les écritures en base de données avec des insertions multi-lignes ou des upserts, par lots limités.
- Paginez les grands ensembles de données avec une pagination par clés (keyset pagination), et non
OFFSET. - Suivez la profondeur de la file d’attente, la durée des tâches, le taux d’échec et l’âge de la tâche la plus ancienne.
- Arrêtez vos workers proprement et prévoyez un délai de grâce correspondant lors des déploiements.
Erreurs courantes
- Effectuer des appels tiers lents pendant la requête et considérer que tout va bien parce que cela fonctionne en local.
- Supposer qu’un job s’exécute exactement une seule fois, et ainsi facturer un client deux fois.
- Relancer une tentative immédiatement sans backoff, amplifiant ainsi la panne initiale.
- Retenter indéfiniment un “poison message”, saturant ainsi la queue.
- Exécuter un seul job géant qui traite chaque ligne dans une transaction unique.
- Insérer des lignes une par une et rejeter la faute sur la base de données.
- Régler la concurrence si haut que la base de données atteint sa limite de connexions.
- Planifier des jobs récurrents avec un id aléatoire, accumulant ainsi des doublons.
- Ne jamais consulter la dead-letter queue.
- Tuer les workers avec
SIGKILLet perdre le travail en cours d’exécution. - Oublier d’ajouter la profondeur de la queue au dashboard jusqu’au premier incident.
Et après ?
Une file d’attente n’est efficace que si le stockage qui la soutient l’est aussi ; le guide Redis est donc la suite logique pour le broker le plus utilisé par les équipes Node.js. Le guide sur le Caching montre comment éviter d’effectuer le travail inutilement, et celui sur le Connection Pooling explique comment empêcher une flotte de workers d’épuiser votre base de données. Lorsque la tâche consiste en une écriture massive, le guide PostgreSQL traite de COPY, des upserts et des structures de transactions qui permettent d’optimiser les performances.