Files d'attente et traitements de fond : les règles que je ne négocie pas
Ayoub
Tech Lead

En bref
Comment gérer les traitements asynchrones et les files d'attente dans une application ?
Trois règles suffisent à éviter la quasi-totalité des incidents : toute tâche doit être rejouable sans effet de bord (idempotence), toute tâche qui échoue définitivement doit atterrir quelque part où quelqu'un la voit (file d'échec), et l'ordre d'exécution ne doit jamais être supposé. Une file d'attente ne garantit pas qu'un message est traité une seule fois — elle garantit qu'il est traité au moins une fois, ce qui n'est pas la même chose et change la façon d'écrire le code.
Les traitements de fond sont la partie d’une application dont on parle le moins et qui produit le plus d’incidents étranges. Un courriel envoyé trois fois. Un stock décrémenté deux fois. Une facture générée en double.
À chaque fois, la cause est la même : on a supposé quelque chose que la file d’attente ne garantit pas.
Ce qu’une file garantit vraiment
C’est le point de départ, et il est contre-intuitif.
Une file d’attente garantit qu’un message sera traité au moins une fois. Elle ne garantit pas qu’il le sera exactement une fois.
La raison est simple. Le programme prend le message, fait le travail, puis signale à la file qu’il a terminé. Si le processus meurt entre le travail et le signal — redémarrage, coupure réseau, dépassement de délai — la file considère le message comme non traité et le redonne à quelqu’un d’autre. Le travail est refait.
Ce n’est pas un défaut : c’est le choix de conception qui garantit qu’aucun message n’est perdu. Perdre un message serait pire. Mais cela veut dire qu’il faut écrire le code en supposant qu’il sera rejoué.
Règle 1 — Toute tâche doit être rejouable
C’est ce qu’on appelle l’idempotence : exécuter deux fois produit le même résultat qu’exécuter une fois.
Trois techniques, selon les cas.
Vérifier avant d’agir. Avant d’envoyer le courriel de confirmation, regarder si un envoi est déjà enregistré pour cette commande. Simple, et suffisant pour la majorité des cas.
Utiliser une clé d’unicité en base. Créer une ligne dans une table avec une contrainte unique sur l’identifiant du message. Si l’insertion échoue, c’est que le message a déjà été traité, et on s’arrête. Cette méthode est fiable même quand deux exécutions ont lieu en parallèle, ce que la première ne garantit pas.
Rendre l’opération naturellement idempotente. « Positionner le statut à validé » se rejoue sans dommage ; « incrémenter le compteur de 1 » non. Quand on a le choix, la première formulation est toujours préférable.
Sur les appels vers des services extérieurs, la même logique s’applique et se transmet : la plupart des interfaces de paiement ou d’envoi acceptent une clé fournie par l’appelant, qui leur permet de reconnaître une demande déjà traitée. Il faut la renseigner — c’est exactement à cela qu’elle sert.
Règle 2 — Les échecs définitifs doivent atterrir quelque part
Une tâche qui échoue est retentée. Après quelques tentatives, elle échoue définitivement. Et là, deux comportements possibles.
Le mauvais : le message disparaît. Personne ne sait qu’un traitement n’a pas eu lieu. Le client n’a jamais reçu son document, et on l’apprendra par lui, trois semaines plus tard.
Le bon : le message part dans une file d’échec, où il est conservé avec l’erreur qui l’a provoqué. Quelqu’un la consulte, comprend, corrige, et rejoue le message.
Cette file d’échec doit avoir deux propriétés pour servir à quelque chose. Elle doit être surveillée — une alerte dès qu’elle n’est plus vide, sinon elle se remplit en silence pendant des mois. Et elle doit être rejouable en un geste, sinon personne ne traitera les messages qui s’y trouvent.
Une file d’échec pleine que personne ne regarde est pire que pas de file du tout : elle donne l’illusion que le sujet est traité.
Photo : domaine public (CC0).
Règle 3 — Ne jamais supposer l’ordre
Deux messages envoyés dans l’ordre A puis B peuvent être traités dans l’ordre B puis A. Il suffit que le premier consommateur soit ralenti, ou qu’A ait été retenté.
Cela produit des incidents difficiles à reproduire : une mise à jour écrasée par une version plus ancienne, un enfant créé avant son parent.
Deux réponses, selon le besoin réel.
Rendre chaque message autonome. Le message porte tout ce qu’il faut pour être traité, y compris un horodatage ou un numéro de version. Le consommateur ignore un message plus ancien que l’état courant. C’est la solution la plus robuste.
Sérialiser par clé. La plupart des systèmes de file permettent de garantir l’ordre à l’intérieur d’un même groupe — tous les messages d’un même dossier, par exemple. On garde du parallélisme entre dossiers, et de l’ordre à l’intérieur de chacun.
Ce qu’il ne faut pas faire, c’est passer toute la file en traitement séquentiel pour régler le problème. On perd tout le bénéfice de l’asynchrone, et la file finit par accumuler du retard.
Ce que je surveille en production
Quatre mesures, et elles se lisent ensemble.
La profondeur de la file. Une file qui grandit régulièrement signifie que la production dépasse la consommation. Ce n’est pas un incident immédiat, c’en est un annoncé.
L’âge du plus vieux message. Plus parlant que la profondeur : une file de dix mille messages traités en deux minutes va bien ; une file de cinquante messages dont le plus ancien date d’une heure ne va pas.
Le taux d’échec et de nouvelle tentative. Une hausse signale généralement une dépendance externe en difficulté, avant même que les utilisateurs ne le ressentent.
Le contenu de la file d’échec, avec une alerte au premier message.
Photo : domaine public (CC0).
Les deux erreurs que je vois le plus souvent
Mettre la file au mauvais endroit. Envoyer un courriel en tâche de fond est une bonne idée. Calculer en tâche de fond ce que l’utilisateur attend à l’écran en est une mauvaise : on transforme une attente de deux secondes en une incertitude, et il faut ensuite construire tout un mécanisme pour informer l’utilisateur que c’est prêt. La règle : passe en asynchrone ce dont le résultat n’est pas attendu immédiatement.
Traiter les tâches planifiées différemment des tâches de file. Une tâche qui tourne toutes les nuits subit exactement les mêmes problèmes : elle peut être lancée deux fois, échouer au milieu, ou tourner alors que la précédente n’est pas terminée. Elle a besoin des mêmes règles — être rejouable, signaler ses échecs, et refuser de démarrer si une exécution est déjà en cours.
Le dimensionnement, qu’on découvre en incident
Une file absorbe les pointes — c’est même son principal intérêt. Encore faut-il que la consommation puisse rattraper.
Le calcul à faire une fois, au moment de la conception : combien de messages par minute en pointe, combien de temps prend un message, combien de consommateurs en parallèle. Si la production dépasse durablement la consommation, la file ne fait que déplacer le problème dans le temps — et elle finit par saturer.
Deux limites à poser explicitement. Un nombre maximal de consommateurs, sinon la file peut saturer la base de données en montant en charge : on remplace un goulot par un autre, en pire. Et une durée de vie des messages, pour éviter qu’un message vieux de trois jours ne soit traité comme s’il était frais — le contexte a changé, et le traiter fait souvent plus de dégâts que de l’abandonner.
Par où commencer sur une application existante
Un exercice d’une heure, très rentable.
Lister les traitements de fond existants, et pour chacun répondre à trois questions : que se passe-t-il si on l’exécute deux fois ? où va-t-il quand il échoue définitivement ? qu’est-ce qui casse si l’ordre change ?
Sur une application qui n’a jamais fait cet exercice, la moitié des traitements ont au moins une réponse inconfortable. Les corriger prend quelques jours, et cela élimine une catégorie entière d’incidents intermittents — ceux qu’on classe « pas reproductible » et qui reviennent tous les deux mois.