Think outbox pattern
Le client a payé.
La commande est en base.
Le colis, lui, n'est jamais parti.
Aucune erreur dans les logs. Aucune alerte. Juste un client qui appelle le support, dix jours plus tard.
Pourtant, le code a l'air parfaitement normal :
final class PlaceOrder
{
public function __construct(
private OrderRepository $orders,
private EventBus $events,
) {}
public function __invoke(PlaceOrderCommand $command): void
{
$order = Order::place($command->customerId, $command->lines);
$this->orders->save($order);
$this->events->publish(new OrderPlaced($order->id()));
}
}
Ici, comme on peut le voir, on sauvegarde en base, puis on envoie un événement métier qui doit absolument partir. Par exemple un message dans un Kafka.
- Le problème
- L'outbox pattern
- Faire partir l'événement
- Idempotence
- Inbox pattern
- Quand mettre de l'outbox dans notre app ?
- Conclusion
Le problème#
Entre le moment où on sauvegarde et le moment où l'événement doit partir, tout peut arriver : problème réseau, latence, broker indisponible, process tué…
La commande existe en base, mais l'événement ne part jamais. Personne n'est prévenu.
Et c'est ça le pire : un bug silencieux.
Un crash, tout le monde le voit. Celui-là, pour le choper, faut s'accrocher.
Souvent, c'est le client qui le trouve. Et avant même d'appeler le support, il a peut-être déjà laissé un avis négatif, ou demandé un remboursement.
Ça, ça ne se rollback pas.
On ne peut pas non plus tout mettre dans une seule transaction SQL : la base de données et le broker sont deux systèmes différents. On ne peut pas écrire dans les deux de façon atomique. C'est ce qu'on appelle un dual write.
L'outbox pattern#
L'une des manières de gérer ça, c'est l'outbox pattern.
Tout simplement, on crée une table outbox, et c'est dans cette table qu'on écrit nos événements.
CREATE TABLE outbox (
id UUID PRIMARY KEY,
topic VARCHAR(255) NOT NULL,
payload JSON NOT NULL,
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP NULL
);
Attention à purger cette table de temps en temps : sinon elle ne s'arrêtera pas de grossir.
Mais attention à ne pas se faire avoir : on ne fait pas « save de la commande » puis « save de l'événement » chacun de son côté. On les fait dans la même transaction.
public function __invoke(PlaceOrderCommand $command): void
{
$order = Order::place($command->customerId, $command->lines);
$this->transaction->wrap(function () use ($order) {
$this->orders->save($order);
$this->outbox->add(new OrderPlaced($order->id()));
});
}
Si le moindre problème arrive, on rollback. La commande et l'événement sont toujours ensemble : les deux sont enregistrés, ou aucun des deux.
C'est bien beau tout ça. Mais l'événement, il part quand, et comment ?
Faire partir l'événement#
Une fois qu'on est sûr que la commande et l'événement sont bien sauvegardés, il faut brancher quelque chose qui lit la table outbox et publie les messages dans Kafka. Deux options (à ma connaissance) :
- un CDC (Change Data Capture), comme Debezium, qui lit le journal de transactions de la base et publie chaque nouvelle ligne ;
- un polling : un cron maison, par exemple, qui lit régulièrement les lignes non publiées.
Avec le polling, une fois la ligne de l'outbox lue et publiée, on la marque comme envoyée. On ne la relira plus. Le CDC, lui, retient sa position dans le journal de transactions et repart de là.
// Exemple de cron maison
public function __invoke(): void
{
foreach ($this->outbox->pending() as $message) {
$this->kafka->publish($message->topic(), $message->payload());
$this->outbox->markAsPublished($message);
}
}
Attention : on retrouve ici le même problème. On publie dans Kafka, puis on met à jour la base. Ce polling ne semble pas très safe au premier abord.
La différence avec notre premier PlaceOrder, c'est que tant que le message n'est pas considéré comme publié, on continue de le renvoyer dans Kafka. Le message ne sera donc jamais perdu. Dans le premier PlaceOrder, on pouvait le perdre à tout moment.
Mais ça peut amener de nouveaux problèmes.
Idempotence#
On a maintenant un système qui lit la table, publie, puis met à jour l'état de l'événement. Entre la publication et la mise à jour, il peut se passer plusieurs choses : le process plante, le réseau coupe, le cron est relancé… Et finalement, le message peut être envoyé plusieurs fois : la ligne est toujours considérée comme non publiée, donc elle repart.
C'est ce qu'on appelle du at least once : le message arrive au moins une fois, mais peut-être plusieurs. On n'a pas de garantie « exactement une fois ».
À ce moment-là, c'est au destinataire du message d'être idempotent.
Derrière ce nom barbare, c'est tout simple : peu importe le nombre de fois qu'on traite le même message, le résultat doit être le même que si on l'avait traité une seule fois.
Par exemple, côté consumer :
public function __invoke(OrderPlaced $event): void
{
if ($this->shipments->existsForOrder($event->orderId)) {
$this->logger->info('OrderPlaced already processed', ['orderId' => $event->orderId]);
return;
}
// Index unique sur shipment.order, pour éviter la concurrence
$this->shipments->save(Shipment::prepareFor($event->orderId));
}
Si l'identifiant de la commande existe déjà en base, on logue « déjà traité » et on s'arrête. Et on acquitte quand même le message (ack), sinon le broker le relivrera.
Pour gérer la concurrence, on peut rajouter un unique en base. Si deux messages arrivent en même temps, le exists ne suffit pas : c'est la base qui dira non au deuxième.
Imaginons maintenant que, lorsqu'on reçoit l'événement OrderPlaced, on ait des listeners / handlers qui vont décrémenter le stock… Comment faire ici pour ne pas décrémenter cent fois, si on reçoit cent fois le même message ?
On parle cette fois-ci d'inbox.
Inbox pattern#
Il arrive que le code ne suffise pas à gérer l'idempotence, comme ici avec un décrément.
Contrairement au Shipment, où l'id de la commande reçu est notre clé métier (ce qui identifie la commande dans notre domaine), ici il n'y a rien à vérifier : le produit ne sait pas quelle commande l'a déjà décrémenté.
Donc on a besoin d'une table inbox, de la même façon que l'outbox. Elle ne sert qu'à ça : noter les messages déjà traités.
D'où vient l'identifiant ? De l'outbox. L'id de la ligne d'outbox, généré par le système qui a envoyé l'event, part avec le message, et c'est lui que le consumer utilise.
CREATE TABLE inbox (
message_id UUID NOT NULL,
handler VARCHAR(255) NOT NULL,
processed_at TIMESTAMP NOT NULL,
PRIMARY KEY (message_id, handler)
);
C'est la combinaison entre le message_id (qui vient de l'outbox) et le handler (le nom de la classe du handler) qui compose notre clé primaire. Le même OrderPlaced peut donc apparaître deux fois : une fois pour le stock, une fois pour les points de fidélité.
| message_id | handler |
|---|---|
| a3f1… | StockHandler |
| a3f1… | LoyaltyPointsHandler |
public function __invoke(OrderPlaced $event): void
{
$this->transaction->wrap(function () use ($event) {
if (!$this->inbox->markAsProcessed($event->messageId, self::class)) {
return; // déjà traité
}
foreach ($event->lines as $line) {
$this->products->get($line->productId)->decreaseStock($line->quantity);
}
});
}
markAsProcessed() fait un simple insert. Elle renvoie false si la clé primaire existe déjà.
Pas de if exists ici : c'est la base qui va gérer le cas d'unicité et/ou de concurrence.
Même transaction, même logique que l'outbox : soit le message est marqué ET le stock décrémenté, soit aucun des deux. Si ça plante, rollback, et le message rejoué sera traité proprement.
Comme l'outbox, cette table grossit. On peut supprimer les lignes plus vieilles que le délai maximal de relivraison (quelques jours, par exemple).
Attention, l'inbox ne protège que ce qui est dans notre base. Un appel externe (un email, un paiement Stripe) peut partir même si la transaction est rollback. C'est alors au tiers de dédupliquer : c'est le rôle de la clé d'idempotence de Stripe, par exemple.
Il existe aussi une variante plus poussée de l'inbox1.
Quand mettre de l'outbox dans notre app ?#
Quand l'événement doit absolument partir et notifier une autre application / un autre système, comme dans l'exemple de cet article : un achat effectué. La plateforme de transport doit absolument être notifiée, sinon le client n'aura jamais son colis.
Et à quel moment peut-on s'en passer ? Pour un email de bienvenue, par exemple. La conséquence de sa non-réception est très réduite (en fonction du métier, évidemment), comparée à un colis qui ne partira jamais malgré un achat.
Plus la perte de l'événement coûte cher, plus l'outbox se justifie.
Et l'inbox ? Elle est utile quand le traitement n'est pas idempotent par nature et qu'il n'y a pas de clé métier à protéger.
Conclusion#
L'outbox pattern est un système simple mais efficace pour être sûr qu'aucune donnée métier n'est sauvegardée sans que son événement finisse par partir.
L'outbox garantit que le message part. L'inbox garantit qu'il n'est traité qu'une fois.
Certains vont plus loin : on stocke le message entier (le payload) dans l'inbox, on acquitte le broker tout de suite, et un worker boucle sur la table pour traiter les messages, comme le relais de l'outbox. Utile pour acquitter vite, rejouer, ou gérer soi-même les retries et l'ordre. ↩