Technischer Leitfaden

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.

23. Februar 202618 Min. LesezeitOronts Engineering Team

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:

MusterWas passiertSchweregrad
Event StormEine Aktion kaskadiert in tausende MessagesKritisch (kann Infrastruktur ueberlasten)
Bidirektionale Sync-SchleifeSystem A synchronisiert zu B, B synchronisiert zurueck zu A, EndlosschleifeKritisch (unendliches Message-Wachstum)
Doppelte VerarbeitungGleiche Message wird zweimal verarbeitet, erzeugt doppelte DatenHoch (Datenintegritaet)
Dead Letter AkkumulationFehlgeschlagene Messages haeufen sich ohne Loesungsstrategie anMittel (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.

BrokerIdeal fuerWeniger geeignet fuerDeployment-Komplexitaet
KafkaHochdurchsatz-Event-Streaming, Event Sourcing, ReplayEinfache Job Queues, Systeme mit geringem VolumenHoch (ZooKeeper/KRaft, Partitions, Consumer Groups)
RabbitMQRouting, Dead Letters, Priority Queues, komplexe TopologienSehr hoher Durchsatz (Millionen/Sek.), Event ReplayMittel (Clustering, HA Policies)
BullMQ (Redis)Job Queues, verzoegerte Jobs, Rate Limiting, einfache SetupsKomplexes Routing, Message Replay, Multi-Consumer-MusterNiedrig (nur Redis)
Symfony MessengerPHP/Symfony-Apps, Pimcore, einfacher Async-DispatchNicht-PHP-Oekosysteme, komplexe Broker-FeaturesNiedrig (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.

SzenarioReihenfolge noetig?Warum
Benachrichtigungs-E-Mail sendenNeinE-Mails sind grundsaetzlich ungeordnet
Thumbnail generierenNeinDie neueste Version gewinnt
Suchindex aktualisierenNeinDer neueste Stand gewinnt (Eventual Consistency)
Zahlungsschritte verarbeitenJaBelastung muss vor Erfassung erfolgen
Bestell-StatusuebergaengeJaVersand kann nicht vor Zahlungsbestaetigung erfolgen
Event Sourcing ReplayJaEvents 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.

StrategieWann einsetzenBeispiel
Sofortiger RetryTransienter Fehler (Race Condition)Optimistic Lock Failure
Exponentielles BackoffService voruebergehend nicht erreichbarExterner API Timeout
Feste VerzoegerungRate LimitingAPI gibt 429 zurueck
Kein RetryPermanenter FehlerUngueltiges 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

MetrikGesundWarnungKritisch
Queue Depth< 100100-1000> 1000
Verarbeitungszeit (p95)< 5s5-30s> 30s
Fehlerrate< 1%1-5%> 5%
Dead Letter Anzahl01-10> 10
Consumer Lag (Kafka)< 10001K-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

  1. 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.

  2. Kein Source Tracking bei bidirektionaler Synchronisation. Ohne x-source Headers schleift bidirektionale Synchronisation zwischen zwei Systemen endlos.

  3. Message ID fuer Idempotenz verwenden. Message IDs aendern sich bei Redelivery in manchen Brokern. Verwende stattdessen Business Keys (Entity-ID + Aktion + Zeit-Bucket).

  4. Unendliche Retries. Setze eine maximale Retry-Anzahl. Nach Ausschoepfung verschiebe in die Dead Letter Queue. Versuche niemals endlos.

  5. Dead Letter Queues ignorieren. Dead Letters sind operativer Schuldenaufbau. Klassifiziere sie (transient, permanent, poison) und behandle jeden Typ unterschiedlich.

  6. Nachrichtenreihenfolge voraussetzen. Die meisten Workloads brauchen keine Reihenfolge. Parallele Verarbeitung ist schneller. Verwende FIFO nur, wenn Statusuebergaenge oder kausale Ordnung es erfordern.

  7. Gleiche Retry-Strategie fuer alle Fehler. Ein Timeout braucht exponentielles Backoff. Ein ungueltiges Payload braucht null Retries. Ein Rate Limit braucht eine feste Verzoegerung.

  8. 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

Event-Driven ArchitectureMessage Queues ProduktionEvent SourcingCQRS ProduktionRabbitMQ vs KafkaBullMQEvent StormsIdempotenzDead Letter Queuebidirektionale Synchronisation

Bereit, 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