Quand un service modifie sa base et veut prévenir le reste du système d’envoyer un événement à Kafka, RabbitMQ ou SQS, le code naïf ressemble à ça : on écrit en base, puis on publie. Si la publication échoue après la commit, l’événement est perdu. Si la publication réussit mais que la commit échoue, l’événement parle d’un état qui n’existe pas. Ces deux cas sont la définition du dual-write problem.

Le pattern Transactional Outbox résout cette incohérence avec une idée simple : ne jamais publier directement. L’événement est écrit dans une table outbox au sein de la même transaction SQL que la modification métier. Un processus séparé lit cette table et publie vers le broker. Tant que la transaction SQL est atomique, la base et le futur événement sont cohérents par construction.

Ce deuxième article de la série sur les patterns d’architecture distribuée fait suite au pattern Saga, dont il complète naturellement les étapes qui doivent émettre des événements de manière fiable.

Le dual-write problem : deux écritures sans atomicité

Reprenons l’exemple de la commande e-commerce. Quand le statut passe à confirmee, plusieurs systèmes doivent être prévenus : le service d’emails, le service analytics, le data warehouse. On publie un événement CommandeConfirmee sur Kafka.

def confirmer_commande(commande_id: int) -> None:
    with transaction.atomic():
        Commande.objects.filter(id=commande_id).update(statut="confirmee")
    kafka.publish("commandes.confirmees", {"id": commande_id})

Quatre cas existent :

  1. La transaction commit, la publication réussit. C’est le cas nominal.
  2. La transaction échoue, la publication n’a pas lieu. Cohérent.
  3. La transaction commit, la publication échoue (broker down, timeout). La commande est confirmée en base, mais personne ne le sait. L’email ne part jamais.
  4. La transaction commit, la publication réussit, mais le réseau coupe avant l’acquittement. Le worker retry et publie une seconde fois. Doublon.

On peut inverser l’ordre (publier d’abord, commit ensuite), mais ça transforme juste le cas 3 en cas inverse : un événement qui parle d’une commande jamais confirmée. Le problème ne disparaît pas, il change de forme.

Aucune combinaison de try/except ne règle ça. Il faut un mécanisme qui rende la publication elle-même transactionnelle.

Le principe : une seule transaction pour deux destinations

L’idée du pattern Outbox : la publication n’écrit pas vers Kafka, elle écrit vers une table SQL outbox. Cette écriture fait partie de la transaction métier. Si la commit passe, l’événement existe en base. Si elle échoue, l’événement n’a jamais existé.

Un processus séparé, appelé relay ou dispatcher, lit la table outbox, publie chaque ligne vers le broker, puis marque la ligne comme publiée (ou la supprime). Ce processus peut crasher, redémarrer, retrier autant qu’il veut. Tant qu’il publie au moins une fois ce qu’il trouve dans la table, l’événement arrive.

Le système devient at-least-once : un consommateur peut recevoir le même événement plusieurs fois. Cette garantie est acceptable si les consommateurs sont idempotents. Elle est en pratique la seule garantie atteignable dans un système distribué, et bien plus utile que le “exactly-once” qu’on n’a jamais vraiment.

La table outbox

Un schéma minimal :

from django.db import models
from django.db.models import Q


class OutboxEvent(models.Model):
    id = models.BigAutoField(primary_key=True)
    aggregate_type = models.CharField(max_length=64)
    aggregate_id = models.CharField(max_length=64)
    event_type = models.CharField(max_length=128)
    payload = models.JSONField()
    created_at = models.DateTimeField(auto_now_add=True)
    published_at = models.DateTimeField(null=True)

    class Meta:
        indexes = [
            models.Index(
                fields=["id"],
                name="outbox_unpublished_idx",
                condition=Q(published_at__isnull=True),
            ),
        ]

L’index partiel ne contient que les lignes non publiées. Sur une table qui grossit (millions de lignes archivées), il garde une taille proche de zéro et la requête de relay reste constante. Django supporte cette syntaxe via condition= depuis la version 2.2 et la traduit en CREATE INDEX ... WHERE côté PostgreSQL. L’id monotone garantit un ordre stable de publication par agrégat.

L’écriture devient :

def confirmer_commande(commande_id: int) -> None:
    with transaction.atomic():
        Commande.objects.filter(id=commande_id).update(statut="confirmee")
        OutboxEvent.objects.create(
            aggregate_type="commande",
            aggregate_id=str(commande_id),
            event_type="CommandeConfirmee",
            payload={"id": commande_id},
        )

Plus de kafka.publish dans le code métier. La transaction est purement SQL, atomique de bout en bout.

Le relay : publier ce qui a été écrit

Le relay tourne en boucle, lit les événements non publiés, les envoie au broker, et les marque.

from celery import shared_task
from django.db import transaction
from django.utils import timezone


BATCH_SIZE = 100


@shared_task
def relay_outbox() -> None:
    with transaction.atomic():
        events = list(
            OutboxEvent.objects
            .select_for_update(skip_locked=True)
            .filter(published_at__isnull=True)
            .order_by("id")[:BATCH_SIZE]
        )
        if not events:
            return

        for event in events:
            kafka.publish(
                topic=f"{event.aggregate_type}.{event.event_type}",
                key=event.aggregate_id,
                value=event.payload,
            )

        OutboxEvent.objects.filter(
            id__in=[e.id for e in events]
        ).update(published_at=timezone.now())

