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énario | Conséquence sans Outbox |
|---|---|
| Créer commande + notifier le stock | Le stock n’est pas réservé, on vend un produit épuisé |
| Traiter paiement + notifier les expéditions | Paiement encaissé mais expédition jamais planifiée |
| Mettre à jour profil + notifier les services | Données désynchronisées entre services |
| Terminer étape N de la saga + publier événement | La 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
| Aspect | Polling | CDC |
|---|---|---|
| Latence | 100ms - 5s (configurable) | < 100ms (quasi temps réel) |
| Complexité | Faible (un cron ou processus simple) | Élevée (Debezium + Kafka Connect) |
| Charge sur la BD | Requêtes périodiques | Lit depuis le WAL, sans requêtes supplémentaires |
| Infrastructure | Minimale | Debezium, Kafka Connect, Zookeeper |
| Scalabilité | Limitée par la BD | Élevée (le CDC est très efficace) |
| Recommandé pour | Volume faible-moyen | Volume é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égorie | Options |
|---|---|
| CDC | Debezium, Maxwell (MySQL), AWS DMS |
| Brokers | Apache Kafka, RabbitMQ, Amazon SNS/SQS, Azure Service Bus |
| Bases de données | PostgreSQL (WAL), MySQL (binlog), SQL Server (CT) |
| Frameworks | Eventuate Tram, MassTransit Outbox (.NET), Wolverine (.NET) |
| Connecteurs | Kafka Connect, Debezium Server |
| Nettoyage | Cron 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
| Erreur | Conséquence | Solution |
|---|---|---|
| Ne pas nettoyer la table outbox | La table croît sans limite, requêtes lentes | Cron de nettoyage ou partitionnement |
| Publisher sans idempotence | Événements dupliqués dans le broker | Consommateurs idempotents + dédup |
| Payload trop volumineux | La table outbox occupe beaucoup d’espace | Ne stocker que les IDs, le consommateur consulte les détails |
| Un seul publisher | Goulot d’étranglement en volume élevé | Plusieurs publishers avec SKIP LOCKED |
| Ne pas surveiller le lag | Les événements s’accumulent sans être détectés | Alertes 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.