Outbox Pattern

Comment garantir la publication fiable d'événements dans les systèmes distribués à l'aide du transactional outbox.

Quel problème résout-il

Dans les systèmes basés sur les événements, un microservice doit accomplir deux tâches lorsqu’il traite une opération : enregistrer des données dans sa base de données et publier un événement pour notifier les autres services. Le problème est que ce sont deux opérations sur deux systèmes différents (base de données et broker de messages), et qu’aucune transaction atomique ne couvre les deux à la fois.

Le problème du dual write

Service de Commandes :
  1. Enregistrer la commande dans PostgreSQL  ✅
  2. Publier "CommandeCréée" dans Kafka  ❌ (échec réseau)

Résultat : La commande existe dans la base de données,
mais aucun autre service n'en a été informé.

Ce scénario s’appelle dual write (double écriture) et peut provoquer :

  • Données enregistrées, événement perdu : La commande existe mais le stock n’a jamais été réservé
  • Événement publié, données non enregistrées : D’autres services réagissent à une commande qui n’existe pas
  • Incohérence silencieuse : Le système semble fonctionner mais les données entre services divergent

Tenter de résoudre cela avec « publier d’abord, enregistrer ensuite » ne fait qu’inverser le problème. Et utiliser des transactions distribuées (2PC) introduit de la latence et des points de défaillance.

Scénarios réels où cela se produit

ScénarioConséquence sans Outbox
Créer commande + notifier le stockLe stock n’est pas réservé, on vend un produit épuisé
Traiter paiement + notifier les expéditionsPaiement encaissé mais expédition jamais planifiée
Mettre à jour profil + notifier les servicesDonnées désynchronisées entre services
Terminer étape N de la saga + publier événementLa saga reste bloquée à une étape intermédiaire

Comment ça fonctionne

Le Outbox Pattern résout le dual write en utilisant une seule transaction de base de données pour les deux opérations. Au lieu de publier l’événement directement vers le broker, le service écrit l’événement dans une table outbox au sein de la même transaction qui enregistre les données métier.

Flux de base

┌─────────────────────────────────────────────┐
│  Transaction de base de données (atomique)  │
│                                             │
│  1. INSERT INTO pedidos (...)               │
│  2. INSERT INTO outbox (evento, payload...) │
│                                             │
│  COMMIT                                     │
└─────────────────────────────────────────────┘

         │  (processus séparé)

┌─────────────────────┐     ┌──────────────┐
│  Relay / Publisher   │────►│  Kafka /     │
│  (lit la table outbox)│    │  RabbitMQ    │
└─────────────────────┘     └──────────────┘

L’essentiel est que les deux écritures (données métier + événement) se produisent dans la même transaction de base de données. Si la transaction échoue, aucune des deux n’est persistée. Si elle réussit, les deux le sont. Il n’y a aucune fenêtre d’incohérence.

Structure de la table outbox

CREATE TABLE outbox (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  aggregate_type  VARCHAR(255) NOT NULL,  -- "Pedido", "Pago"
  aggregate_id    VARCHAR(255) NOT NULL,  -- ID del agregado
  event_type      VARCHAR(255) NOT NULL,  -- "PedidoCreado"
  payload         JSONB NOT NULL,         -- datos del evento
  created_at      TIMESTAMP NOT NULL DEFAULT NOW(),
  published_at    TIMESTAMP NULL          -- NULL = pendiente
);

-- Índice para el publisher
CREATE INDEX idx_outbox_pending
  ON outbox (created_at)
  WHERE published_at IS NULL;

Exemple d’utilisation en code

// Au sein d'une transaction de base de données
BEGIN;

  -- 1. Operación de negocio
  INSERT INTO pedidos (id, cliente_id, total, estado)
  VALUES ('ped-001', 'cli-789', 150.00, 'confirmado');

  INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio)
  VALUES ('ped-001', 'prod-42', 2, 75.00);

  -- 2. Evento en la tabla outbox (misma transacción)
  INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
  VALUES (
    'Pedido',
    'ped-001',
    'PedidoConfirmado',
    '{"pedidoId": "ped-001", "clienteId": "cli-789", "total": 150.00}'
  );

COMMIT;
-- Les deux écritures sont atomiques : soit les deux sont enregistrées, soit aucune

Stratégies de publication

Il existe deux façons principales de lire la table outbox et de publier les événements vers le broker :

Polling Publisher (Publicateur par sondage)

Un processus périodique interroge la table outbox à la recherche d’événements non publiés, les envoie au broker et les marque comme publiés.

