Messagerie et streaming d'événements

Patterns de messagerie et garanties de livraison : idempotence, outbox, retries, ordre

Avancé14 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Chaque broker promet la fiabilité ; aucun ne peut rendre votre application fiable à lui seul. Ce guide explique ce que signifient réellement at-most-once, at-least-once et exactly-once, et d'où viennent les doublons et les pertes, puis passe en revue les patterns qui comblent ces failles — consommateurs idempotents, transactional outbox, retry avec backoff et dead-letter queues, ordre par clé, request-reply, competing consumers, claim check et versionnage des schémas — en montrant comment chacun se traduit dans ActiveMQ, RabbitMQ et Kafka.

Ce que vous allez apprendre

  • Expliquer les trois garanties de livraison et situer précisément où un doublon ou une perte peut survenir dans un pipeline
  • Concevoir un consommateur idempotent avec un registre de dédoublonnage et une fenêtre de rétention adaptée
  • Mettre en œuvre le transactional outbox pour que les écritures en base et les événements publiés ne puissent pas diverger
  • Construire une gestion des retries, du backoff et des dead letters qui ne bloque pas les messages sains
  • Préserver l'ordre là où il compte grâce aux clés, aux message groups, aux partitions et aux single active consumers
  • Appliquer correctement request-reply, competing consumers, claim check et l'évolution des schémas sur chaque broker

Les brokers mettent en avant la durabilité, les acquittements et les transactions, et l'on en déduit facilement qu'en utiliser un rend un système fiable. Ce n'est pas le cas. La fiabilité est une propriété du chemin complet — producteur, broker, consommateur et chaque effet de bord déclenché par le consommateur — et le broker n'en maîtrise que le milieu. Les patterns de ce guide sont ceux qui rendent aussi les extrémités fiables. Ils sont indépendants du broker ; la dernière section montre comment chacun se traduit dans ActiveMQ Classic, RabbitMQ et Kafka, que les guides précédents de ce sujet détaillent.

Ce que signifient vraiment les trois garanties

Les garanties de livraison décrivent ce qu'il advient d'un message quand quelque chose échoue entre le moment où un producteur l'envoie et celui où un consommateur en a terminé avec lui.

At-most-once. Le producteur envoie sans attendre de confirmation, ou le consommateur acquitte avant de traiter. Une défaillance, où qu'elle survienne, fait perdre le message ; rien n'est jamais dupliqué. C'est le bon choix pour des données vite remplacées — échantillons de métriques, mises à jour de présence, invalidations de cache — où un message perdu est corrigé par le suivant.

At-least-once. Le producteur attend la confirmation du broker et renvoie si elle n'arrive pas ; le consommateur n'acquitte qu'après le traitement. Rien n'est perdu, mais chaque nouvel essai peut créer un doublon : le premier envoi du producteur a peut-être réussi avant que la confirmation ne se perde, et le consommateur a peut-être terminé son travail avant de planter sans avoir acquitté. C'est ce que font tourner presque tous les systèmes en production.

Exactly-once. Chaque message affecte le système une fois et une seule. Aucun broker ne peut le garantir pour un consommateur quelconque, car les effets de bord du consommateur — un appel HTTP, une ligne dans une autre base — échappent à son contrôle. Ce que les brokers peuvent offrir, c'est l'exactly-once à l'intérieur d'un périmètre : les transactions Kafka rendent atomique un cycle read-process-write entre topics Kafka, et une session JMS transactionnelle rend atomique la consommation plus la production sur un même broker. Hors de ce périmètre, l'exactly-once se construit à partir d'une livraison at-least-once et d'un traitement idempotent.

Il vaut la peine de retenir d'où viennent les doublons et les pertes, car chaque pattern ci-dessous cible l'un de ces points :

Point de défaillanceSymptôme sans patternPattern
Le producteur plante après le commit en base, avant la publicationÉvénement perdu, systèmes incohérentsTransactional outbox
La confirmation du producteur se perd, le producteur renvoieMessage dupliquéProducteur idempotent (Kafka), dédoublonnage par le broker ou consommateur idempotent
Le broker plante avant de persisterMessage perduLivraison persistante, confirms, réplication
Le consommateur plante après l'effet de bord, avant l'acquittementEffet de bord dupliquéConsommateur idempotent
Le consommateur ne parvient pas à traiter un messageFile bloquée ou boucle infinieRetry avec backoff, dead-letter queue
Consommateurs en parallèleDésordreOrdre par clé

Consommateur idempotent

