Guide technique

Architecture Event-Driven en pratique : ce qui tourne vraiment mal

Patterns réels d'architecture event-driven en production. Event storms, boucles de sync bidirectionnelle, dead letters, stores d'idempotence, et choix entre Kafka, RabbitMQ, BullMQ et Symfony Messenger.

23 février 202618 min de lectureÉquipe d'Ingénierie Oronts

L'event storm que personne n'anticipe

L'architecture event-driven a fière allure sur un tableau blanc. Le service A émet un événement. Le service B le consomme. Couplage faible. Scaling indépendant. De jolis diagrammes.

Puis tu déploies en production. Un seul enregistrement de produit déclenche 8 event subscribers. Chaque subscriber dispatche 3 messages asynchrones. Chaque handler charge le produit, modifie un champ et sauvegarde. Chaque sauvegarde redéclenche les 8 subscribers. En quelques secondes, ta file de messages contient des milliers d'entrées pour une seule mise à jour produit, tes workers sont saturés, et ta base de données est sous contention de verrous.

Nous avons opéré quatre systèmes event-driven différents en production. Chacun utilisait un broker de messages différent. Chacun présentait des patterns de panne distincts. Cet article couvre ce qui tourne réellement mal et comment y remédier. Pour un contexte plus large sur notre approche de l'architecture système, ce guide présente notre méthodologie.

Les quatre patterns de panne

Tout système event-driven finit par rencontrer ceux-ci :

PatternCe qui se passeSévérité
Event stormUne action déclenche une cascade de milliers de messagesCritique (peut saturer l'infrastructure)
Boucle de sync bidirectionnelleLe système A synchronise vers B, B synchronise vers A, boucle infinieCritique (croissance infinie des messages)
Traitement en doubleUn même message traité deux fois crée des données en doubleÉlevée (intégrité des données)
Accumulation de dead lettersLes messages en échec s'accumulent sans stratégie de résolutionMoyenne (dette opérationnelle)

1. Event storms

Le pattern le plus courant et le plus dangereux. Il survient quand les event handlers déclenchent de nouveaux événements, et que ces événements déclenchent d'autres handlers.

Product save
  -> EventSubscriber A dispatche MessageA
  -> EventSubscriber B dispatche MessageB
  -> EventSubscriber C dispatche MessageC
  -> ...8 subscribers au total

WorkerA traite MessageA
  -> Modifie le produit, appelle save()
  -> La sauvegarde redéclenche les 8 subscribers
  -> Chacun dispatche de nouveaux messages

WorkerB traite MessageB (même pattern)
  -> 8 messages de plus

Résultat : 1 save -> 8 messages -> 8 saves -> 64 messages -> ...

La solution : l'EventSubscriberSupervisor. Un contrôle au niveau du processus qui désactive les event subscribers pendant les sauvegardes des workers.

// Handler de worker : désactiver les subscribers avant la sauvegarde
async function handleGenerateThumbnail(message: ThumbnailMessage) {
    const product = await productService.findById(message.productId);

    subscriberSupervisor.disableAll();
    try {
        product.thumbnail = await generateThumbnail(product);
        await product.save(); // Aucun subscriber ne se déclenche, aucun nouveau message dispatché
    } finally {
        subscriberSupervisor.enableAll();
    }
}

L'ordre est important. La sauvegarde doit se faire dans le scope désactivé. Si tu réactives les subscribers avant la sauvegarde, celle-ci déclenche la cascade.

Pour un regard plus approfondi sur la façon dont nous appliquons ce pattern spécifiquement dans Pimcore, consulte notre guide de conception de workflows Pimcore.

2. Boucles de sync bidirectionnelle

Quand deux systèmes synchronisent des données dans les deux sens, chaque mise à jour depuis A déclenche une synchronisation vers B, qui déclenche une synchronisation vers A. Sans prévention de boucle, ça tourne indéfiniment.

Système A (Commerce)         Système B (Opérations)
     │                           │
     │  Client mis à jour        │
     │ ────────────────────────▶ │
     │                           │  Sync reçue, sauvegarde client
     │                           │  L'événement client updated se déclenche
     │  Sync reçue               │
     │ ◀──────────────────────── │
     │  Sauvegarde client        │
     │  Événement client updated │
     │ ────────────────────────▶ │
     │         ...à l'infini...  │

La solution : le source tracking. Chaque message porte un header x-source qui identifie le système d'origine. Quand un système reçoit un message provenant de lui-même (via l'autre système), il l'ignore.

