🎯 OBJECTIF
Comprendre le dual write problem (DB + broker non atomiques), pourquoi les solutions naïves échouent, comment l'outbox pattern le résout, les trois familles d'implémentation (poller maison, CDC Debezium, framework-managed), et les garanties exactes que ça apporte (et celles que ça n'apporte PAS, notamment l'exactly-once).
🧠 MODÈLE MENTAL
Quand un service doit modifier sa DB et publier un message vers un autre système (Kafka, RabbitMQ, autre microservice via HTTP), il opère sur deux systèmes externes sans transaction distribuée commune. Entre les deux opérations, le process peut mourir, le réseau peut tomber, le broker peut être indisponible. Résultat : un état incohérent où la DB et le broker disent des choses différentes.
L'outbox renverse le problème : au lieu d'essayer de synchroniser deux systèmes en même temps, on délègue la publication à un acteur séparé en utilisant la transaction DB comme journal d'intentions. Ce que la DB a commit sera publié — éventuellement, mais sûrement.
Équation : écrire l'intention de publier dans la même transaction que la donnée → la publication elle-même peut foirer autant qu'elle veut, on rejouera depuis le journal.
save() + send() ne l'est pas.save() + send() naïf en cas de crash.INSERT ... ON CONFLICT DO NOTHING est idempotent, INSERT simple ne l'est pas. C'est le prérequis non négociable côté consumer quand on est en at-least-once.SELECT WHERE …) pour trouver du travail à faire. Approche classique de l'outbox sans CDC.Order + ses OrderLine). En outbox pattern, on émet typiquement un event par aggregate modifié.Le scénario typique. Un service e-commerce reçoit une commande. Il doit (a) la persister en DB, (b) publier un event OrderPlaced sur Kafka pour que les services Inventory, Billing, Shipping puissent réagir.
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order); // (a) commit DB
kafkaTemplate.send("orders", new OrderPlaced(order.getId())); // (b) publish broker
}
}javaÇa paraît correct. Ça ne l'est pas.
sequenceDiagram
participant App as Service
participant DB as DB
participant K as Kafka
App->>DB: save(order)
DB-->>App: ✓ commit
Note over App: 💥 JVM crash ici
App--xK: send(OrderPlaced) jamais envoyé
Note over DB,K: État incohérent :<br/>order en DB, aucun event publiémermaidCas 1 — Crash JVM entre commit DB et send Kafka. Le save() a commit, l'event n'a jamais été publié. La commande existe, personne en aval ne le sait.
Cas 2 — Kafka indisponible au moment du send. kafkaTemplate.send() throw une KafkaException. Si on est dans un @Transactional, la transaction Spring rollback → la commande est effacée.
Cas 3 — Send réussit côté client mais Kafka crashe avant fsync sur disque. send() retourne sans erreur, mais le broker tombe avant que le message ne soit durablement écrit.
Cas 4 — Timeout réseau. send() part, le broker l'a peut-être reçu mais la réponse n'arrive pas à temps. L'app croit que c'est échoué, retry → doublon publié.
| Ordre | JVM crash entre les 2 | Kafka down | DB down après commit |
|---|---|---|---|
| save → send | Order en DB, aucun event ❌ | Rollback → order perdue | N/A (déjà commité) |
| send → save | Event publié, aucune order en DB ❌ | Send échoue, save jamais tenté | Event publié, order pas en DB ❌ |
Quel que soit l'ordre choisi, il existe un cas où la cohérence est cassée.
🚫 Naïveté 1 — Try/catch + retry en mémoire
Si la JVM crashe pendant les retries (kill -9, OOM, redéploiement Kubernetes), l'event est perdu et tu n'as aucune trace qu'il devait être publié.
🚫 Naïveté 2 — Inverser l'ordre (publish avant save)
Si le save() échoue après un send() réussi, tu as publié un event sur une commande qui n'existe pas en DB. Inventory décrémente le stock, Shipping prépare l'expédition, mais la commande → 404.
🚫 Naïveté 3 — Two-Phase Commit (XA)
Kafka ne supporte pas XA. Les transactions Kafka couvrent uniquement Kafka ↔ Kafka. Coordinator failure possible. Complexité opérationnelle (Atomikos, Narayana).
🔑 Conclusion clé
Aucune solution synchrone (qui essaie de garantir DB et broker en même temps) n'est viable. Il faut séparer les concerns : la transaction DB d'un côté, la publication vers le broker de l'autre, avec un mécanisme fiable pour passer de la première à la seconde. C'est exactement ce que fait l'outbox.
L'idée centrale. Au lieu de faire save() + send(), on fait save() + insert dans table outbox, dans la même transaction DB. Un acteur séparé (poller, CDC) lira la table outbox plus tard et publiera vers Kafka.
sequenceDiagram
participant App as Service
participant DB as DB<br/>(orders + outbox)
participant Pub as Publisher<br/>(poller / CDC)
participant K as Kafka
App->>DB: BEGIN TX
App->>DB: INSERT INTO orders
App->>DB: INSERT INTO outbox (event)
App->>DB: COMMIT (atomique)
Note over App: ✓ Réponse client OK
rect rgb(200, 230, 200)
Pub->>DB: SELECT FROM outbox WHERE not published
DB-->>Pub: events à publier
Pub->>K: send(event)
K-->>Pub: ack
Pub->>DB: UPDATE outbox SET published_at = NOW()
endmermaidCREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(255) NOT NULL,
aggregate_id VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unpublished
ON outbox (created_at)
WHERE published_at IS NULL; -- index partielsql🚨 À retenir absolument
L'outbox garantit at-least-once delivery, jamais exactly-once. Le consumer DOIT être idempotent. Ce n'est pas optionnel.
Entre "Kafka a accepté le message" et "j'ai marqué la ligne outbox comme publiée", le publisher peut crasher. Au redémarrage, il retrouve l'event comme "non publié" et le republie. Doublon garanti.
@KafkaListener(topics = "orders")
public void onOrderPlaced(OrderPlacedEvent event) {
if (processedEventsRepository.existsById(event.getEventId())) {
return; // déjà traité, on ignore
}
// … traitement métier …
processedEventsRepository.save(new ProcessedEvent(event.getEventId()));
}java@Component
@RequiredArgsConstructor
public class OutboxPoller {
private final JdbcTemplate jdbc;
private final KafkaTemplate<String, String> kafka;
@Scheduled(fixedDelay = 500)
@Transactional
public void publishPending() {
List<OutboxEvent> events = jdbc.query("""
SELECT id, aggregate_type, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED
""", outboxRowMapper);
for (OutboxEvent e : events) {
String topic = e.aggregateType().toLowerCase() + ".events";
kafka.send(topic, e.aggregateId(), e.payload()).get();
jdbc.update(
"UPDATE outbox SET published_at = NOW() WHERE id = ?",
e.id()
);
}
}
}javaFOR UPDATE SKIP LOCKED — en multi-pods, chaque instance traite un sous-ensemble disjoint sans coordination explicite.
| Poller maison | Debezium CDC | Framework-managed | |
|---|---|---|---|
| Effort initial | Moyen | Élevé | Très faible |
| Latence typique | 200–1000 ms | < 100 ms | 200–1000 ms |
| Débit max | ~1k events/s | >100k events/s | ~1k events/s |
| Idéal pour | POC, faible débit, custom | Production gros débit | POC, app interne |
⚡ TL;DR — chaque concept en une ligne
Dual write problem
✓ repo.save() puis kafka.send() n'est jamais atomique : entre les deux, JVM/broker/réseau peut tomber et casser la cohérence DB ↔ broker.
⚠ Aucune solution "synchrone" ne marche durablement — try/catch retry perd les events si la JVM crashe avant le retry, l'inversion de l'ordre publie des trucs jamais commités, le 2PC/XA est lourd et la plupart des brokers modernes (Kafka) ne le supportent pas.
Pattern Outbox ✓ Écrire le message à publier dans une table SQL de la même DB que la donnée métier, dans la même transaction → l'atomicité est garantie par la DB. Un acteur séparé lit cette table et publie vers le broker à son rythme. ⚠ Garantit at-least-once, pas exactly-once. Le consumer DOIT être idempotent. Sans ça, l'outbox te garantit juste d'avoir des doublons proprement.
Poller maison
✓ Job applicatif qui fait SELECT … WHERE published_at IS NULL FOR UPDATE SKIP LOCKED, publie vers Kafka, marque la ligne. Aucune dépendance externe, contrôle total du format des events.
⚠ Code à maintenir, latence dépend de l'intervalle de polling, charge sur la DB qui grossit avec le débit.
Debezium CDC + Outbox Event Router SMT
✓ Lit le WAL Postgres en streaming, applique la transformation io.debezium.transforms.outbox.EventRouter, publie vers Kafka. Latence quasi-instantanée, zéro code applicatif au-delà de l'INSERT dans la table outbox.
⚠ Requiert Kafka Connect en prod (cluster séparé à opérer), schéma de table outbox imposé par la SMT.
Framework-managed (Spring Modulith Events) ✓ Le framework fournit table outbox + poller + retry, intégré au cycle de vie Spring/JPA. Aucune infra additionnelle. ⚠ Couplage au framework, format des events imposé, scaling limité au polling JDBC.
🎓 À retenir
@Transactional ne couvre que la DB.@Transactional ne couvre que la DB, pas le broker