Un consommateur idempotent aboutit au même état final, qu'il traite un message une fois ou plusieurs. C'est le pattern le plus précieux de la messagerie, parce qu'il rend sûre la livraison at-least-once — et at-least-once, c'est ce dont vous disposez.

Certaines opérations sont idempotentes par nature : passer un statut à SHIPPED, faire un upsert d'une ligne indexée par l'identifiant de commande, supprimer un enregistrement. D'autres ne le sont pas — incrémenter un compteur, débiter une carte, envoyer un e-mail — et exigent un registre de dédoublonnage : une table ou une entrée clé-valeur qui recense les identifiants de messages déjà traités, vérifiée et écrite dans la même transaction que l'effet de bord.

@Transactional
public void handle(OrderPaid event, String messageId) {
  // INSERT ... ON CONFLICT DO NOTHING; returns 0 rows when the id was already recorded
  if (processedMessages.insertIfAbsent(messageId, Instant.now()) == 0) {
    return;                                 // duplicate: already handled, acknowledge and move on
  }
  ledger.credit(event.merchantId(), event.amount());
  // commit writes the ledger entry and the de-duplication row atomically
}

Trois détails décident si cela fonctionne. L'identifiant doit être attribué par le producteur — un identifiant de message qu'il génère une seule fois, ou une clé métier comme l'identifiant de commande plus le type d'événement — car un identifiant attribué par le broker change quand le producteur renvoie. La vérification et l'effet de bord doivent se trouver dans une seule transaction, sans quoi un crash entre les deux recrée le problème. Enfin, le registre doit purger ses entrées après une fenêtre plus longue que toute relivraison possible, faute de quoi elle grossit indéfiniment. Quand l'effet de bord est un appel à un autre système, transmettez ce même identifiant comme clé d'idempotence pour que le système en aval puisse lui aussi dédoublonner ; les prestataires de paiement et la plupart des API bien conçues en acceptent une.

Transactional outbox

Un service qui enregistre une commande et publie un événement OrderPlaced a un problème de double écriture : la base de données et le broker sont deux systèmes, et aucune transaction ne couvre les deux. Publier après le commit expose à une commande enregistrée sans événement ; publier avant expose à un événement pour une commande annulée par un rollback.

Le transactional outbox retire le second système de la transaction. Le service insère l'événement dans une table outbox, dans la même transaction que la commande. Un relais distinct lit les nouvelles lignes de l'outbox et les publie, en marquant chacune comme envoyée. Si le relais plante après avoir publié et avant d'avoir marqué la ligne, l'événement est publié deux fois — c'est de l'at-least-once, et le consommateur idempotent décrit plus haut l'absorbe.

CREATE TABLE outbox (
  id            UUID PRIMARY KEY,           -- becomes the message id
  aggregate_id  TEXT NOT NULL,              -- becomes the routing key or partition key
  event_type    TEXT NOT NULL,
  payload       JSONB NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at  TIMESTAMPTZ
);
CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;

Le relais peut être un poller planifié (SELECT ... WHERE published_at IS NULL ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED, publication, mise à jour) ou un outil de change data capture comme Debezium, qui suit le journal de transactions de la base et publie chaque insertion dans l'outbox vers Kafka, sans polling et dans un ordre strict. Le polling est plus simple et suffit pour des volumes modérés ; le CDC monte plus haut en charge et n'ajoute aucune charge sur les tables. Dans les deux cas, supprimez ou archivez les lignes publiées pour que la table reste petite.

L'outbox vous offre aussi gratuitement l'ordre des événements par agrégat : les lignes sont publiées dans l'ordre d'insertion, et utiliser aggregate_id comme message group ou clé de partition conserve cet ordre côté consommateur.

Retries, backoff et dead letters

Un consommateur qui échoue sur un message a trois options honnêtes : réessayer, ignorer ou mettre de côté. La mauvaise option — réessayer immédiatement et indéfiniment — est le comportement par défaut de nombreux listener containers, et elle transforme un seul poison message en une file qui ne se vide jamais et en une dépendance martelée jusqu'à ce qu'elle tombe.