// Publication d'un message de synchronisation
async function publishCustomerSync(customer: Customer, source: string) {
    await messageQueue.publish('customer.updated', {
        customerId: customer.id,
        data: customer,
        source: source,  // "commerce" ou "operations"
    });
}

// Consommation d'un message de synchronisation
async function handleCustomerSync(message: CustomerSyncMessage) {
    // Ignorer les messages provenant de ce système
    if (message.source === THIS_SYSTEM_ID) {
        logger.debug('Ignoring self-originated sync', { customerId: message.customerId });
        return; // ACK le message, ne pas traiter
    }

    const customer = await customerService.update(message.customerId, message.data);

    // À la sauvegarde, marquer la source pour que les événements en aval la portent
    await publishCustomerSync(customer, THIS_SYSTEM_ID);
}

Une approche alternative : la déduplication de messages par hash de contenu. Tu hashes le payload du message et vérifies si le même hash a été traité récemment. Si les données n'ont pas changé, la synchronisation est un no-op.

3. Traitement en double

Les pannes réseau, les redémarrages de workers et les garanties de livraison at-least-once font que le même message peut être délivré plus d'une fois. Sans idempotence, tu te retrouves avec des commandes en double, des emails en double, des enregistrements en double dans la base.

La solution : des stores d'idempotence avec déduplication par clé métier.

// Store d'idempotence avec clé métier
class IdempotencyStore {
    async checkAndAcquire(key: string, scope: string): Promise<boolean> {
        try {
            await this.db.insert('idempotency_keys', {
                key,
                scope,
                status: 'PROCESSING',
                created_at: new Date(),
                expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000), // TTL de 24h
            });
            return true; // Acquis, procéder au traitement
        } catch (error) {
            if (isDuplicateKeyError(error)) {
                return false; // Déjà traité, on passe
            }
            throw error;
        }
    }

    async complete(key: string, scope: string): Promise<void> {
        await this.db.update('idempotency_keys',
            { status: 'COMPLETED', completed_at: new Date() },
            { key, scope }
        );
    }
}

// Utilisation dans un worker
async function handleNotification(message: NotificationMessage) {
    const dedupeKey = `${message.recipientEmail}:${message.category}:${message.entityRef}:${todayBucket()}`;

    const acquired = await idempotencyStore.checkAndAcquire(dedupeKey, 'notification');
    if (!acquired) {
        logger.info('Duplicate notification skipped', { dedupeKey });
        return; // ACK le message
    }

    try {
        await emailService.send(message);
        await idempotencyStore.complete(dedupeKey, 'notification');
    } catch (error) {
        await idempotencyStore.fail(dedupeKey, 'notification');
        throw error; // NACK, laisser la queue retenter
    }
}

La clé de déduplication doit avoir un sens métier. Pour les notifications : destinataire + catégorie + entité + bucket du jour (empêche l'envoi de la même notification deux fois dans la journée mais l'autorise le lendemain). Pour les commandes : tenant + produit + date. Pour les imports : ID de l'enregistrement source + ID du batch d'import.

4. Gestion des dead letters

Les messages qui échouent de manière répétée finissent dans une dead letter queue. La plupart des équipes mettent en place la DLQ puis l'oublient. Les dead letters s'accumulent. Des mois plus tard, quelqu'un découvre des milliers de messages non traités.

