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.
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 :
| Pattern | Ce qui se passe | Sévérité |
|---|---|---|
| Event storm | Une action déclenche une cascade de milliers de messages | Critique (peut saturer l'infrastructure) |
| Boucle de sync bidirectionnelle | Le système A synchronise vers B, B synchronise vers A, boucle infinie | Critique (croissance infinie des messages) |
| Traitement en double | Un même message traité deux fois crée des données en double | Élevée (intégrité des données) |
| Accumulation de dead letters | Les messages en échec s'accumulent sans stratégie de résolution | Moyenne (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.
| Broker | Idéal pour | Pas adapté pour | Complexité de déploiement |
|---|---|---|---|
| Kafka | Streaming d'événements à haut débit, event sourcing, replay | Files de jobs simples, systèmes à faible volume | Élevée (ZooKeeper/KRaft, partitions, consumer groups) |
| RabbitMQ | Routage, dead letters, queues prioritaires, topologies complexes | Très haut débit (millions/sec), replay d'événements | Moyenne (clustering, politiques HA) |
| BullMQ (Redis) | Files de jobs, jobs différés, rate limiting, configurations simples | Routage complexe, replay de messages, patterns multi-consommateurs | Basse (juste Redis) |
| Symfony Messenger | Applications PHP/Symfony, Pimcore, dispatch asynchrone simple | Écosystèmes non-PHP, fonctionnalités avancées de broker | Basse (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énario | Ordre nécessaire ? | Pourquoi |
|---|---|---|
| Envoyer un email de notification | Non | Les emails sont intrinsèquement non ordonnés |
| Générer une miniature | Non | La dernière version prime |
| Mettre à jour l'index de recherche | Non | Le dernier état prime (consistance éventuelle) |
| Traiter les étapes de paiement | Oui | La facturation doit précéder la capture |
| Transitions d'état de commande | Oui | Impossible d'expédier avant la confirmation du paiement |
| Replay d'event sourcing | Oui | Les é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égie | Quand l'utiliser | Exemple |
|---|---|---|
| Retry immédiat | Glitch transitoire (race condition) | Échec de verrou optimiste |
| Backoff exponentiel | Service temporairement indisponible | Timeout d'API externe |
| Délai fixe | Rate limiting | L'API renvoie un 429 |
| Pas de retry | Panne permanente | Payload 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étrique | Sain | Attention | Critique |
|---|---|---|---|
| Profondeur de queue | < 100 | 100-1000 | > 1000 |
| Temps de traitement (p95) | < 5s | 5-30s | > 30s |
| Taux d'échec | < 1% | 1-5% | > 5% |
| Nombre de dead letters | 0 | 1-10 | > 10 |
| Consumer lag (Kafka) | < 1000 | 1K-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
-
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.
-
Pas de source tracking sur la sync bidirectionnelle. Sans headers
x-source, la synchronisation bidirectionnelle entre deux systèmes boucle indéfiniment. -
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.
-
Retries infinis. Fixe un nombre maximum de retries. Après épuisement, redirige vers la dead letter queue. Ne retente jamais indéfiniment.
-
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.
-
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.
-
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.
-
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
Guides connexes
Concurrence et intégrité des données : les patterns qui ont sauvé notre production
Patterns de concurrence en production pour systèmes d'entreprise. Field ownership, verrouillage optimiste, baux coopératifs, stores d'idempotence, gestion des versions, et couches de gouvernance transactionnelle.
Lire le guideConcevoir des Systemes pour la Panne (Parce qu'Ils Vont Tomber en Panne)
Patterns de reponse aux pannes pour les systemes en production. Circuit breakers, strategies de retry, degradation gracieuse, dead letter queues, budgets de timeout et chaos engineering pour petites equipes.
Lire le guideCommerce Multi-Canal : L'Architecture d'un Checkout Unifié à Travers Cinq Fournisseurs
Comment construire un checkout unifié à travers plusieurs fournisseurs. Synchronisation bidirectionnelle, proxy de disponibilité en temps réel, scoping des canaux, routage des commandes et gestion des erreurs quand les fournisseurs tombent en plein checkout.
Lire le guidePrê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