Pattern transactional outbox : diagramme et guide de mise en œuvre

Le pattern transactional outbox règle la double écriture : l'événement s'écrit dans la même transaction que les données, puis est relayé. Diagramme, SQL, polling ou CDC.

· Fondateur et développeur

Publié le

Pattern transactional outbox : diagramme et guide de mise en œuvre

Tout service orienté événements finit par écrire un code de ce genre : enregistrer la commande, puis publier OrderPlaced. Ça marche en test. En production, le processus plante entre les deux lignes, et la commande existe sans l'événement. Ou bien la publication réussit, la transaction en base est annulée, et l'événement annonce une commande qui n'existe pas.

C'est le problème de double écriture (dual write), et le pattern transactional outbox en est la solution standard. Cet article explique le problème, le pattern, les deux façons de construire le relais, et les détails qui décident s'il fonctionne vraiment : ordre, doublons et nettoyage.

Le problème de double écriture

Une base de données et un broker de messages sont deux systèmes sans transaction commune. Tout code qui écrit dans les deux a une fenêtre pendant laquelle une écriture a réussi et l'autre non :

Ordre des opérations Ce qui peut mal tourner
Commit, puis publication Crash après le commit : l'événement est perdu pour toujours
Publication, puis commit Le commit échoue : les consommateurs agissent sur une commande qui n'a jamais existé
Publication dans la transaction L'appel au broker réussit, la transaction est quand même annulée

Les nouvelles tentatives ne ferment pas la fenêtre ; elles la déplacent. Les astuces d'ordonnancement, comme publier depuis un hook après commit, ne font que la réduire : un crash entre le commit et le hook perd toujours l'événement. Les transactions distribuées entre la base et le broker sont rarement disponibles, et lentes quand elles le sont.

Le pattern

Écrivez l'événement dans la base, dans la même transaction que les données métier, et publiez-le plus tard depuis là :

  1. Le service insère la ligne de commande et une ligne outbox (ID d'événement, ID d'agrégat, type, payload) dans une seule transaction locale. Les deux sont validées, ou aucune.
  2. Un relais séparé lit les nouvelles lignes outbox et les publie sur le broker.
  3. Le relais marque les lignes comme publiées, ou les supprime, une fois que le broker a accusé réception.

La garantie devient simple : si la commande existe, son événement sera publié, au moins une fois. La description du pattern par Chris Richardson utilise la même structure.

CREATE TABLE outbox (
  id            uuid PRIMARY KEY,
  aggregate_id  bigint      NOT NULL,
  type          text        NOT NULL,
  payload       jsonb       NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now()
);

Deux façons de construire le relais

Publication par polling. Une boucle sélectionne les lignes non publiées par lots, les publie et les supprime. Dans Postgres, SELECT ... FOR UPDATE SKIP LOCKED permet à plusieurs instances du relais de se partager le travail sans se bloquer ; voir la documentation de la clause de verrouillage. C'est facile à construire et à comprendre. Les coûts sont la latence et la charge du polling, plus une subtilité d'ordre : des lignes avec des ID plus petits peuvent être validées après des lignes avec des ID plus grands, donc un poller qui trie par ID peut sauter une ligne pas encore visible.

Lecture du journal (change data capture). Un outil de CDC lit le journal de transactions de la base et transforme les insertions validées dans l'outbox en messages. L'outbox event router de Debezium fait exactement cela pour les tables outbox, en routant chaque ligne vers un topic selon ses colonnes. Les événements sortent dans l'ordre des commits, avec une faible latence et sans requêtes de polling, au prix d'un pipeline de CDC à faire tourner et à exploiter.

Polling CDC
Latence Intervalle de polling Proche du moment du commit
Ordre Demande de l'attention Ordre des commits
Pièces mobiles Un worker Un connecteur et la configuration du journal
Cas adapté Volumes modérés, petites équipes Gros volumes, nombreuses outbox

Au moins une fois, donc des doublons

Le relais peut publier une ligne et planter avant de la marquer comme publiée. Au redémarrage, il la publie de nouveau. C'est inévitable, donc les consommateurs doivent être idempotents :

  • Chaque événement porte un ID unique (l'ID de la ligne outbox).
  • Les consommateurs enregistrent les ID qu'ils ont traités, idéalement dans la même transaction que l'effet du traitement (une table inbox), et ignorent les ID déjà vus.
  • Ou rendez l'effet naturellement idempotent, par exemple avec un upsert sur l'ID de commande.

Ordre

Les consommateurs tiennent en général à l'ordre par agrégat : OrderPlaced avant OrderCancelled pour une même commande. Utilisez l'ID d'agrégat comme clé de message pour que tous les événements d'une commande aillent dans la même partition, et publiez dans l'ordre des commits (la CDC le fait ; un poller doit trier selon une séquence attribuée au moment du commit, ou traiter les événements d'un agrégat en série). Un ordre global sur toutes les commandes est rarement nécessaire et coûteux à fournir.

Nettoyage

Une table outbox jamais vidée grossit sans limite et ralentit le poller. Supprimez les lignes après publication, ou gardez-les pendant une courte fenêtre d'audit et supprimez les anciennes partitions. Avec la CDC, les lignes peuvent être supprimées juste après l'insertion, dans la même transaction, puisque le connecteur lit l'insertion dans le journal et non dans la table.