// Handler de dead letter avec classification
async function processDeadLetter(message: DeadLetterMessage) {
    const failureType = classifyFailure(message.error);

    switch (failureType) {
        case 'TRANSIENT':
            // Timeout réseau, service temporairement indisponible
            // Remettre en queue avec backoff exponentiel
            await requeue(message, { delay: calculateBackoff(message.retryCount) });
            break;

        case 'PERMANENT':
            // Données invalides, incompatibilité de schéma, violation de règle métier
            // Logger, alerter, archiver. Ne pas retenter.
            await archiveDeadLetter(message);
            await alertOps('Permanent failure in dead letter', message);
            break;

        case 'POISON':
            // Le message lui-même provoque des crashs (payload malformé)
            // Archiver immédiatement, ne jamais retenter
            await archiveDeadLetter(message);
            await alertOps('Poison message detected', message);
            break;
    }
}

function classifyFailure(error: string): 'TRANSIENT' | 'PERMANENT' | 'POISON' {
    if (error.includes('ECONNREFUSED') || error.includes('timeout')) return 'TRANSIENT';
    if (error.includes('SyntaxError') || error.includes('Cannot parse')) return 'POISON';
    return 'PERMANENT';
}

L'insight clé : les dead letters ne sont pas toutes identiques. Les pannes transitoires doivent être retentées. Les pannes permanentes doivent être archivées et investiguées. Les messages poison doivent être mis en quarantaine immédiatement.

Pour découvrir comment nous gérons les patterns de panne dans les systèmes IA spécifiquement, consulte notre guide des modes de défaillance IA.

Choisir un broker de messages

Nous avons utilisé quatre brokers différents en production. Chacun a son créneau idéal.

BrokerIdéal pourPas adapté pourComplexité de déploiement
KafkaStreaming d'événements à haut débit, event sourcing, replayFiles de jobs simples, systèmes à faible volumeÉlevée (ZooKeeper/KRaft, partitions, consumer groups)
RabbitMQRoutage, dead letters, queues prioritaires, topologies complexesTrès haut débit (millions/sec), replay d'événementsMoyenne (clustering, politiques HA)
BullMQ (Redis)Files de jobs, jobs différés, rate limiting, configurations simplesRoutage complexe, replay de messages, patterns multi-consommateursBasse (juste Redis)
Symfony MessengerApplications PHP/Symfony, Pimcore, dispatch asynchrone simpleÉcosystèmes non-PHP, fonctionnalités avancées de brokerBasse (utilise Doctrine, AMQP ou Redis comme transport)

Quand utiliser Kafka

  • Tu as besoin de replay d'événements (les consumers peuvent relire depuis n'importe quel offset)
  • Tu as plusieurs consumer groups qui traitent les mêmes événements différemment
  • Le débit dépasse 100K messages par seconde
  • Tu fais de l'event sourcing ou tu construis un pipeline de change data capture
  • Tu as besoin d'un ordre garanti au sein d'une partition

Quand utiliser RabbitMQ

  • Tu as besoin de routage complexe (topic exchanges, routage basé sur les headers)
  • Les dead letter queues avec politiques de retry automatique sont importantes
  • La priorité des messages compte (messages urgents traités en premier)
  • Tu as besoin de patterns request-reply (RPC par messages)
  • Ton système comporte plusieurs services dans des langages différents

Quand utiliser BullMQ

  • Tu es dans un écosystème Node.js/TypeScript (Vendure, NestJS)
  • Planification de jobs avec délais et patterns cron
  • Rate limiting par queue ou par type de job
  • Tu as déjà Redis pour le cache et les sessions
  • Ton système est de petite à moyenne envergure (moins de 10K messages/sec)

Quand utiliser Symfony Messenger

  • Tu es dans un écosystème PHP/Symfony/Pimcore
  • Tu as besoin d'un dispatch asynchrone simple pour les tâches de fond
  • L'infrastructure de workers suit les conventions Symfony (supervisord)
  • Flexibilité de transport (bascule entre Doctrine, AMQP et Redis sans changer le code)