Une politique saine comporte quatre volets :

  1. Classer l'échec. Un timeout ou un 503 est transitoire et mérite un nouvel essai. Une erreur de validation ou un 404 sur une entité référencée est permanent ; le réessayer gaspille des tentatives.
  2. Espacer les essais de façon exponentielle, avec jitter. Une seconde, deux, quatre, huit, plus une fraction aléatoire pour qu'une centaine de consommateurs en échec simultané ne réessaient pas tous au même instant. Plafonnez le délai et le nombre de tentatives.
  3. Réessayer sans bloquer la file. Un retry en mémoire monopolise le thread du consommateur et les messages qui suivent. Pour des délais au-delà de quelques secondes, republiez le message vers une destination de retry avec un délai — une file à TTL qui renvoie en dead letter vers la file de travail dans RabbitMQ, une livraison planifiée dans ActiveMQ, un topic de retry consommé après une attente dans Kafka — et acquittez l'original.
  4. Envoyer le reste en dead letter. Après la dernière tentative, déplacez le message vers une dead-letter queue (DLQ) en y joignant en en-têtes l'exception, la stack trace, le nombre de tentatives et la destination d'origine. Une DLQ que personne ne lit est un trou noir : alertez sur sa profondeur et gardez un outil pour inspecter et rejouer son contenu.

Ignorer mérite une mention : pour les flux où un enregistrement défectueux ne doit pas arrêter le pipeline — analytique, logs —, journaliser l'enregistrement et passer au suivant est une politique légitime, à condition que ce soit une décision et non un accident.

Ordre

Les brokers ne préservent l'ordre qu'à un seul endroit : une file ou une partition unique lue par un consommateur unique. Tout ce qui ajoute du débit — competing consumers, partitions multiples, relivraison après un échec — l'affaiblit. La position tenable est l'ordre par clé : tous les messages concernant une même entité arrivent dans l'ordre, tandis que ceux qui concernent des entités différentes peuvent s'entrelacer.

BrokerMécanismeCôté consommateur
ActiveMQ ClassicMessage groups JMSXGroupID : tous les messages d'un même group id vont à un seul consommateur tant qu'il est vivantRien de particulier ; le broker épingle le groupe
RabbitMQUne file par plage de clés (plugin consistent-hash exchange) ou une file unique avec x-single-active-consumerUn seul consommateur actif par file
KafkaPartitionnement par clé ; le partitioner par défaut le faitUn consommateur par partition au sein d'un groupe

Deux subtilités subsistent même avec l'ordre par clé. Un message réessayé peut doubler des messages ultérieurs de la même clé, sauf si les retries sont eux aussi gérés par clé — la correction la plus sûre consiste à suspendre le traitement de la clé jusqu'à ce que le retry réussisse, ce que Kafka fait naturellement en bloquant la partition. Et le sens d'un événement ne devrait pas dépendre d'un ordre strict quand on peut l'éviter : porter un numéro de version ou un horodatage dans chaque événement permet à un consommateur d'ignorer une mise à jour périmée arrivée en retard, ce qui coûte moins cher qu'un ordre parfait.

Patterns d'interaction

Competing consumers

Plusieurs consommateurs lisant une même file se partagent ses messages, et ajouter un consommateur augmente le débit jusqu'à ce que le producteur ou une dépendance en aval devienne la limite. C'est le modèle de montée en charge par défaut des files et, avec les consumer groups, de Kafka. Gardez le prefetch ou max.poll.records modeste pour que le travail soit réparti équitablement et qu'un crash ne provoque que peu de relivraisons.

Publish-subscribe

Un événement, plusieurs lecteurs indépendants, chacun avec sa propre position et ses propres échecs. On le met en œuvre avec des topics et des abonnements durables en JMS, un exchange fanout ou topic alimentant une file par abonné dans RabbitMQ, et des consumer groups indépendants dans Kafka. Chaque abonné doit gérer ses propres retries et dead letters ; un abonné lent ne doit jamais pouvoir bloquer les autres.

Request-reply

Un message qui attend une réponse a besoin d'un identifiant de corrélation que la réponse renvoie et d'une adresse de réponse vers laquelle le répondeur envoie. Donnez un timeout à chaque requête et décidez de ce que fait le demandeur à son expiration — une réponse arrivée après le timeout doit être reconnue comme périmée et écartée. Comme le request-reply réintroduit un couplage temporel, réservez-le aux cas où l'appelant ne peut vraiment pas continuer sans la réponse.

Claim check

Les brokers sont conçus pour de petits messages ; une charge utile de 20 Mo ralentit chaque saut et peut dépasser les limites de trame. Le pattern claim check stocke la charge utile dans un object storage ou une base de données et envoie un message qui ne contient qu'une référence et les métadonnées dont un routeur a besoin. Le consommateur récupère la charge utile au moment de traiter le message. Purgez les charges stockées avec la même rigueur que les enregistrements de dédoublonnage.

Routage par contenu et traduction de messages

Quand un message doit aller à différents consommateurs selon ses attributs, routez sur les en-têtes ou les routing keys au niveau du broker (un topic exchange, un selector, un topic par type), plutôt que de faire tout recevoir à chaque consommateur pour qu'il en jette l'essentiel. Quand deux systèmes ne s'accordent pas sur le format, placez la traduction à un seul endroit — un consommateur dédié qui republie — plutôt que dans chaque consommateur.

