Event-Driven Architecture in der Praxis: Was wirklich schiefgeht
Echte Event-Driven-Architecture-Muster aus der Produktion. Event Storms, bidirektionale Sync-Schleifen, Dead Letters, Idempotency Stores und die Wahl zwischen Kafka, RabbitMQ, BullMQ und Symfony Messenger.
Der Event Storm, mit dem niemand rechnet
Event-Driven Architecture sieht auf dem Whiteboard sauber aus. Service A emittiert ein Event. Service B konsumiert es. Lose Kopplung. Unabhaengige Skalierung. Huebsche Diagramme.
Dann deployst du in die Produktion. Ein einzelnes Produkt-Save loest 8 Event Subscriber aus. Jeder Subscriber dispatcht 3 asynchrone Messages. Jeder Message Handler laedt das Produkt, aendert ein Feld und speichert. Jeder Save loest erneut 8 Subscriber aus. Innerhalb von Sekunden hat deine Message Queue tausende Nachrichten fuer ein einziges Produkt-Update, deine Worker sind ausgelastet und deine Datenbank kaempft mit Lock Contention.
Wir haben vier verschiedene Event-Driven-Systeme in der Produktion betrieben. Jedes nutzte einen anderen Message Broker. Jedes hatte andere Fehlermuster. Dieser Artikel behandelt, was tatsaechlich schiefgeht und wie du es behebst. Fuer den groesseren Kontext, wie wir Systemarchitektur angehen, deckt dieser Guide unsere Methodik ab.
Die vier Fehlermuster
Jedes Event-Driven-System trifft frueher oder spaeter auf diese Probleme:
| Muster | Was passiert | Schweregrad |
|---|---|---|
| Event Storm | Eine Aktion kaskadiert in tausende Messages | Kritisch (kann Infrastruktur ueberlasten) |
| Bidirektionale Sync-Schleife | System A synchronisiert zu B, B synchronisiert zurueck zu A, Endlosschleife | Kritisch (unendliches Message-Wachstum) |
| Doppelte Verarbeitung | Gleiche Message wird zweimal verarbeitet, erzeugt doppelte Daten | Hoch (Datenintegritaet) |
| Dead Letter Akkumulation | Fehlgeschlagene Messages haeufen sich ohne Loesungsstrategie an | Mittel (operativer Schuldenaufbau) |
1. Event Storms
Das haeufigste und gefaehrlichste Muster. Es tritt auf, wenn Event Handler neue Events ausloesen und diese Events weitere Handler triggern.
Product save
-> EventSubscriber A dispatcht MessageA
-> EventSubscriber B dispatcht MessageB
-> EventSubscriber C dispatcht MessageC
-> ...8 Subscriber insgesamt
WorkerA verarbeitet MessageA
-> Aendert Produkt, ruft save() auf
-> Product save triggert erneut 8 Subscriber
-> Jeder dispatcht weitere Messages
Ergebnis: 1 Save -> 8 Messages -> 8 Saves -> 64 Messages -> ...
Die Loesung: EventSubscriberSupervisor. Eine prozessbezogene Steuerung, die Event Subscriber waehrend Worker-Saves deaktiviert.
// Worker Handler: Subscriber vor dem Speichern deaktivieren
async function handleGenerateThumbnail(message: ThumbnailMessage) {
const product = await productService.findById(message.productId);
subscriberSupervisor.disableAll();
try {
product.thumbnail = await generateThumbnail(product);
await product.save(); // Keine Subscriber feuern, keine neuen Messages
} finally {
subscriberSupervisor.enableAll();
}
}
Die Reihenfolge ist entscheidend. Der Save muss innerhalb des deaktivierten Bereichs stattfinden. Wenn du die Subscriber vor dem Speichern wieder aktivierst, loest der Save die Kaskade aus.
Fuer einen tieferen Blick darauf, wie wir dieses Muster speziell in Pimcore anwenden, siehe unseren Pimcore-Workflow-Design-Guide.
2. Bidirektionale Sync-Schleifen
Wenn zwei Systeme Daten bidirektional synchronisieren, loest jedes Update von A einen Sync zu B aus, der wiederum einen Sync zurueck zu A ausloest. Ohne Schleifenverhinderung laeuft das endlos.
System A (Commerce) System B (Operations)
│ │
│ Kunde aktualisiert │
│ ────────────────────────▶ │
│ │ Sync empfangen, Kunde speichern
│ │ Kunde-aktualisiert-Event feuert
│ Sync empfangen │
│ ◀──────────────────────── │
│ Kunde speichern │
│ Kunde-aktualisiert-Event │
│ ────────────────────────▶ │
│ ...endlos... │
Die Loesung: Source Tracking. Jede Message traegt einen x-source Header, der das Ursprungssystem identifiziert. Wenn ein System eine Message von sich selbst erhaelt (ueber das andere System), ignoriert es sie.
// Beim Veroeffentlichen einer Sync-Nachricht
async function publishCustomerSync(customer: Customer, source: string) {
await messageQueue.publish('customer.updated', {
customerId: customer.id,
data: customer,
source: source, // "commerce" oder "operations"
});
}
// Beim Konsumieren einer Sync-Nachricht
async function handleCustomerSync(message: CustomerSyncMessage) {
// Messages ignorieren, die von diesem System stammen
if (message.source === THIS_SYSTEM_ID) {
logger.debug('Ignoriere selbst-originierte Sync', { customerId: message.customerId });
return; // ACK der Message, nicht verarbeiten
}
const customer = await customerService.update(message.customerId, message.data);
// Beim Speichern die Source setzen, damit Downstream-Events sie weitertragen
await publishCustomerSync(customer, THIS_SYSTEM_ID);
}
Ein alternativer Ansatz: Message Deduplizierung per Content Hash. Hashe die Message Payload und pruefe, ob der gleiche Hash kuerzlich verarbeitet wurde. Wenn sich die Daten nicht geaendert haben, ist der Sync ein No-Op.
3. Doppelte Verarbeitung
Netzwerkausfaelle, Worker-Neustarts und At-Least-Once-Delivery-Garantien bedeuten, dass dieselbe Message mehr als einmal zugestellt werden kann. Ohne Idempotenz bekommst du doppelte Bestellungen, doppelte E-Mails, doppelte Datensaetze.
Die Loesung: Idempotency Stores mit Business-Key-Deduplizierung.
// Idempotency Store mit Business Key
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), // 24h TTL
});
return true; // Erfasst, Verarbeitung fortsetzen
} catch (error) {
if (isDuplicateKeyError(error)) {
return false; // Bereits verarbeitet, ueberspringen
}
throw error;
}
}
async complete(key: string, scope: string): Promise<void> {
await this.db.update('idempotency_keys',
{ status: 'COMPLETED', completed_at: new Date() },
{ key, scope }
);
}
}
// Verwendung in einem 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('Doppelte Benachrichtigung uebersprungen', { dedupeKey });
return; // ACK der Message
}
try {
await emailService.send(message);
await idempotencyStore.complete(dedupeKey, 'notification');
} catch (error) {
await idempotencyStore.fail(dedupeKey, 'notification');
throw error; // NACK, Queue soll es erneut versuchen
}
}
Der Dedupe Key muss geschaeftlich sinnvoll sein. Fuer Benachrichtigungen: Empfaenger + Kategorie + Entitaet + Tages-Bucket (verhindert das zweimalige Senden derselben Benachrichtigung an einem Tag, erlaubt es aber am naechsten Tag). Fuer Bestellungen: Tenant + Produkt + Datum. Fuer Imports: Quell-Datensatz-ID + Import-Batch-ID.
4. Dead Letter Handling
Messages, die wiederholt fehlschlagen, landen in einer Dead Letter Queue. Die meisten Teams richten die DLQ ein und ignorieren sie dann. Dead Letters haeufen sich an. Monate spaeter entdeckt jemand tausende unverarbeitete Messages.
// Dead Letter Handler mit Klassifizierung
async function processDeadLetter(message: DeadLetterMessage) {
const failureType = classifyFailure(message.error);
switch (failureType) {
case 'TRANSIENT':
// Netzwerk-Timeout, Service voruebergehend nicht erreichbar
// Erneut in die Queue mit exponentiellem Backoff
await requeue(message, { delay: calculateBackoff(message.retryCount) });
break;
case 'PERMANENT':
// Ungueltige Daten, Schema-Mismatch, Geschaeftsregel-Verletzung
// Loggen, alarmieren, archivieren. Nicht erneut versuchen.
await archiveDeadLetter(message);
await alertOps('Permanenter Fehler in Dead Letter', message);
break;
case 'POISON':
// Message selbst verursacht Abstuerze (fehlerhaftes Payload)
// Sofort archivieren, niemals erneut versuchen
await archiveDeadLetter(message);
await alertOps('Poison Message erkannt', 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';
}
Die zentrale Erkenntnis: Nicht alle Dead Letters sind gleich. Transiente Fehler sollten erneut versucht werden. Permanente Fehler sollten archiviert und untersucht werden. Poison Messages sollten sofort in Quarantaene.
Wie wir Fehlermuster speziell in KI-Systemen behandeln, beschreibt unser Guide zu KI-Fehlermodi.
Einen Message Broker waehlen
Wir haben vier verschiedene Broker in der Produktion eingesetzt. Jeder hat seinen eigenen Sweet Spot.
| Broker | Ideal fuer | Weniger geeignet fuer | Deployment-Komplexitaet |
|---|---|---|---|
| Kafka | Hochdurchsatz-Event-Streaming, Event Sourcing, Replay | Einfache Job Queues, Systeme mit geringem Volumen | Hoch (ZooKeeper/KRaft, Partitions, Consumer Groups) |
| RabbitMQ | Routing, Dead Letters, Priority Queues, komplexe Topologien | Sehr hoher Durchsatz (Millionen/Sek.), Event Replay | Mittel (Clustering, HA Policies) |
| BullMQ (Redis) | Job Queues, verzoegerte Jobs, Rate Limiting, einfache Setups | Komplexes Routing, Message Replay, Multi-Consumer-Muster | Niedrig (nur Redis) |
| Symfony Messenger | PHP/Symfony-Apps, Pimcore, einfacher Async-Dispatch | Nicht-PHP-Oekosysteme, komplexe Broker-Features | Niedrig (nutzt Doctrine, AMQP oder Redis als Transport) |
Wann Kafka die richtige Wahl ist
- Du brauchst Event Replay (Consumer koennen ab jedem Offset erneut lesen)
- Du hast mehrere Consumer Groups, die dieselben Events unterschiedlich verarbeiten
- Der Durchsatz uebersteigt 100K Messages pro Sekunde
- Du betreibst Event Sourcing oder baust eine Change Data Capture Pipeline
- Du brauchst garantierte Reihenfolge innerhalb einer Partition
Wann RabbitMQ die richtige Wahl ist
- Du brauchst komplexes Routing (Topic Exchanges, Header-basiertes Routing)
- Dead Letter Queues mit automatischen Retry Policies sind wichtig
- Message Priority ist relevant (dringende Messages werden zuerst verarbeitet)
- Du brauchst Request-Reply-Muster (RPC ueber Messages)
- Dein System hat mehrere Services in verschiedenen Sprachen
Wann BullMQ die richtige Wahl ist
- Du bist im Node.js/TypeScript-Oekosystem (Vendure, NestJS)
- Job Scheduling mit Verzoegerungen und Cron-Patterns
- Rate Limiting pro Queue oder pro Job-Typ
- Du hast Redis bereits fuer Caching und Sessions
- Dein System ist klein bis mittelgross (unter 10K Messages/Sek.)
Wann Symfony Messenger die richtige Wahl ist
- Du bist im PHP/Symfony/Pimcore-Oekosystem
- Du brauchst einfachen Async-Dispatch fuer Hintergrundaufgaben
- Die Worker-Infrastruktur folgt Symfony-Konventionen (supervisord)
- Transportflexibilitaet (Wechsel zwischen Doctrine, AMQP, Redis ohne Code-Aenderungen)
Fuer unsere Pimcore-Projekte setzen wir Symfony Messenger mit RabbitMQ-Transport ein. Fuer Vendure-Projekte nutzen wir BullMQ. Fuer Hochdurchsatz-Datenaufnahme-Plattformen setzen wir Kafka ein. Die Broker-Wahl folgt dem Oekosystem, nicht umgekehrt.
Message Ordering
Die meisten Teams nehmen an, dass die Nachrichtenreihenfolge wichtig ist. Meistens stimmt das nicht.
| Szenario | Reihenfolge noetig? | Warum |
|---|---|---|
| Benachrichtigungs-E-Mail senden | Nein | E-Mails sind grundsaetzlich ungeordnet |
| Thumbnail generieren | Nein | Die neueste Version gewinnt |
| Suchindex aktualisieren | Nein | Der neueste Stand gewinnt (Eventual Consistency) |
| Zahlungsschritte verarbeiten | Ja | Belastung muss vor Erfassung erfolgen |
| Bestell-Statusuebergaenge | Ja | Versand kann nicht vor Zahlungsbestaetigung erfolgen |
| Event Sourcing Replay | Ja | Events muessen in kausaler Reihenfolge abgespielt werden |
Wenn die Reihenfolge wichtig ist, verwende FIFO Queues (SQS FIFO, Kafka Partitions mit Entity-ID als Key, RabbitMQ mit einzelnem Consumer). Wenn nicht, ist parallele Verarbeitung schneller und einfacher.
// Kafka: Reihenfolge innerhalb einer Partition (mit Produkt-ID als Key)
await producer.send({
topic: 'product-updates',
messages: [{
key: productId, // Alle Updates fuer dasselbe Produkt gehen in dieselbe Partition
value: JSON.stringify(event),
}],
});
// BullMQ: Keine Reihenfolge noetig fuer Thumbnails
await thumbnailQueue.add('generate', { productId }, {
removeOnComplete: true,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
});
Retry-Strategien
Nicht alle Retries sind gleich. Die Strategie haengt vom Fehlertyp ab.
| Strategie | Wann einsetzen | Beispiel |
|---|---|---|
| Sofortiger Retry | Transienter Fehler (Race Condition) | Optimistic Lock Failure |
| Exponentielles Backoff | Service voruebergehend nicht erreichbar | Externer API Timeout |
| Feste Verzoegerung | Rate Limiting | API gibt 429 zurueck |
| Kein Retry | Permanenter Fehler | Ungueltiges Payload, Geschaeftsregel-Verletzung |
// BullMQ Retry-Konfiguration
const queue = new Queue('notifications', {
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 60000, // 1 Min., 2 Min., 4 Min., 8 Min., 16 Min.
},
removeOnComplete: { count: 1000 },
removeOnFail: false, // Fuer Dead Letter Analyse aufbewahren
},
});
Nach Ausschoepfung aller Retries wandert die Message in eine Dead Letter Queue. Versuche niemals unendlich oft. Setze eine maximale Anzahl an Versuchen und behandle den Fehler explizit.
Monitoring von Event-Driven-Systemen
Der schwierigste Teil von Event-Driven-Systemen ist das Debugging. Wenn etwas fehlschlaegt, kann der Fehler 5 Services und 12 Messages von der urspruenglichen Ursache entfernt sein.
Correlation IDs
Jede Message traegt eine Correlation ID, die sie mit der urspruenglichen Aktion verknuepft:
// Urspruenglicher API Request generiert Correlation ID
const correlationId = generateUUID();
// Jede Message in der Kette traegt sie mit
await queue.add('process-order', {
orderId: order.id,
correlationId,
});
// Worker geben sie an Downstream-Messages weiter
async function handleProcessOrder(job) {
const { orderId, correlationId } = job.data;
// ... Bestellung verarbeiten ...
// Downstream-Messages tragen dieselbe Correlation ID
await notificationQueue.add('send-confirmation', {
orderId,
correlationId, // Gleiche ID, rueckverfolgbar bis zum urspruenglichen Request
});
}
Beim Debugging kannst du alle Logs und Events nach Correlation ID abfragen, um die gesamte Kette zu sehen. Fuer mehr zu Distributed-Tracing-Mustern, siehe unseren Guide zu KI-Observability.
Queue Health Metriken
| Metrik | Gesund | Warnung | Kritisch |
|---|---|---|---|
| Queue Depth | < 100 | 100-1000 | > 1000 |
| Verarbeitungszeit (p95) | < 5s | 5-30s | > 30s |
| Fehlerrate | < 1% | 1-5% | > 5% |
| Dead Letter Anzahl | 0 | 1-10 | > 10 |
| Consumer Lag (Kafka) | < 1000 | 1K-10K | > 10K |
Alarmiere bei Queue-Depth-Trends, nicht bei absoluten Werten. Eine Queue Depth von 500, die seit Stunden stabil ist, ist in Ordnung. Eine Queue Depth von 50, die vor einer Stunde 0 war und waechst, ist ein Problem.
Haeufige Fallstricke
-
Keine Subscriber-Kontrolle bei Worker-Saves. Die groesste Ursache fuer Event Storms. Worker-Saves duerfen nicht dieselben Event Subscriber ausloesen, die Arbeit an Worker dispatchen.
-
Kein Source Tracking bei bidirektionaler Synchronisation. Ohne
x-sourceHeaders schleift bidirektionale Synchronisation zwischen zwei Systemen endlos. -
Message ID fuer Idempotenz verwenden. Message IDs aendern sich bei Redelivery in manchen Brokern. Verwende stattdessen Business Keys (Entity-ID + Aktion + Zeit-Bucket).
-
Unendliche Retries. Setze eine maximale Retry-Anzahl. Nach Ausschoepfung verschiebe in die Dead Letter Queue. Versuche niemals endlos.
-
Dead Letter Queues ignorieren. Dead Letters sind operativer Schuldenaufbau. Klassifiziere sie (transient, permanent, poison) und behandle jeden Typ unterschiedlich.
-
Nachrichtenreihenfolge voraussetzen. Die meisten Workloads brauchen keine Reihenfolge. Parallele Verarbeitung ist schneller. Verwende FIFO nur, wenn Statusuebergaenge oder kausale Ordnung es erfordern.
-
Gleiche Retry-Strategie fuer alle Fehler. Ein Timeout braucht exponentielles Backoff. Ein ungueltiges Payload braucht null Retries. Ein Rate Limit braucht eine feste Verzoegerung.
-
Keine Correlation IDs. Ohne sie ist das Debugging einer Fehlerkette ueber 5 Services hinweg unmoeglich.
Wichtigste Erkenntnisse
-
Event Storms sind der gefaehrlichste Fehlermodus. Eine unkontrollierte Save-Kaskade kann deine gesamte Infrastruktur ueberlasten. Der EventSubscriberSupervisor ist nicht optional.
-
Bidirektionale Synchronisation braucht Source Tracking. Jede Message traegt die ID des Ursprungssystems. Wenn du eine Message von dir selbst erhaeltst (ueber das andere System), ignoriere sie.
-
Idempotenz nutzt Business Keys, keine Message IDs. Empfaenger + Kategorie + Entitaet + Tages-Bucket fuer Benachrichtigungen. Tenant + Produkt + Datum fuer Bestellungen. Der Key muss geschaeftlich sinnvoll sein.
-
Dead Letters brauchen Klassifizierung. Transiente Fehler werden erneut versucht. Permanente Fehler werden archiviert. Poison Messages werden in Quarantaene gestellt. Ignoriere niemals die Dead Letter Queue.
-
Der Broker folgt dem Oekosystem. Kafka fuer Hochdurchsatz-Streaming, RabbitMQ fuer komplexes Routing, BullMQ fuer Node.js Job Queues, Symfony Messenger fuer PHP. Waehle nicht einen Broker und baue dann alles drumherum.
-
Die meisten Workloads brauchen keine Reihenfolge. Parallele Verarbeitung ist einfacher und schneller. Verwende FIFO nur, wenn Statusuebergaenge es erfordern.
Wir setzen diese Muster in unseren Custom-Software-Projekten, Data-Engineering-Pipelines und Cloud-Deployments ein. Wenn du ein Event-Driven-System aufbaust oder eines debuggst, das bereits in Schwierigkeiten steckt, sprich mit unserem Team oder fordere ein Angebot an.
Behandelte Themen
Verwandte Guides
Concurrency und Datenintegrität: Die Patterns, die unsere Produktion gerettet haben
Produktions-Concurrency-Patterns für Enterprise-Systeme. Field Ownership, Optimistic Locking, kooperative Leases, Idempotency Stores, Versionsmanagement und Transaction Governance Layers.
Guide lesenSysteme für Ausfälle designen (denn sie werden ausfallen)
Ausfallmuster für Produktionssysteme. Circuit Breaker, Retry-Strategien, Graceful Degradation, Dead Letter Handling, Timeout-Budgets und Chaos Engineering für kleine Teams.
Guide lesenUnternehmenshandbuch zu Agentischen KI-Systemen
Technischer Leitfaden zu agentischen KI-Systemen in Unternehmen. Erfahre mehr ueber Architektur, Faehigkeiten und Anwendungen autonomer KI-Agenten.
Guide lesenBereit, produktionsreife KI-Systeme zu bauen?
Unser Team ist spezialisiert auf produktionsreife KI-Systeme. Lass uns besprechen, wie wir deinem Unternehmen helfen können.
Gespräch starten