05 / 06 Dual-write et outbox transactionnel
  1. ← Parler par messages : la fiabilité des systèmes distribués
  2. 00 Pourquoi parler par messages
  3. 01 Les trois familles de messages
  4. 02 Courtier contre journal
  5. 03 Sémantiques de livraison
  6. 04 Ordre et idempotence
  7. 05 Dual-write et outbox transactionnel
Parler par messages : la fiabilité des systèmes distribués · 05 / 06

Dual-write et outbox transactionnel

On a blindé le consommateur au chapitre précédent. Mais tout reposait sur une promesse du producteur : publier le message quand l'état métier change. Or publier, c'est toucher deux mondes à la fois, sa base et le broker, et rien ne les rend atomiques ensemble. Ce chapitre répare cette dernière faille.

Au chapitre quatre, on a rendu le consommateur incassable : il déduplique avec sa clé, il respecte l’ordre par partition, et l’atomicité de son inbox rend son idempotence vraie. Mais toute cette mécanique repose sur une supposition qu’on n’a jamais vérifiée : que le producteur, lui, publie bien son message au bon moment. Or publier n’est pas un geste simple. Le service doit faire deux choses : enregistrer l’état métier dans sa base (la commande est créée) et annoncer ce fait au broker (le message part). Deux systèmes distincts, deux écritures séparées. Que se passe-t-il si la base dit oui et que le broker n’entend jamais le message ? Aucune redélivraison ne le sauvera : il n’a jamais existé. Ce chapitre construit le dernier verrou, celui qui scelle l’écriture et la publication ensemble.

Le piège de la double écriture

Reprends notre commande. Quand un client la passe, le service de commande doit faire deux choses : écrire la commande dans sa base, et publier « commande créée » pour que le service de paiement et le service d’expédition réagissent. C’est une double écriture Double écriture Situation où un service doit modifier deux systèmes distincts pour une seule action, typiquement sa propre base de données et un broker de messages. Comme aucune transaction ne couvre les deux à la fois, une panne entre les deux écritures laisse une incohérence : la base est à jour mais le message n'est jamais parti, ou le message est parti mais la base a été annulée. C'est le problème que l'outbox transactionnel résout. Source : Richardson, Microservices Patterns : deux systèmes à modifier pour une seule action métier. Et il n’existe aucune transaction qui couvre à la fois ta base et le broker. Ce sont deux mondes séparés.

Regarde les deux façons de s’y prendre, et leur faille commune. Première façon, écrire la base d’abord, publier ensuite. Si la machine tombe juste après le commit de la base et avant la publication, la commande existe en base mais le message n’est jamais parti. Le paiement ne sera jamais déclenché : le message est perdu, et personne ne le sait.

Seconde façon, publier d’abord, écrire la base ensuite. Si la machine tombe après la publication et avant le commit, le broker a reçu « commande créée » pour une commande qui, en base, n’existe pas. Le service de paiement va débiter un client dont la commande a disparu : c’est un message fantôme.

Perdu ou fantôme, peu importe l’ordre : le défaut est le même. Entre les deux écritures se glisse un instant où une panne peut les désynchroniser, parce qu’aucune atomicité ne les lie. Retenir un message pour réessayer plus tard ne suffit pas non plus : ce buffer vit en mémoire, et la mémoire part avec la panne. Il faut une autre idée.

L’outbox : n’écrire qu’un seul monde

L’idée est d’un beau pragmatisme : puisqu’on ne peut pas rendre atomiques deux systèmes, on n’en écrit plus qu’un seul. On renonce à publier directement au broker. À la place, le service écrit, dans sa propre base, l’état métier ET une ligne qui décrit le message à envoyer, le tout dans la même transaction. Cette table de messages en attente s’appelle l’ outbox transactionnel Outbox transactionnel Motif qui supprime le problème de la double écriture en n'écrivant qu'un seul système de façon atomique. Le service inscrit l'état métier ET une ligne décrivant le message à envoyer dans la même transaction de sa base de données. Un relais lit ensuite cette table outbox et publie les messages vers le broker. Une écriture distribuée impossible devient une écriture locale atomique suivie d'un relais. Source : Richardson, Microservices Patterns .

Le gain est immédiat. Comme la commande et la ligne outbox sont écrites dans une seule transaction de la même base, une panne ne peut plus les séparer : soit les deux sont là, soit aucune. Le scénario « commande en base mais message perdu » devient impossible, parce que le message n’est plus une publication fragile vers un autre monde, c’est une simple ligne dans la transaction qui crée la commande.

L'état métier et le message naissent dans la même transaction. Un relais lit ensuite la table outbox et publie vers le broker.

Mais une ligne dans une table ne s’envoie pas toute seule au broker. Il manque une pièce : qui lit cette table et publie pour de vrai ?

