Un service écrit une ligne en base, puis publie un message sur RabbitMQ pour prévenir le reste du système. Deux opérations, deux systèmes, une seule illusion : qu’elles réussissent ou échouent ensemble. Ce sujet nous a occupés chez Stonal avant mars 2026. La table qui en est sortie tourne depuis sans incident. C’est encourageant, ce n’est pas une preuve : le volume reste modeste, environ 50 000 événements par jour, moins d’un par seconde en moyenne.
Le dual write ne se répare pas en changeant l’ordre
Écrire en base puis publier sur le broker n’est pas atomique. Le commit réussit et la publication échoue. Ou l’inverse, transaction annulée derrière. Ou le processus crashe entre les deux. Ou le broker accepte sans jamais confirmer.
Publier avant de committer ne résout rien, ça déplace le risque : notifier un événement qui n’existe pas encore en base. Aucun ordre d’exécution ne rend deux systèmes atomiques l’un envers l’autre. Il faut une transaction distribuée, rarement disponible avec un broker, ou renoncer à écrire dans les deux à la fois.
La solution : une seule écriture, un relay à part
L’outbox pattern écrit le message dans une table dédiée, dans la même transaction que le changement métier (voir Bien gérer ses transactions en base de données). Une transaction Postgres est atomique par construction : les deux lignes existent, ou aucune. Un processus séparé, le relay, lit ensuite la table, publie vers RabbitMQ, puis marque la ligne comme publiée.
Le prix : de la latence, et de l’at-least-once plutôt que de l’exactly-once. Le relay peut publier un message puis crasher avant d’enregistrer que c’est fait ; au redémarrage, il le republie. Le consommateur devra reconnaître ce doublon, sujet du deuxième article de la série.
CREATE TABLE outbox (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_pending_idx ON outbox (id) WHERE published_at IS NULL;
L’index partiel ne porte que sur les lignes encore à publier : le relay ne scanne jamais l’historique déjà envoyé. Les extraits Go qui suivent viennent d’un module qui compile et qui passe ses tests, où deux services, orders et shipping, s’échangent leurs événements via RabbitMQ.
Deux colonnes méritent qu’on s’y arrête.
aggregate_type et aggregate_id nomment l’entité que la transaction vient de modifier, order et 4711, là où event_type dit ce qui lui est arrivé.
Deux questions distinctes, deux colonnes.
Le mot vient du Domain-Driven Design, où l’agrégat est l’unité de cohérence transactionnelle : par construction, votre ligne outbox décrit le changement d’un agrégat et d’un seul.
// CreateOrder writes the order row and its outbox event in the same transaction: either both
// commit, or neither does — the outbox never lags behind the business state it describes.
func (s *PgStore) CreateOrder(ctx context.Context, order Order, payload []byte) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin order transaction: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx,
`INSERT INTO orders (id, customer_id, status) VALUES ($1, $2, $3)`,
order.ID, order.CustomerID, order.Status,
); err != nil {
return fmt.Errorf("insert order: %w", err)
}
if _, err := tx.Exec(ctx,
`INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('order', $1, 'created', $2)`,
order.ID, payload,
); err != nil {
return fmt.Errorf("insert outbox event: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit order transaction: %w", err)
}
return nil
}
Le piège que le curseur d’id ne voit pas
Le réflexe naturel pour le relay est un curseur : SELECT ... WHERE id > $dernier_id ORDER BY id. C’est le piège. La séquence qui alimente id attribue sa valeur au moment de l’INSERT, pas au commit. Passer de BIGSERIAL à GENERATED ALWAYS AS IDENTITY n’y change rien : une colonne d’identité reste adossée à une séquence. Deux transactions concurrentes peuvent obtenir les ids 41 et 42, puis committer dans l’ordre inverse. Un relay déjà passé au-delà de 41 ne reverra jamais cette ligne : perte de message silencieuse, sans erreur ni log.
Oskar Dudycz a documenté ce comportement et propose un filtrage par snapshot de transactions visibles. Plus simple : ne jamais filtrer sur l’id, filtrer sur published_at IS NULL. Une ligne qui commit en retard reste éligible tant qu’elle n’a pas été marquée publiée, quel que soit son id.
SELECT id, payload FROM outbox
WHERE published_at IS NULL
ORDER BY id LIMIT $1
FOR UPDATE SKIP LOCKED;
Gérer des jobs asynchrones avec Postgres détaille déjà FOR UPDATE SKIP LOCKED, qui laisse plusieurs workers picorer la table au prix de l’ordre entre eux. Un seul relay, comme chez Stonal, supprime ce désordre-là, mais pas celui du paragraphe précédent : l’attribution des ids reste indépendante de l’ordre des commits, quel que soit le nombre de relays. S’il en faut plusieurs un jour, Un worker pool en Go montre comment les construire.
Le relay qu’on réveille, plutôt qu’on interroge en boucle
Le passage dont je suis le plus content, et le seul extrait de cette série directement inspiré du code de production : le relay n’interroge pas la table à intervalle fixe, il attend sur une channel Go réveillée à chaque insertion d’une ligne outbox.
// signal is the wake channel shared between the worker (receive-only) and whatever
// enqueues intents (send-only) — the channel itself decouples the two, no shared interface.
signal := make(chan struct{}, 1)
Le buffer à 1 fait tout le travail : si dix transactions insèrent chacune une ligne pendant que le worker est occupé, elles ne poussent qu’un seul réveil en attente. Le relay se réveille une fois, relit la table, traite tout ce qui s’y trouve. L’envoi côté producteur reste non bloquant, sinon un worker occupé bloquerait le service au moment d’insérer :
// Notify wakes the relay without blocking: the buffer already holds a pending wake-up if the
// relay hasn't drained it yet, so a redundant signal is simply dropped, never queued.
func (r *Relay) Notify() {
select {
case r.signal <- struct{}{}:
default:
}
}
Reste que la channel ne vit que dans le processus courant : un redémarrage la vide, et une ligne insérée par un autre biais (migration, script, rejeu manuel) ne déclenche aucun signal. Sans ticker de secours relançant le relay indépendamment de tout signal, une ligne oubliée peut rester published_at IS NULL indéfiniment. La channel réduit la latence perçue ; le ticker est ce qui rend le mécanisme fiable.
// Run drains on every wake-up and on every tick, until ctx is cancelled. The ticker is the
// safety net: it fires on schedule whether or not any signal has ever reached this replica.
func (r *Relay) Run(ctx context.Context) error {
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-r.signal:
if _, err := r.drain(ctx); err != nil {
return fmt.Errorf("drain outbox after signal: %w", err)
}
case <-ticker.C:
if _, err := r.drain(ctx); err != nil {
return fmt.Errorf("drain outbox after tick: %w", err)
}
}
}
}
Et ce design ne tient qu’à un fil : un seul réplica. L’écrivain et le relay partagent le même processus, la channel les relie directement. À deux réplicas, un événement inséré par l’instance A ne réveille jamais le relay de l’instance B, qui attendra le prochain tick du ticker. Nous n’avons pas résolu ce cas chez Stonal, je ne prétendrai donc pas l’avoir fait. Trois pistes, aucune éprouvée : LISTEN/NOTIFY entre instances (8000 octets, non durable, incompatible PgBouncer en mode transaction), un relay élu par lease comme dans Quand l’advisory lock ne suffit plus, ou un ticker plus agressif.
Le publisher confirm autorise l’UPDATE, pas l’envoi
Sans publisher confirms, publier sur RabbitMQ reste du fire-and-forget : l’appel réseau part, rien ne dit que le broker a accepté le message. Avec eux, le broker n’acquitte qu’une fois le message accepté par toutes les queues concernées, ce qui implique la persistance sur disque pour un message persistant en queue durable. Marquer published_at avant cet accusé de réception reproduirait le dual write à l’intérieur du relay lui-même.
// Publish sends the event with its outbox id as the RabbitMQ MessageId — the same id the
// shipping service will later use as its inbox deduplication key — and blocks until the
// broker confirms that specific delivery, positively or negatively.
//
// The confirmation is tied to this call's own DeliveryTag rather than read off a shared
// channel: a shared channel would let a confirmation abandoned by one Publish (its ctx
// cancelled while waiting) be picked up by the next one, marking the wrong row published.
func (p *RabbitPublisher) Publish(ctx context.Context, event Event) error {
confirmation, err := p.channel.PublishWithDeferredConfirmWithContext(ctx, p.exchange, event.RoutingKey(),
false, false, amqp.Publishing{
MessageId: strconv.FormatInt(event.ID, 10),
Type: event.EventType,
ContentType: "application/json",
Headers: amqp.Table{
// RabbitMQ has no per-key partitioning to hand the aggregate id to, unlike a Kafka
// message key, so it travels as correlation metadata a consumer can log and filter on.
"x-aggregate-type": event.AggregateType,
"x-aggregate-id": event.AggregateID,
},
Body: event.Payload,
})
if err != nil {
return fmt.Errorf("publish event %d to rabbitmq: %w", event.ID, err)
}
ack, err := confirmation.WaitContext(ctx)
if err != nil {
return fmt.Errorf("wait for publisher confirm on event %d: %w", event.ID, err)
}
if !ack {
return fmt.Errorf("broker nacked event %d", event.ID)
}
return nil
}
Entretenir la table : le fillfactor est un faux ami
Un job de purge tourne chaque jour chez Stonal. Question restée ouverte : faut-il régler le fillfactor ou l’autovacuum pour une table qui écrit et met à jour à ce rythme ?
Réponse courte : le fillfactor ne sert à rien ici. Il n’a d’intérêt que s’il permet un HOT update, une mise à jour restant dans la même page sans toucher les index. Or notre index partiel porte sur published_at, la colonne modifiée à chaque publication. La règle est posée noir sur blanc dans README.HOT, la documentation interne du moteur : une colonne « indexée » désigne toute colonne référencée dans la définition d’un index, y compris celle testée dans le prédicat d’un index partiel sans y être stockée. La page CREATE INDEX, elle, n’en dit rien ; sur ce point, c’est la doc interne du moteur qui fait autorité, pas la référence SQL. Chaque UPDATE published_at crée donc une nouvelle entrée d’index, fillfactor ou pas.
Ce qui compte réellement :
- L’autovacuum par défaut suffit à ce volume. À 50 000 événements par jour, la table reste de l’ordre de quelques dizaines de milliers de lignes entre deux purges. Le régler plus finement serait de l’optimisation prématurée.
- Le vrai risque est le bloat d’index, mais à une autre échelle. Sadeq Dousti a reproduit le cas sur PostgreSQL 17.5 : à un million de lignes dans la table, la requête du relay se dégrade de cinq ordres de grandeur (0,13 ms à 18,5 s), à cause d’entrées d’index mortes que le vacuum ne nettoie pas systématiquement. Son partitionnement par état (
published_at IS NULLcontreNOT NULL) ramène la requête à 1 à 3 ms stables. Utile quand la table chaude se compte en millions de lignes, pas dans les dizaines de milliers de Stonal. - L’astuce insert-puis-delete de Gunnar Morling ne s’applique pas ici. Elle insère puis supprime la ligne dans la même transaction pour que seul le WAL en garde la trace, valable en CDC log-based (Debezium lit le WAL). Un relay applicatif qui fait un
SELECTsur la table ne trouve plus rien : contresens fréquent.
Ce que fait l’outbox, ce qu’elle ne fait pas
L’outbox garantit qu’un message finira par partir, jamais qu’il ne partira qu’une fois. Un crash du relay entre publication et marquage, un redémarrage : chaque cas peut produire un doublon, prix payé délibérément pour ne jamais en perdre.
La ligne outbox porte déjà la clé pour l’absorber côté consommateur : son id. Sujet du prochain article, Consommer sans doublon : le pattern inbox en MongoDB.
📚 Ressources
- Oskar Dudycz — How Postgres sequences issues can impact your messaging guarantees
- Sadeq Dousti — PostgreSQL + Outbox Pattern Revamped, Part 1
- PostgreSQL — README.HOT (arbre source)
- PostgreSQL docs — CREATE INDEX
- Gunnar Morling — Revisiting the Outbox Pattern
- RabbitMQ docs — Consumer Acknowledgements and Publisher Confirms
