Blog
Symfony Messenger — Wenn dieselbe Nachricht zweimal verarbeitet wird
Ein Worker kann die fachliche Operation abschließen und die Nachricht trotzdem erneut erhalten. Entscheidend ist nicht die Annahme ohne Duplikate, sondern sichere Wiederholung.
Die Bestellung ist bearbeitet. Warum kommt die Nachricht zurück?
Nehmen wir eine Anwendung, die auf OrderCreated hin eine Bestätigung versendet, eine Rechnung erstellt und die Bestellung aktualisiert. Der E-Mail-Anbieter nimmt die Nachricht an, doch der Worker beendet sich, bevor er dem Transport den Abschluss bestätigt. Die Nachricht kann erneut zugestellt werden, obwohl ein Teil der Arbeit bereits erledigt ist.
Das ist ein hypothetisches Beispiel, kein Bericht über einen GiSoft-Vorfall. Es zeigt eine wesentliche Schwierigkeit: Anbieter, Anwendungsdatenbank und Nachrichtentransport kennen jeweils unterschiedliche Teile des Ergebnisses. Eine fehlende Bestätigung macht weder eine versandte E-Mail noch eine bereits gespeicherte Rechnung rückgängig.
Bei einem asynchronen Transport, der unbestätigte Nachrichten erneut zustellt, kann diese Lücke so aussehen:
Zeit Fachlicher Effekt Transport
A Anbieter nimmt E-Mail an In Bearbeitung
B Worker stoppt Keine Bestätigung
C Effekt besteht weiter Erneute Zustellung
möglichDer genaue Ablauf hängt vom Transport und seiner Konfiguration ab. Ein Verbindungsabbruch, eine abgelaufene Unsichtbarkeitsfrist oder ein Zeitlimit für die erneute Zustellung können unterschiedliche Folgen haben. Messenger bietet nicht für jeden Transport einschließlich synchroner Verarbeitung dieselbe Zustellgarantie.
Auch ein Wiederholungsversuch nach einer Exception im Handler und die erneute Zustellung nach dem Ausfall eines Workers sind verschiedene Mechanismen. Beide können dieselbe fachliche Operation nochmals ausführen.
Teilerfolg beim E-Mail-Versand und Rechnungsexport
Die folgenden Ausschnitte verwenden hypothetische Projektdienste. Konstruktoren, Eigenschaftsdeklarationen, Nachrichtenklassen und die Registrierung der Handler in Messenger sind ausgelassen. Methoden wie sendOrderConfirmation() und transaction->run() sind Anwendungsverträge, keine APIs von Symfony oder einem Anbieter-SDK.
Dieser Handler für Bestellbestätigungen enthält keinen Duplikatschutz:
final class SendOrderConfirmationHandler
{
public function __invoke(SendOrderConfirmation $message): void
{
$order = $this->orders->get($message->orderId);
$this->mailer->sendOrderConfirmation($order);
}
}Dabei wird angenommen, dass orders->get() eine vorhandene Bestellung liefert oder eine Exception auslöst und der E-Mail-Adapter den Anbieter direkt aufruft. Stellt er lediglich eine weitere Nachricht in eine Warteschlange, verlagert sich die Unsicherheit auf deren Empfänger.
Eine lokale Kennzeichnung als „gesendet“ schließt diese Lücke nicht allein. Erfolgt sie vor dem Versand, kann eine nie übermittelte E-Mail übersprungen werden; erfolgt sie danach, bleibt ein Zeitfenster für ein Duplikat. Ein Idempotenzmechanismus des Anbieters kann wiederholte Versandaufträge unter dessen dokumentierten Bedingungen erkennen. Das garantiert nicht allgemein, dass genau eine E-Mail beim Empfänger ankommt.
Beim Rechnungsexport entsteht eine ähnliche Situation:
public function __invoke(CreateInvoice $message): void
{
$invoice = $this->invoiceService->create($message->orderId);
$this->externalAccounting->send($invoice);
$this->repository->markAsExported($invoice);
}Die Buchhaltung kann die Rechnung annehmen, bevor markAsExported() fehlschlägt. Sieht die konfigurierte Strategie einen weiteren Versuch vor, beginnt der Handler von vorn, nicht in der letzten Zeile. Eine bereits vorhandene lokale Rechnung verhindert möglicherweise einen zweiten lokalen Datensatz, aber nicht automatisch einen zweiten Beleg im Buchhaltungssystem. Der Export braucht eine eigene stabile Identität und eine Möglichkeit, das externe Ergebnis zu prüfen.
Die Operation identifizieren, nicht den Wiederholungsversuch
Vier Dinge sind zu unterscheiden: die Transportnachricht, die beabsichtigte fachliche Operation, ein Ausführungsversuch und die entstandene Wirkung. Zwei Nachrichten können dieselbe Operation anfordern. Umgekehrt kann eine Bestellung mehrere unterschiedliche, berechtigte Operationen benötigen.
Idempotenz bedeutet hier, dass die Wiederholung derselben Operation keine zusätzlichen unbeabsichtigten fachlichen Effekte erzeugt. Der PHP-Code muss dafür nicht physisch genau einmal laufen.
Betrachten wir diesen unvollständigen Zahlungsaufruf:
$paymentGateway->charge($order->getAmount());Die Methode gehört zu einem hypothetischen Adapter. Währung, Autorisierung und Duplikatschutz sind nicht dargestellt. Ein Schlüssel für eine autorisierte Zahlungsoperation könnte charge-OP-2026-00045 lauten. Diese Identität wird beim Anlegen der Operation gespeichert und für Wiederholungen, erneute Zustellungen und manuelle Wiederaufnahme beibehalten. Der Wiederholungszähler des Transports darf nicht Teil eines jeweils neu erzeugten Schlüssels werden.
Ein neuer, gesondert autorisierter Zahlungsversuch benötigt eine andere Operations-ID. Ein ungewisser Ausgang rechtfertigt für sich genommen noch keine neue Operation. Ebenso bezeichnet invoice-order-123 die beabsichtigte Rechnung nur dann eindeutig, wenn das Geschäftsmodell eine solche Rechnung pro Bestellung vorsieht; reservation-928 kann eine bestimmte Bestandsreservierung identifizieren.
Relevant sind der Gültigkeitsbereich des Schlüssels beim Anbieter, seine Aufbewahrungsdauer, Parameterprüfungen und Möglichkeiten zur Ergebnisabfrage. Eine zufällige ID kann geeignet sein, wenn sie einmal für die Operation gespeichert und wiederverwendet wird. Eine neue ID bei jedem Versand verhindert dagegen die Duplikaterkennung. Auch lokale Nachweise müssen so lange aufbewahrt werden, wie eine erneute Ausführung berücksichtigt werden soll. Eine Operations-ID muss an Zweck, Entität und relevante Parameter gebunden sein. Dieselbe ID mit einer anderen Anforderung ist ein Konflikt, kein erfolgreich behandeltes Duplikat.
Lokale Transaktionen brauchen einen Duplikatschutz
Auch eine reine Datenbankoperation kann zweimal wirken:
$stock->decrease(1);
$entityManager->flush();Hier ist $stock eine verwaltete Bestandsentität; decrease(1) verringert die Stückzahl. Aus zehn können nach zwei bestätigten Ausführungen acht statt neun werden. Jede Transaktion kann für sich korrekt sein. flush() überträgt Änderungen an die Datenbank; bei einer umschließenden Transaktion steht deren endgültiger Commit weiterhin aus.
Der folgende GenerateInvoiceHandler prüft auf Duplikate bei aufeinanderfolgenden Ausführungen. Er ist keine vollständige, nebenläufigkeitssichere Implementierung:
final class GenerateInvoiceHandler
{
public function __invoke(GenerateInvoice $message): void
{
$this->transaction->run(function () use ($message): void {
$operationId = $message->operationId;
if ($this->processedOperations->exists($operationId)) {
return;
}
$this->invoiceService->generate($message->orderId);
$this->processedOperations->markProcessed($operationId);
});
}
}In diesem Beispiel darf generate() nur lokale Datenbankänderungen vornehmen. Rechnung und Nachweis der abgeschlossenen Operation müssen über dieselbe transaktionale Verbindung gemeinsam bestätigt oder zurückgerollt werden. Die ausgelassene Implementierung von run() muss diese Grenze tatsächlich sicherstellen und ausstehende ORM-Änderungen vor dem Commit schreiben.
Zwei Worker können gleichzeitig keinen Operationsnachweis finden und beide generate() aufrufen. Die vorgelagerte exists()-Prüfung ist daher eine Optimierung, keine Garantie. Eine mögliche Umsetzung erzwingt einen eindeutigen, nicht nullbaren Operationsschlüssel in der Datenbank. Scheitert das Einfügen des Abschlussnachweises an einem Konflikt, muss die unterlegene Transaktion auch ihre Rechnungsänderungen zurückrollen. Die Duplikatbehandlung erfolgt außerhalb der fehlgeschlagenen Transaktion, mit einem verwendbaren Persistenzkontext und einer Prüfung der bereits bestätigten Operation. Andere Constraint-Verletzungen dürfen nicht als erfolgreich behandelte Duplikate gelten.
Alternativ kommen eine atomare Reservierung der Operation oder geeignete Sperren infrage. Beides ist im Ausschnitt nicht implementiert. Ein Rollback würde auch keinen externen Aufruf zurücknehmen, den generate() bereits ausgeführt hat.
Verlangt das Geschäftsmodell tatsächlich eine Rechnung pro Bestellung, bietet ein Constraint wie invoice.order_id UNIQUE einen zweiten, eigenständigen Schutz. Die Spalte muss NOT NULL sein, und der Eindeutigkeitsbereich muss zur Fachregel passen. Teilrechnungen oder Rechnungskorrekturen können eine andere Regel erfordern. Der Constraint schützt lokale Datensätze, nicht bereits angenommene E-Mails oder Zahlungen.
Gleichzeitige Zahlungsversuche und unklare Ergebnisse
Eine Prüfung am Objekt im Arbeitsspeicher koordiniert keine Worker:
if ($order->isPaid()) {
return;
}
$order->markAsPaid();Der Ausschnitt ändert lokalen Zustand; er belastet weder einen Kunden noch weist er eine Zahlung nach. Soll eine solche Prüfung einen Zahlungsaufruf absichern, können zwei Worker beide „unbezahlt“ lesen, bevor einer die Änderung speichert, und beide eine Belastung anfordern.
Ein atomares bedingtes Update kann einem Worker die Bearbeitung zuweisen:
UPDATE orders
SET payment_started = true
WHERE id = :id
AND payment_started = false;Dieses parametrisierte SQL setzt eine eindeutige Bestell-ID und eine nicht nullbare Boolean-Spalte voraus, deren Ausgangswert false ist. In diesem Verfahren darf nur der Worker fortfahren, dessen Update eine Zeile geändert hat. Null geänderte Zeilen bedeuten nicht „Zahlung erfolgreich“. Die Reservierung muss bestätigt sein, bevor sich der weitere Ablauf darauf stützt; alle übrigen Zahlungsregeln bleiben notwendig.
Das Flag hält weder das externe Ergebnis fest noch stellt es einen ausgefallenen Worker wieder her. Die Wiederaufnahme muss zwischen einem Abbruch vor dem Absenden und einem Abbruch nach der Annahme durch den Anbieter unterscheiden. Einfach das Flag zurückzusetzen und erneut abzubuchen erzeugt das ursprüngliche Risiko wieder. Weiterhin nötig sind ein dauerhafter Operationsnachweis, derselbe Schlüssel beim Anbieter und ein festgelegtes Verfahren zum Abgleich des Ergebnisses.
Auch ein HTTP-Timeout lässt den Ausgang möglicherweise offen. Der Anbieter kann die Operation abgeschlossen haben, bevor seine Antwort verloren ging. Soweit die API dies unterstützt, lässt sich das Ergebnis über die externe Referenz oder Operations-ID abfragen. Weitere Versuche müssen dem Vertrag des Anbieters folgen. Ist keine sichere Klärung möglich, sind automatische Versuche zu stoppen und der Vorgang zur Prüfung weiterzugeben.
Eine normale Doctrine-Transaktion umfasst die beteiligten Datenbankänderungen. Sie rollt nicht gleichzeitig die Zahlungs-API, den Mailserver oder den Broker zurück. Auch eine während des HTTP-Aufrufs gehaltene Datenbanksperre schafft diese Garantie nicht.
Veröffentlichung nach Commit oder über eine Outbox?
Ein unabhängiger asynchroner Transport kann eine Nachricht sichtbar machen, bevor die zugehörige Datenbanktransaktion bestätigt ist. Beispielsweise führt der Erzeuger für eine Order innerhalb einer offenen Transaktion persist() und flush() aus und versendet dann OrderCreated. Ein anderer Worker fragt über eine separate Verbindung ab und sieht die Bestellung noch nicht; der Commit folgt erst danach. Die Sichtbarkeit hängt vom Transport, der Isolation und der tatsächlichen Transaktionsteilnahme ab.
Den Versand bis nach dem Commit aufzuschieben löst dieses Reihenfolgeproblem. Eine nur im Arbeitsspeicher vorgemerkte Nachricht kann aber verloren gehen, wenn der Prozess zwischen Commit und Veröffentlichung stoppt. „Nach der Verarbeitung auf dem aktuellen Bus“ heißt nicht automatisch „nach jeder Datenbanktransaktion“: Reihenfolge der Middleware und Zuständigkeit für die Transaktion sind entscheidend. Symfony dokumentiert die notwendige Anordnung von dispatch_after_current_bus und doctrine_transaction.
Eine Transactional Outbox speichert die Veröffentlichungsabsicht dauerhaft zusammen mit der fachlichen Änderung. Die Darstellung zeigt eine konzeptionelle Grenze, kein ausführbares SQL:
Eine lokale Datenbanktransaktion
Order speichern
OutboxMessage(OrderCreated) speichern
COMMIT
--------------------------------------------
Nach dem Commit
Publisher -> Transport -> Empfänger
Veröffentlichung oder Zustellung wiederholbar
Empfänger schützt seine fachliche OperationBeide Einträge müssen an derselben geeigneten Datenbanktransaktion teilnehmen. Ein separater Publisher liest anschließend bestätigte Einträge und dokumentiert den Veröffentlichungsfortschritt. Stoppt er nach erfolgreichem Versand, aber vor dessen Speicherung, kann er erneut senden. Die Outbox sichert den gemeinsamen Eintrag von Änderung und Veröffentlichungsabsicht. Sie braucht weiterhin einen überwachten Publisher und duplikatsichere Empfänger, keine Annahme einer exakt einmaligen Ausführung.
Fehlergrenzen verständlich halten
Ein Handler mit mehreren Wirkungen lässt sich schwer wiederaufnehmen, wenn der letzte Schritt scheitert:
public function __invoke(OrderCompleted $message): void
{
$this->stock->decrease($message->orderId);
$this->invoice->create($message->orderId);
$this->mailer->sendConfirmation($message->orderId);
$this->crm->notify($message->orderId);
}Hier ist $this->stock ein Dienst für die ganze Bestellung, nicht die zuvor gezeigte Bestandsentität: Das Argument ist eine Bestell-ID. Scheitert der CRM-Aufruf nach erfolgreichen Bestands-, Rechnungs- und E-Mail-Operationen, kann der nächste Versuch alle vier Schritte erneut durchlaufen.
Eine Möglichkeit besteht darin, die erforderlichen lokalen Änderungen zusammen mit Aufträgen für Folgeoperationen zu bestätigen und diese anschließend unabhängig zu verarbeiten. Das kann innerhalb einer Anwendung bleiben. Jeder Schritt benötigt dennoch eine eigene Operationsidentität, Duplikatbehandlung und explizite Abhängigkeiten, falls die Reihenfolge relevant ist. Eine Aufteilung in Klassen allein macht die Verarbeitung nicht sicher.
Wiederholungsversuche sollten begrenzt und auf den Fehler abgestimmt sein. Vorübergehende Nichterreichbarkeit kann einen weiteren Versuch rechtfertigen; ungültige Daten oder eine abgelehnte Autorisierung verlangen meist eine Korrektur. Ein unklarer externer Ausgang verlangt einen Abgleich. Die installierte Messenger-Version und die Ausnahmebehandlung sind zu prüfen: Nicht jeder Wiederholungsmechanismus berücksichtigt zwingend dieselbe Versuchsgrenze.
Ein konfigurierter Fehlertransport nimmt fehlgeschlagene Nachrichten gemäß der Fehlerstrategie auf. Ein solches Ziel existiert nicht automatisch. Eine manuelle Wiederaufnahme führt den Handler erneut aus, statt an der fehlgeschlagenen Zeile fortzusetzen. Zuvor muss geklärt werden, welche früheren Wirkungen bereits eingetreten sind; die fachliche Operations-ID bleibt erhalten.
Eine Operation über Systemgrenzen hinweg untersuchen
Ausgangspunkt ist die Operations-ID, nicht nur die Exception. Ausführungen lassen sich damit dem bestätigten Datenbankzustand, Anbieterreferenzen und der verfügbaren Transporthistorie zuordnen. Erst wenn klar ist, was bereits abgeschlossen wurde, lässt sich über einen weiteren Versuch entscheiden.
Nützliche strukturierte Logfelder sind Nachrichtentyp, fachliche Operations-ID, betreffende Entity-ID, verfügbare Versuchs- oder Wiederholungsdaten, Worker-/Prozesskennung, Beginn und Ende, Transaktionsergebnis und externe Referenz. Der Messenger-Wiederholungszähler muss nicht jede erneute Broker-Zustellung oder manuelle Ausführung erfassen. Angaben zu Commit, Rollback und Empfangsbestätigung benötigen entsprechende Instrumentierung; sie stehen nicht automatisch in jedem Log.
Auch Worker-Neustarts, Speicherlimits, parallel laufende Versionen beim Deployment und Transportzeitlimits sind relevant. Ist das Intervall für eine erneute Zustellung kürzer als die Verarbeitungsdauer, können entsprechende Transporte überlappende Ausführungen zulassen. Kontrolliertes Herunterfahren und unterstützte Keepalive-Einstellungen können das Risiko verringern, ersetzen aber keine Duplikatbehandlung. Die Optionen müssen zur eingesetzten Version und zum Transport passen.
Vollständige Entitäten, Nachrichteninhalte, Zugangsdaten, vollständige Zahlungsdaten und unnötige personenbezogene Daten gehören nicht ins Log.
Wiederholung und Teilerfolg testen
Dieser Pest-Ausschnitt führt dieselbe Operation zweimal nacheinander aus. Die ausgelassene Vorbereitung muss $orderId, $operationId, $handler und $invoiceRepository innerhalb der Testfunktion definieren, mit einer bekannten Bestellung und einer isolierten Datenbank. Nachrichtenklasse und Repository-Methode sind projektspezifisch, keine Pest-Hilfsfunktionen. Benannte Argumente setzen mindestens PHP 8 voraus; die installierte Pest-Version hat eigene Anforderungen.
it(
'erstellt bei Wiederholung keine zweite Rechnung',
function (): void {
// Testdaten und Einrichtung der Abhängigkeiten ausgelassen.
$message = new GenerateInvoice(
orderId: $orderId,
operationId: $operationId,
);
$handler($message);
$handler($message);
$invoices = $invoiceRepository->findByOrderId($orderId);
expect($invoices)->toHaveCount(1);
},
);Der Test prüft eine resultierende Rechnung im untersuchten sequenziellen Fall. Er erfasst weder Transportbestätigungen noch zwei gleichzeitige Transaktionen oder das reale Verhalten eines Anbieters. Die Testumgebung muss Änderungen vertragsgemäß schreiben und die Datenbank abfragen, statt nur eine Collection im Arbeitsspeicher zu untersuchen.
Weitere gezielte Tests sollten folgende Fälle abdecken:
- Zwei Worker mit separaten Datenbankverbindungen und derselben Operations-ID, einschließlich Eindeutigkeitskonflikt und Rollback.
- Ein Testdouble nimmt die Operation an; danach scheitert ein lokaler Schritt. Die Wiederholung muss denselben Schlüssel und den vorgesehenen Abfrage- oder Deduplizierungsweg verwenden.
- Ein unklarer Ausgang nach Timeout sowie manuelle Wiederaufnahme mit der ursprünglichen Operationsidentität.
- Die tatsächliche Fachregel: eine Rechnung, eine beabsichtigte Bestandsminderung oder eine autorisierte Zahlungsoperation.
Ein Testdouble prüft das Protokoll der Anwendung, nicht die Garantien des Anbieters. Vertragstests in einer geeigneten Testumgebung des Anbieters können Integrationsannahmen überprüfen, sind aber kein Nachweis exakt einmaliger Zustellung.
Entscheidend ist ein korrektes fachliches Ergebnis trotz wiederholter Verarbeitung. Dafür braucht es eine eindeutige Operation, lokale Datenregeln und einen Weg, ungewisse externe Ergebnisse zu klären. Eine zweimal verarbeitete Nachricht muss nicht zu einer zweimaligen Belastung des Kunden führen.
Technische Referenzen
- Symfony Messenger 7.4: Transporte, Wiederholungen und Transaktions-Middleware — https://symfony.com/doc/7.4/messenger.html
- Doctrine ORM: Transaktionen und Nebenläufigkeit — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/reference/transactions-and-concurrency.html
- AWS: Transactional Outbox, atomare Speicherung und wiederholte Veröffentlichung — https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html
- Stripe: Beispiel für anbieterspezifische Idempotenzregeln, kein universeller API-Vertrag — https://docs.stripe.com/api/idempotent_requests
