Go Distributed Transaction Outbox: 5 Core Patterns for Reliable Event-Driven Architecture
Le problème : Les points douloureux déclenchés par des événements
Un système de commande e-commerce refactorisé en architecture orientée événements souffrait fréquemment d'incohérences de données — "commande créée mais inventaire non déduit". L'enquête a révélé : l'envoi de messages et les opérations de base de données ne se trouvant pas dans la même transaction entraînant une perte de messages, les fluctuations de réseau déclenchant une consommation en double, une mauvaise sélection de clé de partition causant des problèmes d'ordre des événements, et des intervalles de balayage de la sortie trop longs provoquant des retards en aval. Ces quatre problèmes cumulatifs ont transformé la "cohérence finale" en "cohérence occasionnelle". Le modèle Transactional Outbox est la solution centrale, garantissant l'atomicité entre les opérations commerciales et la publication des événements.
Concepts clés en un coup d'œil
| Concept | Description | Importance |
|---|---|---|
| Boîte de sortie transactionnelle | Écrire des événements dans une table de sortie au sein de la même transaction métier, en garantissant l'atomique | ⭐⭐⭐⭐⭐ |
| Pilote par événements | Découpler les services via des notifications d'événements plutôt que des appels synchrones | ⭐⭐⭐⭐⭐ |
| Fiabilité des messages | Assurez-vous que les messages ne sont pas perdus, non dupliqués et livrés dans l'ordre | ⭐⭐⭐⭐⭐ |
| Consommation idempotente | Le consommateur produit le même résultat lors du traitement d'un même message à plusieurs reprises | ⭐⭐⭐⭐⭐ |
| CDC | Capture des changements de données, surveillance du Binlog de la base de données pour la publication d'événements en temps réel | ⭐⭐⭐⭐⭐ |
| Debezium | Plateforme CDC open-source prenant en charge la capture des changements MySQL/PostgreSQL | ⭐⭐⭐⭐ |
| Message Retry | Mécanisme de réessai pour la consommation échouée des messages avec stratégie d'atténuation | ⭐⭐⭐⭐ |
| Source d'Événements | Utiliser les séquences d'événements comme source de vérité, permettant la reconstruction de l'état et l'audit | ⭐⭐⭐ |
Analyse du problème : 5 grands défis dans la boîte aux lettres transactionnelle
**1. Atomicité des opérations commerciales et de l'envoi de messages : L'approche traditionnelle consiste à écrire dans la base de données (DB) avant d'envoyer un message — deux opérations qui ne peuvent pas garantir l'atomicité. Si l'écriture dans la DB réussit mais l'envoi du message échoue, les services en aval ne reçoivent jamais l'événement ; si l'envoi du message a lieu mais l'écriture dans la DB échoue, cela crée des événements fantômes.
**2. Garantie d'ordre des messages : Les événements pour la même racine d'agrégat doivent être consommés dans l'ordre, mais une mauvaise sélection de la clé de partition Kafka ou un envoi simultané par relais peut entraîner un réordonnancement, conduisant ainsi à l'exécution de logiques métier basées sur un état obsolète en aval.
**3. Implémentation de la consommation idempotente : La retransmission du réseau, l'envoi redondant en relais et les redémarrages des consommateurs provoquent tous une consommation double. Sans idempotence, une même commande pourrait déduire le stock deux fois.
**4. Latence de sondage de la sortie : Le sondage repose sur le balayage périodique de la table de sortie — des intervalles trop longs augmentent la latence, tandis que des intervalles trop courts gaspillent les ressources de la base de données. Sous haute concurrence, le sondage devient un goulot d'étranglement en termes de performance.
**5. Complexité de la configuration CDC : Debezium nécessite le déploiement de Kafka Connect, la configuration des Connecteurs et la gestion des changements de schéma — ce qui entraîne des coûts opérationnels élevés. Les environnements de production doivent également prendre en compte le format Binlog, les GTID et la haute disponibilité.
Pattern 1 : Conception de la table Outbox et écriture transactionnelle
La table Outbox est écrite dans la même transaction de base de données que la table métier, garantissant l'atomicité entre les opérations métier et les enregistrements d'événements. L'état de l'événement commence par PENDING et est envoyé de manière asynchrone par le relais.
package outbox
import (
"context"
"database/sql"
"encoding/json"
"time"
)
type OutboxEvent struct {
ID int64 `json:"id"`
AggregateID string `json:"aggregate_id"`
EventType string `json:"event_type"`
Payload json.RawMessage `json:"payload"`
Status string `json:"status"`
Retries int `json:"retries"`
CreatedAt time.Time `json:"created_at"`
}
type OutboxRepository struct {
db *sql.DB
}
func NewOutboxRepository(db *sql.DB) *OutboxRepository {
return &OutboxRepository{db: db}
}
func (r *OutboxRepository) SaveWithTx(ctx context.Context, tx *sql.Tx, event *OutboxEvent) error {
query := `INSERT INTO outbox_events (aggregate_id, event_type, payload, status, created_at)
VALUES (?, ?, ?, 'PENDING', NOW())`
result, err := tx.ExecContext(ctx, query,
event.AggregateID, event.EventType, event.Payload)
if err != nil {
return err
}
event.ID, _ = result.LastInsertId()
return nil
}
type OrderService struct {
db *sql.DB
outbox *OutboxRepository
}
func (s *OrderService) CreateOrder(ctx context.Context, orderID, userID string, items []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
`INSERT INTO orders (id, user_id, items, status, created_at) VALUES (?, ?, ?, 'CREATED', NOW())`,
orderID, userID, items)
if err != nil {
return err
}
payload, _ := json.Marshal(map[string]interface{}{
"order_id": orderID,
"user_id": userID,
"items": items,
"action": "order_created",
})
event := &OutboxEvent{
AggregateID: orderID,
EventType: "order.created",
Payload: payload,
}
if err := s.outbox.SaveWithTx(ctx, tx, event); err != nil {
return err
}
return tx.Commit()
}
Table DDL de la boîte aux lettres :
CREATE TABLE outbox_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
aggregate_id VARCHAR(128) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
status ENUM('PENDING','SENT','FAILED') DEFAULT 'PENDING',
retries INT DEFAULT 0,
created_at DATETIME(3) NOT NULL,
INDEX idx_status_created (status, created_at),
INDEX idx_aggregate_id (aggregate_id)
) ENGINE=InnoDB;
Pattern 2 : Émetteur de relais par sondage
Le relais de sondage scanne périodiquement les événements de la boîte de sortie avec un statut PENDING, les publie sur Kafka, puis met à jour le statut à SENT. Point clé : utilisez SELECT ... FOR UPDATE SKIP LOCKED pour éviter les envois en double entre plusieurs instances.
package outbox
import (
"context"
"database/sql"
"fmt"
"log"
"time"
"github.com/segmentio/kafka-go"
)
type PollingRelay struct {
db *sql.DB
writer *kafka.Writer
batchSize int
interval time.Duration
}
func NewPollingRelay(db *sql.DB, kafkaAddr, topic string, batchSize int, interval time.Duration) *PollingRelay {
return &PollingRelay{
db: db,
writer: &kafka.Writer{
Addr: kafka.TCP(kafkaAddr),
Topic: topic,
Balancer: &kafka.LeastBytes{},
BatchTimeout: 10 * time.Millisecond,
},
batchSize: batchSize,
interval: interval,
}
}
func (r *PollingRelay) Start(ctx context.Context) {
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := r.pollAndPublish(ctx); err != nil {
log.Printf("polling relay error: %v", err)
}
}
}
}
func (r *PollingRelay) pollAndPublish(ctx context.Context) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx,
`SELECT id, aggregate_id, event_type, payload, retries
FROM outbox_events
WHERE status = 'PENDING' AND retries < 5
ORDER BY created_at ASC
LIMIT ? FOR UPDATE SKIP LOCKED`, r.batchSize)
if err != nil {
return err
}
defer rows.Close()
var events []OutboxEvent
for rows.Next() {
var e OutboxEvent
if err := rows.Scan(&e.ID, &e.AggregateID, &e.EventType, &e.Payload, &e.Retries); err != nil {
return err
}
events = append(events, e)
}
if len(events) == 0 {
return nil
}
var messages []kafka.Message
for _, e := range events {
messages = append(messages, kafka.Message{
Key: []byte(e.AggregateID),
Value: e.Payload,
Headers: []kafka.Header{
{Key: "event_type", Value: []byte(e.EventType)},
{Key: "event_id", Value: []byte(fmt.Sprintf("%d", e.ID))},
},
})
}
if err := r.writer.WriteMessages(ctx, messages...); err != nil {
for _, e := range events {
tx.ExecContext(ctx, `UPDATE outbox_events SET retries = retries + 1 WHERE id = ?`, e.ID)
}
return fmt.Errorf("kafka write failed: %w", err)
}
for _, e := range events {
if _, err := tx.ExecContext(ctx, `UPDATE outbox_events SET status = 'SENT' WHERE id = ?`, e.ID); err != nil {
return err
}
}
return tx.Commit()
}
Modèle 3 : Capture de données de changement CDC (Debezium)
Le CDC capture les modifications de la table Outbox en temps réel en surveillant le Binlog de la base de données — pas besoin de sondage, latence réduite. Debezium est une solution CDC de niveau production fonctionnant via Kafka Connect.
Configuration du connecteur MySQL Debezium :
{
"name": "outbox-connector",
"config": {
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.hostname": "mysql",
"database.port": "3306",
"database.user": "debezium",
"database.password": "dbz_pass",
"database.server.id": "184054",
"database.server.name": "outbox_server",
"database.include.list": "order_db",
"table.include.list": "order_db.outbox_events",
"database.history.kafka.bootstrap.servers": "kafka:9092",
"database.history.kafka.topic": "schema-changes",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.route.topic.replacement": "order-events",
"transforms.outbox.table.field.event.id": "id",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.table.field.event.type": "event_type",
"transforms.outbox.table.field.event.payload": "payload",
"transforms.outbox.table.fields.additional.placement": "status:header:eventStatus"
}
}
Intégration du consommateur Go :
package consumer
import (
"context"
"log"
"github.com/segmentio/kafka-go"
)
type OutboxEventHandler struct {
reader *kafka.Reader
}
func NewOutboxEventHandler(kafkaAddr, topic, groupID string) *OutboxEventHandler {
return &OutboxEventHandler{
reader: kafka.NewReader(kafka.ReaderConfig{
Brokers: []string{kafkaAddr},
Topic: topic,
GroupID: groupID,
MinBytes: 10e3,
MaxBytes: 10e6,
}),
}
}
func (h *OutboxEventHandler) Start(ctx context.Context) {
for {
msg, err := h.reader.ReadMessage(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
log.Printf("read message error: %v", err)
continue
}
eventType := ""
for _, hdr := range msg.Headers {
if hdr.Key == "event_type" {
eventType = string(hdr.Value)
break
}
}
log.Printf("received event: type=%s key=%s", eventType, string(msg.Key))
}
}
Modèle 4 : Consommation idempotente et suppression des doublons
La consommation idempotente est le filet de sécurité de l'architecture pilotée par événements. La suppression des doublons via une table d'enregistrement de consommation garantit qu'un même événement ne sera jamais traité deux fois.
package consumer
import (
"context"
"database/sql"
"fmt"
)
type IdempotentHandler struct {
db *sql.DB
}
func NewIdempotentHandler(db *sql.DB) *IdempotentHandler {
return &IdempotentHandler{db: db}
}
func (h *IdempotentHandler) Handle(ctx context.Context, eventID string, handler func(ctx context.Context) error) error {
tx, err := h.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var status string
err = tx.QueryRowContext(ctx,
`SELECT status FROM consume_records WHERE event_id = ? FOR UPDATE`, eventID).Scan(&status)
if err == nil {
if status == "PROCESSED" {
return nil
}
return fmt.Errorf("event %s in status %s, skip", eventID, status)
}
if err != sql.ErrNoRows {
return err
}
_, err = tx.ExecContext(ctx,
`INSERT INTO consume_records (event_id, status, created_at) VALUES (?, 'PROCESSING', NOW())`, eventID)
if err != nil {
return err
}
if err := handler(ctx); err != nil {
tx.ExecContext(ctx, `UPDATE consume_records SET status = 'FAILED' WHERE event_id = ?`, eventID)
return err
}
_, err = tx.ExecContext(ctx, `UPDATE consume_records SET status = 'PROCESSED' WHERE event_id = ?`, eventID)
if err != nil {
return err
}
return tx.Commit()
}
Table de consommation :
CREATE TABLE consume_records (
event_id VARCHAR(128) PRIMARY KEY,
status ENUM('PROCESSING','PROCESSED','FAILED') DEFAULT 'PROCESSING',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB;
Modèle 5 : Framework de sortie de production (avec surveillance)
Une boîte de sortie de niveau production nécessite : des vérifications de santé, la collecte de métriques, une fermeture gracieuse, des files d'attente de lettres mortes et des alertes. Ce framework intègre tous les modèles mentionnés ci-dessus.
package outbox
import (
"context"
"database/sql"
"log"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/segmentio/kafka-go"
)
type OutboxFramework struct {
db *sql.DB
writer *kafka.Writer
relay *PollingRelay
handler *IdempotentHandler
cancel context.CancelFunc
wg sync.WaitGroup
eventsPublished prometheus.Counter
eventsFailed prometheus.Counter
relayLatency prometheus.Histogram
}
func NewOutboxFramework(db *sql.DB, kafkaAddr, topic string) *OutboxFramework {
f := &OutboxFramework{
db: db,
writer: kafka.NewWriter(kafka.WriterConfig{
Brokers: []string{kafkaAddr},
Topic: topic,
Balancer: &kafka.LeastBytes{},
BatchTimeout: 10 * time.Millisecond,
}),
relay: NewPollingRelay(db, kafkaAddr, topic, 100, 500*time.Millisecond),
handler: NewIdempotentHandler(db),
}
f.eventsPublished = prometheus.NewCounter(prometheus.CounterOpts{
Name: "outbox_events_published_total",
Help: "Total number of outbox events published",
})
f.eventsFailed = prometheus.NewCounter(prometheus.CounterOpts{
Name: "outbox_events_failed_total",
Help: "Total number of outbox events failed",
})
f.relayLatency = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "outbox_relay_latency_seconds",
Help: "Latency from event creation to publish",
Buckets: prometheus.DefBuckets,
})
prometheus.MustRegister(f.eventsPublished, f.eventsFailed, f.relayLatency)
return f
}
func (f *OutboxFramework) Start() {
ctx, cancel := context.WithCancel(context.Background())
f.cancel = cancel
f.wg.Add(1)
go func() {
defer f.wg.Done()
f.relay.Start(ctx)
}()
f.wg.Add(1)
go func() {
defer f.wg.Done()
f.monitorPendingEvents(ctx)
}()
log.Println("outbox framework started")
}
func (f *OutboxFramework) monitorPendingEvents(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
var pending int
f.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM outbox_events WHERE status = 'PENDING'`).Scan(&pending)
if pending > 1000 {
log.Printf("ALERT: %d pending outbox events, possible relay lag", pending)
}
}
}
}
func (f *OutboxFramework) Shutdown() {
f.cancel()
f.wg.Wait()
f.writer.Close()
log.Println("outbox framework shutdown complete")
}
Guide des pièges à éviter
❌ Écrivez d'abord la base de données, puis envoyez le message, deux opérations sans garantie de transaction ✅ Utilisez la table Outbox pour écrire des événements dans la même transaction, en garantissant l'atomicité
❌ Relais de sondage sans verrouillage, plusieurs instances envoient des doublons
✅ Utilisez FOR UPDATE SKIP LOCKED pour une consommation d'exclusion mutuelle sans verrouillage
❌ Les clés aléatoires des messages Kafka provoquant un réordonnancement des événements ✅ Utilisez aggregate_id comme clé de partition pour garantir l'ordre pour la même racine d'agrégat
❌ Consommateur sans idempotence, la consommation en double provoque des erreurs métier ✅ Table d'enregistrement de consommation + Handler idempotent garantit que chaque événement est traité une seule fois
❌ La table de la boîte de sortie croît indéfiniment, la performance des requêtes se détériore ✅ Archiver régulièrement les événements SENT, les migrer vers la table d'historique après 7 jours
Dépannage des erreurs
| Symptôme d'erreur | Cause possible | Solution |
|---|---|---|
| Événements en attente s'accumulant | Le relais n'a pas été démarré ou Kafka est inaccessible | Vérifiez l'état de la routine de relais et la connexion Kafka |
| Le consommateur reçoit des événements en double | L'envoi a réussi mais la mise à jour de l'état a échoué | Vérifiez la logique de validation de la transaction, assurez-vous que l'envoi et la mise à jour de l'état sont atomiques |
| Commande de consommation d'événement incorrecte | Clé de partition ne utilisant pas aggregate_id | Unifier l'utilisation de l'ID de racine d'agrégat comme clé de message Kafka |
| Le connecteur Debezium s'est arrêté | Format de binlog non ROW ou droits insuffisants | Vérifiez que binlog_format=ROW et accordez les privilèges de REPLICATION |
| Table d'impotentiel bloquée | Consommation concurrente du même événement avec FOR UPDATE | Utiliser un index unique + INSERT IGNORE au lieu de SELECT FOR UPDATE |
| Latence de sondage trop élevée | Taille du lot trop petite ou intervalle trop long | Augmenter batch_size à 200+ et raccourcir l'intervalle à 200 ms |
| Ralentissement de la requête de la table Outbox | Grand volume de données sans index | Ajouter un index composite (status, created_at), archiver périodiquement |
| Timeout lors de l'envoi d'un message Kafka | Surcharge du cluster Kafka ou fluctuations du réseau | Augmenter le WriteTimeout, activer les reprises et le producteur idempotent |
| Gestion de l'explosion des enregistrements dans la table | Enregistrements expirés non nettoyés | Suppression périodique des enregistrements PROCESSED plus anciens de 7 jours |
| Retard CDC de plusieurs minutes | Mode de capture Debezium mal configuré | Utilisez schema_only pour éviter la capture complète, vérifiez la rétention des Binlog |
Optimisation avancée
1. Multi-Tenant Outbox : Ajoutez un champ tenant_id à la table Outbox, les relais sont envoyés par le shard du tenant pour empêcher les grands tenants de bloquer les petits.
2. Compression des événements : Utilisez la compression gzip pour le champ Payload — les corps d'événements volumineux (par exemple, les détails de commande) peuvent atteindre 70 % de compression, réduisant ainsi la bande passante Kafka et les coûts de stockage.
3. File d'attente prioritaire : Ajoutez un champ de priorité à la table de la Boîte de sortie. Les événements de haute priorité (paiement réussi) sont envoyés en premier, tandis que les événements de faible priorité (notifications) sont reportés.
**4. Dégradation d'écriture : Lorsque Kafka est indisponible, la table Outbox sert de tampon persistant. Le relais se dégrade automatiquement en mode de stockage local et rejoue les messages lorsque Kafka est rétabli.
**5. Registry des schémas d'événements : Utilisez Confluent Schema Registry pour gérer les versions des schémas d'événements. Les consommateurs désérialisent en fonction de la version, empêchant ainsi les modifications de schéma de provoquer des échecs de consommation.
Analyse comparative
| Dimension | Sortie de boîte Polling | CDC (Debezium) | MQ Transactionnel | Événements Saga |
|---|---|---|---|---|
| Latence | Moyenne (100ms-1s) | Faible (<100ms) | Faible (<50ms) | Moyenne |
| Complexité de mise en œuvre | Faible | Élevée | Moyenne | Élevée |
| Coût opérationnel | Faible | Haut (Kafka Connect) | Moyen | Haut |
| Dépendance de la base de données | Forte (pression de sonde) | Faible (surveillance Binlog) | Aucune | Moyenne |
| Ordre des messages | ✅ Contrôle de la clé de partition | ✅ Journal binaire ordonné | ✅ Messages de transaction ordonnés | ⚠️ Exige une conception supplémentaire |
| Support de l'idempotence | ⚠️ Implémenté manuellement | ⚠️ Implémenté manuellement | ✅ Présent dans le MQ | ⚠️ Implémenté manuellement |
| Cas d'utilisation | Petite à moyenne échelle, adoption rapide | Grande échelle, exigences de faible latence | Écosystème RocketMQ | Orchestration de transactions longues |
Résumé et Perspectives
La Transactional Outbox est la pierre angulaire de la fiabilité de l'architecture orientée événements, résolvant le problème d'atomicité entre les opérations métier et la publication d'événements. L'approche par sondage est simple à mettre en œuvre et convient pour une adoption rapide ; l'approche CDC offre une latence plus faible pour les scénarios à grande échelle. Les deux nécessitent une consommation idempotente pour garantir la cohérence finale. Les tendances futures incluent : la surveillance des changements de base de données basée sur eBPF remplaçant l'analyse Binlog, les bus d'événements Serverless simplifiant le relais Outbox, et le routage des messages et la détection d'anomalies pilotés par l'IA. Maîtriser ces 5 modèles fondamentaux permet de construire des architectures orientées événements fiables de niveau production.
Outils en ligne recommandés
- JSON Formatter - Formater le payload de l'événement Outbox
- Hash Calculator - Générer des clés de partition de hachage d'ID agrégé
- Curl to Code - Générer rapidement du code de test pour l'API Kafka
- Convertisseur d'horodatage - Convertir entre les horodatages d'événements et les heures lisible
Essayez ces outils exécutés localement dans le navigateur — aucune inscription requise →