Évolution des schémas

Les messages survivent au code qui les a produits. Une file peut contenir le format d'hier au moment où le consommateur d'aujourd'hui est déployé, et un topic Kafka conserve une semaine d'historique. Chaque message a donc besoin d'une version de schéma, et chaque changement doit être classé comme compatible ou cassant :

  • Compatible : ajouter un champ optionnel avec une valeur par défaut, élargir un type numérique, ajouter un nouveau type d'événement sur une nouvelle routing key. Les consommateurs ignorent les champs inconnus.
  • Cassant : renommer ou supprimer un champ, changer un type ou sa signification, changer la clé. Ces changements exigent un nouveau topic ou une nouvelle routing key, ou une étape de traduction, ainsi qu'une période pendant laquelle les deux versions sont produites ou consommées.

Un schema registry (Avro, Protobuf ou JSON Schema) applique les règles de compatibilité au moment de la publication et mérite d'être adopté dès que plus d'une équipe consomme un topic. Sans registry, un en-tête schemaVersion et un consommateur qui valide avant de traiter sont le minimum ; utilisez le formateur JSON pour inspecter les charges utiles et le décodeur Base64 quand un outil affiche le corps encodé.

Correspondance entre patterns et brokers

PatternActiveMQ ClassicRabbitMQKafka
At-least-onceLivraison persistante, ack AUTO/CLIENT ou session transactionnelleQuorum queue durable, message persistant, confirms, ack manuelacks=all, producteur idempotent, commit après traitement
Périmètre exactly-once côté brokerSession transactionnelle (consommation + production sur un broker), XA via un serveur d'applicationsAucun ; utilisez des consommateurs idempotentsTransactions avec des consommateurs read_committed, EOS de Kafka Streams
Dédoublonnage côté producteurAucun ; utilisez un identifiant d'outboxAucun ; utilisez un identifiant d'outboxProducteur idempotent (par session)
Retry différéLivraison planifiée (AMQ_SCHEDULED_DELAY)File à TTL avec dead-letter exchange vers la file de travailTopics de retry consommés après un délai
Dead lettersActiveMQ.DLQ ou DLQ par destination, automatique après la redelivery policyDead-letter exchange, x-delivery-limit sur les quorum queuesPas intégré ; topic d'erreurs écrit par le consommateur ou le framework
Ordre par cléJMSXGroupIDSingle active consumer, consistent-hash exchangeClé de partition
Pub-subTopics, abonnements durables et partagésExchange fanout/topic avec une file par abonnéConsumer groups
RejeuNonStreams uniquementRéinitialisation des offsets
Request-replyFiles temporaires, JMSReplyTo, JMSCorrelationIDDirect reply-to, propriétés reply_to et correlation_idTopic de réponse indexé par identifiant de corrélation ; peu commode

Le tableau met une chose en évidence : Kafka offre les garanties les plus fortes côté producteur et côté broker, et l'outillage par message le plus pauvre, tandis que les brokers à files offrent une gestion riche de chaque message et vous laissent régler vous-même le dédoublonnage. Aucun ne dispense des consommateurs idempotents et d'un outbox.

Erreurs courantes

  • Croire l'« exactly-once » inscrit sur la boîte. Il s'applique à l'intérieur du périmètre du broker. Les effets de bord hors de ce périmètre exigent l'idempotence.
  • Des identifiants attribués par le broker pour le dédoublonnage. Ils changent quand le producteur renvoie. Générez l'identifiant dans le producteur ou dérivez-le d'une clé métier.
  • Le dédoublonnage hors de la transaction. Vérifier la table, faire le travail, puis enregistrer l'identifiant laisse une fenêtre de crash qui reproduit le doublon. Une seule transaction.
  • Publier après le commit sans outbox. Cela fonctionne jusqu'au premier crash entre les deux, qu'il est alors presque impossible de reconstituer.
  • Des retries immédiats et illimités. Les poison messages bloquent les files et les retries amplifient les pannes. Espacez, plafonnez, envoyez en dead letter.
  • Supposer un ordre global. Il n'existe pas au-delà d'un consommateur sur une file ou une partition. Raisonnez par clé et portez des numéros de version.
  • De grosses charges utiles sur le bus. Utilisez un claim check ; limitez les messages à quelques kilo-octets.