Passer les pannes en revue

Vérifiez la conception à chaque point de crash :

  • Crash avant le commit. Ni la commande ni l'événement n'existent. Le client reçoit une erreur et peut réessayer.
  • Crash après le commit, avant le passage du relais. Les deux lignes existent ; le relais publie l'événement à son démarrage.
  • Crash du relais après la publication, avant la suppression de la ligne. L'événement est republié au redémarrage, et les consommateurs écartent le doublon grâce à l'ID.
  • Broker indisponible. Les lignes s'accumulent dans l'outbox ; le service continue de prendre des commandes, et le relais rattrape son retard quand le broker revient. Déclenchez une alerte sur l'âge de la plus ancienne ligne non publiée.

Dans tous les cas, la base reste la source de vérité, et c'est précisément la propriété que le pattern doit fournir.

Le diagramme d'architecture

Un service de commandes insère des lignes dans deux tables, orders et outbox, dans une seule transaction Postgres. Un relais outbox lit les nouvelles lignes et les publie dans le topic order-events. Les services d'expédition et d'e-mail les consomment ; l'e-mail enregistre les ID traités.

10 composants · 6 connexions

Ouvrir dans Archboard
Contenu du diagramme
  • Order service
  • Postgres: one transaction
  • orders
  • outbox
  • Outbox relay
  • order-events
  • Shipping service
  • Email service
  • Processed events
  • both rows
  • new rows
  • publish
  • OrderPlaced
  • seen it?
Le pattern transactional outbox. Le service de commandes écrit la commande et une ligne outbox dans une seule transaction Postgres ; un relais publie les lignes outbox dans un topic Kafka, et les consommateurs dédupliquent par ID, car la livraison est au moins une fois.

Comment le lire :

  • À gauche : le service de commandes écrit deux lignes, dans les tables orders et outbox, au sein d'une seule transaction Postgres. Le cadre marque la frontière de la transaction.
  • Au milieu : le relais outbox, par polling ou CDC, lit les nouvelles lignes et les publie dans le topic order-events.
  • À droite : les services d'expédition et d'e-mail consomment les événements. Le service d'e-mail consulte une table d'ID d'événements traités avant d'agir ; c'est ainsi que les doublons sont absorbés.
  • Le bloc SQL montre la transaction, pour que personne n'ait à deviner ce que « atomiquement » veut dire ici.

Ce qu'il faut mettre sur le diagramme

  • La frontière de la transaction. Dessinez les deux tables à l'intérieur ; c'est tout le pattern.
  • Le relais et son type. Polling ou CDC change la latence et l'ordre.
  • La sémantique de livraison. Écrivez « au moins une fois » sur le relais et « idempotent » sur chaque consommateur.
  • La clé de message. Indiquez que les événements ont l'ID d'agrégat pour clé.

L'article sur l'architecture événementielle traite plus en détail le côté consommateur, et le diagramme du pattern saga montre où une outbox est nécessaire à chaque étape d'un workflow multi-services. L'article sur la réplication de bases de données explique le write-ahead log que lit la CDC.

Le construire dans Archboard

  1. Connectez-vous avec Google ou GitHub et créez un diagramme.
  2. Ajoutez un Conteneur du pack Modèle C4 pour le service de commandes et un autre pour le relais outbox.
  3. Ajoutez un Cadre du pack Base, étiqueté avec la base et la transaction, et placez-y deux nœuds Entité du pack Entité-association pour les tables orders et outbox.
  4. Ajoutez Kafka du pack Données pour le topic, des nœuds Conteneur pour les consommateurs et une Base de données pour les ID d'événements traités.
  5. Ajoutez un bloc Markdown du pack Base avec le SQL de la transaction.

L'exemple utilise 10 nœuds de diagramme et reste dans les limites de l'offre Free.

FAQ

Quel problème la transactional outbox résout-elle ? La perte ou l'invention d'événements quand un service écrit dans sa base et publie un message en deux étapes séparées. L'outbox fait de l'événement une partie de la même transaction de base de données.

Le pattern outbox garantit-il exactly once ? Non. Il garantit une livraison au moins une fois. Les consommateurs doivent gérer les doublons, en général en enregistrant les ID d'événements traités.

Polling ou CDC : quel relais choisir ? Le polling est plus simple et suffit pour des volumes modérés. La CDC offre une latence plus faible et une livraison dans l'ordre des commits, et vaut le coup à fort volume ou quand beaucoup de services utilisent des outbox.

Ai-je encore besoin de l'outbox si j'utilise l'event sourcing ? Non, si l'event store est lui-même la source à laquelle les consommateurs s'abonnent. Il en faut une chaque fois qu'un service stocke son état dans des tables et doit aussi publier des événements.

Quelle taille atteint la table outbox ? Celle que vous lui laissez prendre. Supprimez rapidement les lignes publiées, ou partitionnez la table par date et supprimez les anciennes partitions.

Commencez gratuitement — un siège, sans carte bancaire.

Articles associés

← Retour au blog

Ouvrez un canevas en une minute

Connectez-vous avec Google ou GitHub. Gratuit pour un siège. Passez à un forfait supérieur quand l’équipe vous rejoint.