Le relais : du « publier » fragile au « republier jusqu’au succès »

Cette pièce, c’est le relais d’outbox Relais d'outbox Processus qui lit la table outbox, publie vers le broker les messages en attente, puis les marque comme envoyés. Il tourne séparément du service métier. Comme il peut publier un message puis tomber avant de le marquer envoyé, il le republiera : sa livraison est au moins une fois, et les doublons sont absorbés en aval par un consommateur idempotent. Source : Richardson, Microservices Patterns . C’est un processus séparé du service métier, dont la seule tâche est une boucle : lire les lignes outbox encore en attente, les publier au broker, puis les marquer comme envoyées. Rien de plus.

Ce qui est subtil, c’est ce que le relais fait du « publier » qui nous posait problème. Il peut, lui aussi, publier un message puis tomber juste avant de le marquer envoyé. Mais cette fois, ce n’est pas grave : la ligne est toujours marquée « en attente » dans la base, donc au redémarrage le relais la retrouve et la republie. Le prix à payer, c’est un possible doublon. On reconnaît la livraison au moins une fois du chapitre trois, et c’est exactement là qu’on récolte ce qu’on a semé au chapitre quatre : ce doublon est absorbé en aval par le consommateur idempotent. L’outbox côté producteur et l’inbox côté consommateur sont les deux mâchoires d’une même pince.

Observe les deux méthodes

Le composant ci-dessous fait passer la même commande par les deux méthodes : la double écriture naïve et l’outbox transactionnel. Tu choisis le moment de la panne, et tu compares ce que voient la base et le broker à la fin.

Commence sans panne : les deux méthodes marchent, base et broker sont d’accord. Mais place ensuite la panne entre les deux écritures. En dual-write, la base garde la commande et le broker reste vide : incohérent, message perdu. En outbox, regarde la différence : les deux écritures étaient dans une seule transaction (encadrée en pointillés), la panne ne les a pas séparées, le relais redémarre, retrouve la ligne en attente et publie. Cohérent, livré après reprise. C’est toute la leçon, en un interrupteur.

Dual-write contre outbox

Une commande doit être écrite en base ET annoncée au broker. Choisis la méthode et le moment de la panne, puis regarde si la base et le broker finissent d'accord.

Méthode

Moment de la panne

Écrire la commandePublier au broker
Base de donnéesCommande écrite
BrokerMessage reçu
CohérenceBase et broker d'accord
Cohérent, par chance

Trois questions à te poser en jouant :

  • En dual-write avec panne, la base garde la commande mais le broker reste vide. Pourquoi le verdict parle-t-il de message perdu plutôt que de message en retard ?
  • En outbox, qu’est-ce qui rend la panne entre les deux écritures inoffensive, alors qu’elle était fatale en dual-write ?
  • Le mode outbox affiche « cohérent après reprise » et non « cohérent » tout court. Que signifie ce « après reprise », et qui paie le prix de ce délai ?

Plusieurs relais : la réclamation de ligne

Un seul relais finit par devenir un goulot. On veut donc en faire tourner plusieurs en parallèle. Mais surgit aussitôt un danger : si deux relais lisent la même table et tombent sur la même ligne en attente, ils publient tous les deux le même message. Le doublon est déjà géré par le consommateur idempotent, mais on préfère ne pas le provoquer gratuitement à chaque tour.

La parade est la réclamation de ligne Réclamation de ligne Verrou qui permet à un relais de prendre une ligne de la table outbox sans qu'un relais concurrent ne prenne la même. En SQL, la clause FOR UPDATE SKIP LOCKED : chaque relais réclame des lignes encore libres et saute celles déjà verrouillées par un autre. Sans ce verrou, plusieurs relais publieraient le même message, un double-dispatch. Source : PostgreSQL, Documentation (SELECT FOR UPDATE SKIP LOCKED) . Chaque relais réclame les lignes qu’il va traiter avec un verrou, en SQL la clause FOR UPDATE SKIP LOCKED : il prend les lignes encore libres et saute celles qu’un autre relais a déjà verrouillées. Deux relais ne peuvent plus attraper la même ligne. C’est un ordre partiel sur le travail, exactement dans l’esprit du chapitre quatre, mais appliqué aux lignes de l’outbox plutôt qu’aux messages.

Côté code : deux frameworks, le même pacte

Voyons le pattern dans deux écosystèmes. Le pacte est identique : écrire l’état métier et le message dans la même transaction, et ne laisser partir le message qu’après le commit.

Hexeract, le framework Rust qui nous sert de fil rouge, expose une insertion idempotente dans l’outbox, indexée sur l’identifiant d’événement, plus un relais (OutboxWorker) qui scrute les lignes en attente sous FOR UPDATE SKIP LOCKED et les marque envoyées après succès.