Points clés à retenir

  • L'at-least-once est ce que vous obtenez de n'importe quel broker en production ; l'exactly-once n'existe qu'à l'intérieur du périmètre transactionnel d'un broker, et l'at-most-once est un choix délibéré pour des données jetables.
  • Les doublons viennent des renvois du producteur et des crashs du consommateur avant l'acquittement ; le consommateur idempotent, avec un identifiant attribué par le producteur et vérifié dans la transaction de l'effet de bord, absorbe les deux.
  • Le transactional outbox rend cohérents écritures en base et événements publiés en plaçant l'événement dans la base et en le relayant ensuite.
  • Réessayez les échecs transitoires avec un backoff exponentiel plafonné, mettez les échecs permanents de côté dans une dead-letter queue avec leur cause, et ne laissez jamais un message défectueux arrêter la file.
  • L'ordre n'est préservé que par file ou par partition avec un seul consommateur ; concevez pour un ordre par clé avec les message groups, les single active consumers ou les clés de partition, et portez des numéros de version.
  • Choisissez délibérément vos patterns d'interaction — competing consumers, pub-sub, request-reply avec timeouts, claim check pour les grosses charges utiles — et versionnez chaque schéma de message dès la première release.

Sources et lectures complémentaires

Questions fréquentes

Si mon broker gère l'exactly-once, ai-je encore besoin de consommateurs idempotents ?
Oui, sauf si chaque effet de bord de votre consommateur se trouve lui-même dans la transaction du broker. L'exactly-once de Kafka couvre les traitements de Kafka vers Kafka ; dès qu'un consommateur appelle une API de paiement ou écrit une ligne en base, cet effet sort de la transaction et peut être rejoué après un crash. L'idempotence côté consommateur est le seul mécanisme qui fonctionne quel que soit le broker.
Pourquoi ne pas simplement publier l'événement après le commit en base ?
Parce que le processus peut mourir entre le commit et la publication, laissant une commande enregistrée sans événement, ou la publication peut réussir et le commit échouer, laissant un événement pour une commande qui n'existe pas. Le transactional outbox écrit l'événement dans la même transaction que les données et confie sa publication à un relais distinct : les deux ne peuvent plus jamais diverger.
Combien de temps faut-il conserver les enregistrements de dédoublonnage ?
Plus longtemps que la plus longue relivraison plausible. Cette durée est bornée par la rétention ou la fenêtre de relivraison du broker : quelques heures couvrent la plupart des relivraisons de files, alors que des consommateurs Kafka qui rembobinent une semaine d'historique exigent une semaine. Stockez l'identifiant du message avec un horodatage et purgez les entrées par un job en arrière-plan ou avec un TTL en base.
Faut-il relancer immédiatement les messages en échec ?
Une ou deux fois seulement, pour absorber les micro-coupures réseau. Au-delà, les retries immédiats martèlent une dépendance déjà en difficulté et bloquent les messages qui suivent. Utilisez un backoff exponentiel avec jitter, plafonnez le nombre de tentatives et déplacez le message vers une destination de dead letters avec la cause de l'échec, pour que les messages sains continuent de circuler.
Middleware orienté messages : files, topics et communication asynchroneRôle d'un broker de messages, découplage producteurs/consommateurs par files et topics, protocoles existants, et quand préférer la messagerie à un appel HTTP.Débutant12 min de lectureProducteurs et consommateurs Kafka en Java : offsets, idempotence et transactionsClients Kafka en Java : batching, acks et idempotence du producteur, boucle de poll, commits d'offsets, rééquilibrages, transactions exactly-once, Spring Kafka.Intermédiaire9 min de lectureRabbitMQ avec Spring AMQP et Spring Boot : RabbitTemplate, @RabbitListener et retriesSpring AMQP dans Spring Boot 3 : files en beans, publication JSON avec RabbitTemplate, consommation par @RabbitListener, confirms, retries et dead letters.Intermédiaire8 min de lectureArchitectureTransactions distribuées et cohérence : 2PC, sagas, outbox et CAPACID contre BASE, ce que disent vraiment CAP et PACELC, pourquoi le commit à deux phases bloque, et comment fonctionnent sagas, outbox et idempotence.Avancé13 min de lectureArchitecturePatterns de conception microservices : résilience, découverte, données et observabilitéUn catalogue pratique des patterns microservices : gateway, découverte, circuit breaker, bulkhead, retry, saga, outbox, CQRS et service mesh.Avancé11 min de lectureArchitectureDomain-Driven Design : langage omniprésent, agrégats et bounded contextsComment le DDD construit le logiciel autour d'un modèle partagé du métier : bounded contexts, agrégats en Java, context maps et architecture hexagonale.Avancé11 min de lecture