┌──────────────┐                    ┌──────────┐
│   Outbox     │  SELECT * FROM     │ Polling  │
│   Table      │◄─── outbox WHERE ──│ Publisher│
│              │  published_at NULL  │          │
└──────────────┘                    └────┬─────┘

                                   Publier événement


                                   ┌──────────┐
                                   │  Broker  │
                                   └──────────┘

Avantages : Simple à implémenter, ne nécessite aucune infrastructure supplémentaire. Inconvénients : Latence (dépend de l’intervalle de polling), charge sur la base de données.

Implémentation typique du polling :

// Toutes les 500ms (configurable)
loop {
  eventos = SELECT * FROM outbox
            WHERE published_at IS NULL
            ORDER BY created_at
            LIMIT 100
            FOR UPDATE SKIP LOCKED;  -- evita conflictos entre publishers

  for evento in eventos {
    broker.publish(evento.event_type, evento.payload);

    UPDATE outbox
    SET published_at = NOW()
    WHERE id = evento.id;
  }
}

Change Data Capture (CDC)

Un connecteur CDC (comme Debezium) surveille le log de transactions de la base de données et capture automatiquement les insertions dans la table outbox.

┌──────────────┐                    ┌──────────┐
│  PostgreSQL  │  WAL / binlog      │ Debezium │
│  Transaction │──────────────────► │  (CDC)   │
│  Log         │                    └────┬─────┘
└──────────────┘                         │
                                    Publier événement


                                    ┌──────────┐
                                    │  Kafka   │
                                    └──────────┘

Avantages : Latence très faible (quasi temps réel), ne génère pas de charge supplémentaire par des requêtes. Inconvénients : Nécessite une infrastructure supplémentaire (Debezium, Kafka Connect), plus complexe à exploiter.

Comparaison des stratégies

AspectPollingCDC
Latence100ms - 5s (configurable)< 100ms (quasi temps réel)
ComplexitéFaible (un cron ou processus simple)Élevée (Debezium + Kafka Connect)
Charge sur la BDRequêtes périodiquesLit depuis le WAL, sans requêtes supplémentaires
InfrastructureMinimaleDebezium, Kafka Connect, Zookeeper
ScalabilitéLimitée par la BDÉlevée (le CDC est très efficace)
Recommandé pourVolume faible-moyenVolume élevé, faible latence

Garanties de livraison

Le Outbox Pattern garantit le at-least-once delivery (livraison au moins une fois). Cela signifie qu’un événement peut être publié plus d’une fois. C’est pourquoi les consommateurs doivent être idempotents.

Scénario de duplication :
  1. Le publisher lit un événement depuis outbox
  2. Le publisher publie l'événement dans Kafka  ✅
  3. Le publisher échoue avant de le marquer comme publié  ❌
  4. Le publisher redémarre et republie le même événement

Solution : Le consommateur détecte le doublon grâce à l'ID de l'événement
et l'ignore (Idempotency Pattern).

Nettoyage de la table outbox

La table outbox croît en continu. Vous avez besoin d’une stratégie de nettoyage :

-- Opción 1: Borrar eventos publicados hace más de 7 días
DELETE FROM outbox
WHERE published_at IS NOT NULL
  AND published_at < NOW() - INTERVAL '7 days';

-- Opción 2: Particionado por fecha (más eficiente)
-- Crear particiones mensuales y dropear particiones antiguas
CREATE TABLE outbox (
  ...
) PARTITION BY RANGE (created_at);

-- Opción 3: Mover a tabla de archivo antes de borrar
INSERT INTO outbox_archive SELECT * FROM outbox
WHERE published_at < NOW() - INTERVAL '30 days';

Avantages

  • Cohérence garantie : Données et événements sont écrits dans la même transaction, éliminant le dual write
  • Sans transactions distribuées : Vous n’avez besoin ni de 2PC ni de transactions XA
  • Audit naturel : La table outbox fait office de journal de tous les événements publiés
  • Résilience : Si le broker est hors service, les événements s’accumulent et sont publiés à son retour
  • Découplage temporel : Le service ne dépend pas de la disponibilité du broker
  • Compatible avec n’importe quelle base de données relationnelle : Vous n’avez besoin que d’une table supplémentaire

Trade-offs / Inconvénients

  • Latence supplémentaire : Les événements ne sont pas publiés instantanément (surtout avec le polling)
  • Complexité opérationnelle : Vous devez maintenir le publisher ou le connecteur CDC
  • La table outbox croît : Vous avez besoin d’une stratégie de nettoyage périodique
  • At-least-once : Les consommateurs doivent gérer les doublons (idempotence obligatoire)
  • Couplage avec la base de données : Le CDC dépend de fonctionnalités spécifiques au moteur (WAL, binlog)
  • Surcoût en écriture : Chaque opération écrit une ligne supplémentaire dans la table outbox