Pour nos projets Pimcore, nous utilisons Symfony Messenger avec le transport RabbitMQ. Pour les projets Vendure, nous utilisons BullMQ. Pour les plateformes d'ingestion de données à haut débit, nous utilisons Kafka. Le choix du broker suit l'écosystème, pas l'inverse.

Ordre des messages

La plupart des équipes supposent que l'ordre des messages est important. En général, ça ne l'est pas.

ScénarioOrdre nécessaire ?Pourquoi
Envoyer un email de notificationNonLes emails sont intrinsèquement non ordonnés
Générer une miniatureNonLa dernière version prime
Mettre à jour l'index de rechercheNonLe dernier état prime (consistance éventuelle)
Traiter les étapes de paiementOuiLa facturation doit précéder la capture
Transitions d'état de commandeOuiImpossible d'expédier avant la confirmation du paiement
Replay d'event sourcingOuiLes événements doivent être rejoués dans l'ordre causal

Quand l'ordre est important, utilise des queues FIFO (SQS FIFO, partitions Kafka avec clé par ID d'entité, RabbitMQ avec un seul consumer). Quand ce n'est pas le cas, le traitement parallèle est plus rapide et plus simple.

// Kafka : ordre au sein d'une partition (clé par ID produit)
await producer.send({
    topic: 'product-updates',
    messages: [{
        key: productId,  // Toutes les mises à jour du même produit vont dans la même partition
        value: JSON.stringify(event),
    }],
});

// BullMQ : pas d'ordre nécessaire pour les miniatures
await thumbnailQueue.add('generate', { productId }, {
    removeOnComplete: true,
    attempts: 3,
    backoff: { type: 'exponential', delay: 5000 },
});

Stratégies de retry

Tous les retries ne se valent pas. La stratégie dépend du type de panne.

StratégieQuand l'utiliserExemple
Retry immédiatGlitch transitoire (race condition)Échec de verrou optimiste
Backoff exponentielService temporairement indisponibleTimeout d'API externe
Délai fixeRate limitingL'API renvoie un 429
Pas de retryPanne permanentePayload invalide, violation de règle métier
// Configuration de retry BullMQ
const queue = new Queue('notifications', {
    defaultJobOptions: {
        attempts: 5,
        backoff: {
            type: 'exponential',
            delay: 60000, // 1min, 2min, 4min, 8min, 16min
        },
        removeOnComplete: { count: 1000 },
        removeOnFail: false, // Conserver pour analyse des dead letters
    },
});

Quand tous les retries sont épuisés, le message part dans une dead letter queue. Ne retente jamais indéfiniment. Fixe un nombre maximum de tentatives et gère l'échec explicitement.

Monitoring des systèmes event-driven

La partie la plus difficile des systèmes event-driven, c'est le debugging. Quand quelque chose échoue, l'erreur peut se trouver à 5 services et 12 messages de la cause d'origine.

Correlation IDs

Chaque message porte un correlation ID qui le relie à l'action originale :

// La requête API d'origine génère un correlation ID
const correlationId = generateUUID();

// Chaque message de la chaîne le porte
await queue.add('process-order', {
    orderId: order.id,
    correlationId,
});

// Les workers le transmettent aux messages en aval
async function handleProcessOrder(job) {
    const { orderId, correlationId } = job.data;

    // ... traitement de la commande ...

    // Les messages en aval portent le même correlation ID
    await notificationQueue.add('send-confirmation', {
        orderId,
        correlationId, // Même ID, traçable jusqu'à la requête d'origine
    });
}

Pour le debugging, requête tous les logs et événements par correlation ID afin de voir la chaîne complète. Pour en savoir plus sur les patterns de tracing distribué, consulte notre guide sur l'observabilité IA.

Métriques de santé des queues