// Côté écriture : l'événement entre dans la table outbox de façon idempotente.
// La clé est event_id : une insertion déjà vue est ignorée.
// (insert_idempotent_sql : INSERT ... ON CONFLICT (event_id) DO NOTHING)
outbox.enqueue_idempotent(event_id, "OrderCreated", &payload).await?;

// Côté relais (OutboxWorker) : la sélection des lignes en attente.
// Une ligne est "en attente" tant que delivered_at IS NULL.
//   SELECT ... FROM outbox
//   WHERE delivered_at IS NULL AND attempts < max_attempts
//     AND (next_retry_at IS NULL OR next_retry_at <= now)
//   ORDER BY id LIMIT n FOR UPDATE SKIP LOCKED
// Après une publication réussie : mark_delivered (UPDATE SET delivered_at = now).

Wolverine, en .NET, fournit le même pacte clé en main avec sa boîte d’envoi durable. Sur une transaction Marten, le message publié n’est pas envoyé tout de suite : il est rangé avec l’écriture métier, et il ne partira qu’après le commit.

// L'état métier et le message dans la même transaction Marten.
session.Store(order);

// Ce message ne part PAS avant le succès de la transaction :
// il est persisté avec la commande, puis relayé après le commit.
await outbox.PublishAsync(new OrderCreated(order.Id));

await session.SaveChangesAsync();

Deux langages, une seule idée : on n’écrit jamais le broker et la base dans deux gestes séparés. On écrit la base une fois, le message avec, et un relais se charge du reste. Le mot « outbox » porte la garantie ; le framework porte la plomberie.

Exercices

Prends une feuille et un crayon. Les corrigés sont juste en dessous, à ne regarder qu’après avoir essayé.

Exercice 1 : les deux visages de la double écriture

Un service de commande fait deux choses pour chaque commande : écrire en base et publier « commande créée ». On considère deux implémentations naïves. (a) Il écrit la base, puis publie. (b) Il publie, puis écrit la base. Pour chacune, une panne survient au pire moment, juste entre les deux gestes. Décris l’état final (que voit la base, que voit le broker) et nomme le défaut. Puis explique en une phrase pourquoi l’outbox transactionnel élimine les deux d’un coup.

Exercice 2 : le relais qui tombe au mauvais moment

Un relais d’outbox lit une ligne en attente, la publie avec succès au broker, puis sa machine s’éteint juste avant qu’il ait pu la marquer envoyée. (i) Décris ce qui se passe au redémarrage du relais. (ii) Combien de fois le message est-il publié au total ? (iii) Qui, en aval, empêche ce comportement de causer un double paiement ? Relie ta réponse à un mécanisme vu au chapitre quatre.

En une phrase

On ne peut pas rendre atomiques une base et un broker, alors on n’écrit qu’un seul monde : l’état métier et le message partent ensemble dans une transaction de la base (l’outbox transactionnel), puis un relais publie au moins une fois ce que la transaction a scellé, et le consommateur idempotent du chapitre quatre absorbe les doublons que ce relais peut produire.

Quiz
  1. 1. Qu'est-ce que le problème de la double écriture ?

  2. 2. Comment l'outbox transactionnel supprime ce problème ?

  3. 3. Pourquoi plusieurs relais doivent-ils réclamer leurs lignes avec FOR UPDATE SKIP LOCKED ?

Vers le chapitre suivant

L’outbox plus le relais nous donnent une garantie solide : le message finit toujours par partir, et le consommateur idempotent absorbe les doublons. Mais cette garantie a un côté sombre. Le relais republie tant qu’un message n’est pas marqué envoyé, et le consommateur retente tant qu’un traitement n’a pas réussi. Que se passe-t-il si un message ne peut jamais être traité, parce qu’il est malformé ou déclenche un bug à tous les coups ? Le relais et le consommateur vont le rejouer indéfiniment, et pire, ce message empoisonné peut bloquer toute la file derrière lui pendant qu’on s’acharne. La garantie « au moins une fois » devient alors « pour toujours, et au détriment des autres ». Le chapitre six, « Quand un message empoisonne », apprend à reconnaître ces messages, à borner les tentatives avec un recul progressif, et à les écarter proprement dans une file de rebut.

Sources

  • Richardson, C. (2018). Microservices Patterns, chapitre 3 « Interprocess communication » et motif « Transactional outbox ». Manning. microservices.io, Transactional Outbox
  • Kleppmann, M. (2017). Designing Data-Intensive Applications, chapitre 11 « Stream Processing » (idempotence du producteur, exactly-once). O’Reilly. Reference de l’editeur
  • PostgreSQL. Documentation : SELECT, clause FOR UPDATE SKIP LOCKED. postgresql.org
  • Wolverine. Durable Outbox and Marten Integration. Documentation officielle. Durabilite, Outbox Marten