Quand l’utiliser

  • Systèmes event-driven où la fiabilité des événements est critique
  • Microservices qui doivent notifier des changements à d’autres services de manière fiable
  • Lorsque vous implémentez le Saga Pattern et devez garantir que les événements de chaque étape soient publiés
  • Systèmes financiers ou d’e-commerce où la perte d’un événement a un impact sur le métier
  • Lorsque vous utilisez déjà une base de données relationnelle et souhaitez éviter les transactions distribuées
  • Flux où la cohérence à terme est acceptable mais où la perte d’événements ne l’est pas

Quand l’éviter

  • Systèmes qui exigent une publication d’événements en temps réel strict (microsecondes)
  • Lorsque vous utilisez une base de données qui ne supporte pas les transactions ACID (certains NoSQL)
  • Applications simples où publier directement vers le broker suffit
  • Systèmes à très faible volume d’événements où la complexité ne se justifie pas
  • Lorsque vous avez déjà implémenté l’Event Sourcing (l’event store est déjà votre « outbox »)

Technologies et implémentations courantes

CatégorieOptions
CDCDebezium, Maxwell (MySQL), AWS DMS
BrokersApache Kafka, RabbitMQ, Amazon SNS/SQS, Azure Service Bus
Bases de donnéesPostgreSQL (WAL), MySQL (binlog), SQL Server (CT)
FrameworksEventuate Tram, MassTransit Outbox (.NET), Wolverine (.NET)
ConnecteursKafka Connect, Debezium Server
NettoyageCron jobs, pg_partman (partitionnement par date)

Relation avec d’autres patterns

Le Outbox Pattern est une pièce fondamentale de l’infrastructure des systèmes event-driven :

┌─────────────────────────────────────────────────┐
│         Outbox dans l'écosystème                 │
│                                                  │
│  ┌──────────┐  necesita   ┌──────────────────┐  │
│  │  Saga    │ ──────────► │  Outbox Pattern  │  │
│  │ Pattern  │  publicar   │  (este patrón)   │  │
│  └──────────┘  eventos    └────────┬─────────┘  │
│                                    │             │
│                              entrega al          │
│                              menos una vez       │
│                                    │             │
│                                    ▼             │
│                            ┌──────────────┐      │
│                            │ Idempotency  │      │
│                            │   Pattern    │      │
│                            └──────────────┘      │
│                                                  │
│  ┌──────────────┐  usa     ┌──────────────┐     │
│  │ Event-Driven │ ────────►│    Outbox     │     │
│  │ Architecture │  para    │   (confiable) │     │
│  └──────────────┘  eventos └──────────────┘     │
└─────────────────────────────────────────────────┘
  • Saga Pattern : Chaque étape d’une saga doit publier des événements de manière fiable. Le Outbox garantit qu’aucun événement n’est perdu entre les étapes
  • Idempotency Pattern : Comme le Outbox garantit le at-least-once, les consommateurs doivent être idempotents pour gérer les doublons
  • Event-Driven Architecture : Le Outbox est l’infrastructure qui rend fiable la publication d’événements dans une architecture event-driven
  • Circuit Breaker : Si le publisher de l’outbox ne parvient pas à se connecter au broker, le circuit breaker évite des réessais infinis
  • DDD : Les Domain Events générés par chaque bounded context sont publiés de manière fiable via l’outbox

Erreurs courantes

ErreurConséquenceSolution
Ne pas nettoyer la table outboxLa table croît sans limite, requêtes lentesCron de nettoyage ou partitionnement
Publisher sans idempotenceÉvénements dupliqués dans le brokerConsommateurs idempotents + dédup
Payload trop volumineuxLa table outbox occupe beaucoup d’espaceNe stocker que les IDs, le consommateur consulte les détails
Un seul publisherGoulot d’étranglement en volume élevéPlusieurs publishers avec SKIP LOCKED
Ne pas surveiller le lagLes événements s’accumulent sans être détectésAlertes selon la taille de la file d’attente en attente

Prochaines étapes

La publication fiable d’événements étant résolue, le défi suivant consiste à protéger vos services lorsque les dépendances échouent. Le Circuit Breaker est le pattern qui empêche qu’un service en panne n’entraîne tout le système avec lui. Explorez également le Idempotency Pattern pour vous assurer que les consommateurs de vos événements gèrent correctement les doublons.