MétriqueSainAttentionCritique
Profondeur de queue< 100100-1000> 1000
Temps de traitement (p95)< 5s5-30s> 30s
Taux d'échec< 1%1-5%> 5%
Nombre de dead letters01-10> 10
Consumer lag (Kafka)< 10001K-10K> 10K

Alerte sur les tendances de profondeur de queue, pas sur les valeurs absolues. Une profondeur de queue à 500 qui est stable depuis des heures, c'est normal. Une profondeur de queue à 50 qui était à 0 il y a une heure et qui monte, c'est un problème.

Pièges courants

  1. Pas de contrôle des subscribers pendant les sauvegardes de workers. La source numéro un des event storms. Les sauvegardes de workers ne doivent pas déclencher les mêmes event subscribers qui dispatchent du travail vers les workers.

  2. Pas de source tracking sur la sync bidirectionnelle. Sans headers x-source, la synchronisation bidirectionnelle entre deux systèmes boucle indéfiniment.

  3. Utiliser l'ID de message pour l'idempotence. Les IDs de message changent à la redélivrance dans certains brokers. Utilise des clés métier (ID d'entité + action + bucket temporel) à la place.

  4. Retries infinis. Fixe un nombre maximum de retries. Après épuisement, redirige vers la dead letter queue. Ne retente jamais indéfiniment.

  5. Ignorer les dead letter queues. Les dead letters sont de la dette opérationnelle. Classifie-les (transitoire, permanent, poison) et gère chaque type différemment.

  6. Supposer que l'ordre des messages est important. La plupart des workloads n'ont pas besoin d'ordre. Le traitement parallèle est plus rapide. N'utilise le FIFO que lorsque les transitions d'état ou l'ordre causal l'exigent.

  7. Même stratégie de retry pour toutes les pannes. Un timeout nécessite un backoff exponentiel. Un payload invalide nécessite zéro retry. Un rate limit nécessite un délai fixe.

  8. Pas de correlation IDs. Sans eux, debugger une chaîne de pannes à travers 5 services est impossible.

Points clés à retenir

  • Les event storms sont le mode de défaillance le plus dangereux. Une cascade de sauvegardes incontrôlée peut saturer toute ton infrastructure. L'EventSubscriberSupervisor n'est pas optionnel.

  • La sync bidirectionnelle nécessite du source tracking. Chaque message porte l'ID du système d'origine. Quand tu reçois un message provenant de toi-même (via l'autre système), ignore-le.

  • L'idempotence utilise des clés métier, pas des IDs de message. Destinataire + catégorie + entité + bucket du jour pour les notifications. Tenant + produit + date pour les commandes. La clé doit avoir un sens métier.

  • Les dead letters nécessitent une classification. Les pannes transitoires sont retentées. Les pannes permanentes sont archivées. Les messages poison sont mis en quarantaine. N'ignore jamais la dead letter queue.

  • Le broker suit l'écosystème. Kafka pour le streaming à haut débit, RabbitMQ pour le routage complexe, BullMQ pour les files de jobs Node.js, Symfony Messenger pour PHP. Ne choisis pas un broker pour construire autour ensuite.

  • La plupart des workloads n'ont pas besoin d'ordre. Le traitement parallèle est plus simple et plus rapide. N'utilise le FIFO que lorsque les transitions d'état l'exigent.

Nous appliquons ces patterns à travers nos projets de développement logiciel sur mesure, nos pipelines de data engineering, et nos déploiements cloud. Si tu construis un système event-driven ou que tu en debugges un qui pose déjà problème, contacte notre équipe ou demande un devis.

Sujets couverts

architecture event-drivenfiles de messages productionevent sourcingCQRS productionRabbitMQ vs KafkaBullMQevent stormsidempotencedead letter queuesynchronisation bidirectionnelle

Prêt à construire des systèmes IA prêts pour la production ?

Notre équipe est spécialisée dans les systèmes IA prêts pour la production. Discutons de comment nous pouvons aider.

Démarrer une conversation