Trois choix de conception importants se cachent là.

Le select_for_update(skip_locked=True) permet de faire tourner plusieurs workers de relay en parallèle. Chaque worker prend un lot que les autres ignorent. C’est ce qui rend l’horizontal scaling possible sans risque de doublon dû à une concurrence interne.

La publication a lieu dans la transaction qui marque published_at. Si Kafka accuse réception mais que la transaction échoue ensuite, on republie au prochain tour. C’est de l’at-least-once par design. Le skip_locked évite que ce verrou un peu long bloque les autres workers : ils sautent simplement les lignes verrouillées et prennent un autre lot.

L’ordre par id garantit que les événements d’un même agrégat sont publiés dans l’ordre où ils ont été créés, à condition de partitionner le topic par aggregate_id (la key Kafka).

La tâche traite un seul batch puis rend la main. Côté planification, Celery Beat la rappelle toutes les quelques secondes. Cette approche garde des transactions courtes, évite les timeouts de tâche, et laisse le worker libre entre deux passes.

Idempotence côté consommateur

Le contrat est at-least-once. Le consommateur doit être idempotent. Deux approches.

Déduplication par event_id. On ajoute un UUID stable dans OutboxEvent et chaque consommateur garde une table processed_events(event_id) avec un index unique. Avant de traiter, il tente l’insert. Si l’insert échoue (contrainte unique violée), l’événement a déjà été traité.

Opération naturellement idempotente. Si la conséquence du message est “mettre le statut à confirmee”, la rejouer n’a aucun effet. C’est le cas le plus simple, et celui à viser quand le design le permet.

Sans idempotence côté consommateur, le pattern Outbox produit des doublons silencieux. C’est une discipline à appliquer dès le premier consommateur.

Outbox + Saga : la combinaison naturelle

Une étape de Saga qui doit publier un événement utilise l’Outbox sans rien faire de plus. L’écriture métier de l’étape et l’événement à émettre partagent la même transaction.atomic. La compensation, si elle survient, écrit elle aussi son propre événement dans l’Outbox (PaiementRembourse, CommandeAnnulee), de manière strictement symétrique.

Sans Outbox, une étape de Saga qui publie directement vers Kafka peut succeed sur la publication et fail sur la commit DB. L’écosystème croit qu’une commande a été créée alors qu’elle ne l’a pas été. Les deux patterns se renforcent : la Saga gère la cohérence du workflow, l’Outbox garantit que les événements de chaque étape sont émis si et seulement si l’étape a réellement eu lieu.

CDC : l’alternative sans relay applicatif

Une variante du pattern délègue le relay à un outil externe basé sur la Change Data Capture. Debezium lit le WAL PostgreSQL, voit les insertions dans la table outbox, et publie vers Kafka. Aucun code applicatif pour le relay.

Avantages : zéro charge applicative, latence plus basse (le WAL est lu en quasi temps réel), pas de polling. Inconvénients : un composant supplémentaire à opérer (Kafka Connect, Debezium), une configuration plus complexe, et un couplage à PostgreSQL ou MySQL via leurs mécanismes de réplication.

Pour un projet Django de taille modeste, un relay Celery est largement suffisant. Pour un système à fort débit ou avec des contraintes de latence serrées, le CDC devient pertinent.

Pièges fréquents

Ne pas purger l’Outbox. La table grossit indéfiniment si on ne supprime rien. Soit on supprime les lignes après publication, soit on les garde quelques jours puis on archive. Garder un historique a une valeur d’audit, mais une outbox qui contient des millions de lignes finit par ralentir la sélection des non-publiés malgré l’index.

Confondre payload et état actuel. Le payload de l’événement doit refléter l’état au moment de l’écriture, pas l’état courant lu au moment de la publication. Si le relay lit Commande à la volée, il publie l’état présent, qui a peut-être déjà changé. Sérialiser le payload dans la transaction d’origine évite ce piège.

Oublier le partitionnement par agrégat. Sans key=aggregate_id côté Kafka, deux événements du même agrégat peuvent se retrouver sur des partitions différentes, et arriver dans le désordre côté consommateur. La garantie d’ordre Kafka est par partition, pas globale.

Quand ne pas utiliser l’Outbox

Le pattern ajoute une table, un index, un worker, et une discipline d’idempotence côté consommateur. Pour une application monolithique qui ne publie rien à l’extérieur, c’est inutile.

L’Outbox devient pertinent dès que :

  • vous publiez vers un broker externe (Kafka, RabbitMQ, SQS, Redis Streams)
  • la cohérence entre votre base et l’événement publié est critique
  • vous voulez survivre aux pannes du broker sans perdre d’événement
  • vous combinez avec une Saga ou un autre workflow long

À l’inverse, pour une notification dont la perte est acceptable (un log analytics, un ping de monitoring), publier directement est plus simple et largement suffisant.

Conclusion

Le Transactional Outbox transforme un problème distribué (deux écritures à coordonner) en problème local (une seule transaction SQL). Tout le travail compliqué est délégué à un relay qui peut crasher, redémarrer et retrier sans risquer la cohérence.

Comme la Saga, c’est un pattern qui révèle son utilité au moment où on commence à observer ce qui se passe vraiment en production. Le jour où un broker tombe pendant trente secondes et qu’aucun événement n’est perdu, le coût d’une table en plus paraît dérisoire.