Symfony Messenger Retry-Strategien und Failed-Transport im Detail verstehen
AI generated
SF
{ }
Symfony · Messenger · Queues
Symfony Messenger Retry-Strategien und Failed-Transport im Detail
Wie Symfony entscheidet, ob eine Nachricht erneut versucht wird oder endgültig scheitert

Sobald eine Anwendung Nachrichten asynchron per Symfony Messenger verarbeitet, ist eine fehlschlagende Nachricht keine Ausnahme, sondern der Normalfall: Ein externer Dienst ist kurz nicht erreichbar, eine Datenbank-Verbindung bricht ab, oder ein Worker wird während der Verarbeitung neu gestartet. Wer sich blind auf die Standardeinstellungen verlässt, verliert entweder Nachrichten, weil sie zu früh aufgegeben werden, oder überlastet ein bereits angeschlagenes System mit zu häufigen Wiederholungsversuchen. Dieser Artikel erklärt, wie die Retry-Strategie von Messenger im Detail funktioniert, wie der Failed-Transport als zweite Sicherheitsebene arbeitet, und wie man beide Mechanismen gezielt für den eigenen Anwendungsfall konfiguriert.

14 Min. Lesezeit Messenger · Retry-Strategien Failed-Transport · Commands

1. Warum asynchrone Nachrichten in der Praxis regelmäßig fehlschlagen

Symfony Messenger verarbeitet Nachrichten typischerweise nach dem Prinzip der mindestens-einmal-Zustellung: Eine Nachricht wird solange als unverarbeitet betrachtet, bis der Handler erfolgreich durchläuft und der Worker sie explizit beim Transport bestätigt (acknowledged). Zwischen dem Absetzen und der erfolgreichen Verarbeitung liegt jedoch ein Zeitfenster, in dem beliebig viele Dinge schiefgehen können: Ein externer Payment-Provider antwortet mit einem Timeout, eine Datenbank-Verbindung wird durch einen Netzwerk-Hickup gekappt, oder der Worker-Prozess wird durch ein Deployment neu gestartet, während er gerade eine Nachricht verarbeitet.

Ohne einen eingebauten Wiederholungsmechanismus würde jeder dieser vorübergehenden Fehler dazu führen, dass die Nachricht entweder komplett verloren geht oder der gesamte Worker-Prozess abstürzt und manuell neu gestartet werden muss. Symfony Messenger löst dieses Problem mit einem zweistufigen Sicherheitsnetz: Eine konfigurierbare Retry-Strategie entscheidet nach jedem Fehlschlag, ob und wann ein erneuter Versuch stattfindet, und ein separater Failed-Transport nimmt Nachrichten auf, die auch nach allen Versuchen nicht verarbeitet werden konnten, damit sie für eine spätere manuelle oder automatisierte Behandlung erhalten bleiben.

2. Das RetryStrategyInterface als zentrale Abstraktion

Im Kern jeder Retry-Entscheidung steht das Interface Symfony\Component\Messenger\Retry\RetryStrategyInterface mit zwei Methoden: isRetryable(Envelope $envelope, ?\Throwable $throwable = null): bool entscheidet, ob überhaupt ein weiterer Versuch stattfinden soll, und getWaitingTime(Envelope $envelope, ?\Throwable $throwable = null): int liefert die Wartezeit in Millisekunden bis zum nächsten Versuch. Jeder konfigurierte Transport erhält eine eigene Instanz dieser Strategie, sodass unterschiedliche Warteschlangen unterschiedliche Retry-Regeln haben können.

Nach jedem gescheiterten Verarbeitungsversuch fragt der Worker automatisch diese beiden Methoden ab, bevor er entscheidet, ob die Nachricht mit einem DelayStamp erneut in den Transport eingereiht oder direkt an den Failure-Transport weitergegeben wird. Diese Abfrage geschieht vollständig innerhalb des Messenger-Kerns, sodass Handler-Code selbst nichts von der Retry-Logik wissen muss, es sei denn, er möchte gezielt Einfluss auf sie nehmen, etwa über eine spezielle Exception.

3. Die Standard-Implementierung MultiplierRetryStrategy im Detail konfigurieren

Symfony liefert mit MultiplierRetryStrategy eine Standard-Implementierung, die sich vollständig über vier Parameter in der Transport-Konfiguration steuern lässt: max_retries legt fest, wie oft eine Nachricht maximal erneut versucht wird, delay definiert die Basis-Wartezeit in Millisekunden vor dem ersten Retry, multiplier bestimmt den Faktor, um den sich die Wartezeit bei jedem weiteren Versuch verlängert, und max_delay deckelt diese Wartezeit nach oben, damit sie nicht ins Unbegrenzte wächst.

In der Praxis reicht es fast immer, diese vier Werte direkt in der framework.yaml je Transport zu setzen, ohne eine eigene Klasse zu schreiben. Das folgende Beispiel zeigt eine typische Konfiguration für einen produktiven asynchronen Transport zusammen mit dem zugehörigen Failure-Transport, der separat als eigener Doctrine-basierter Transport definiert wird.


framework:
    messenger:
        failure_transport: failed

        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 4
                    delay: 1000
                    multiplier: 3
                    max_delay: 30000

            failed:
                dsn: 'doctrine://default?queue_name=failed'

        routing:
            App\Message\SendOrderConfirmationMessage: async

4. Wie exponentielles Backoff das System vor Überlastung schützt

Mit den Werten aus dem obigen Beispiel berechnet sich die Wartezeit vor jedem Versuch nach der Formel delay * multiplier^versuchsnummer, gedeckelt durch max_delay: Der erste Retry wartet eine Sekunde, der zweite drei Sekunden, der dritte neun Sekunden, und der vierte würde rechnerisch 27 Sekunden warten, was noch unterhalb der 30-Sekunden-Obergrenze liegt. Erst danach, nach insgesamt vier gescheiterten Versuchen, wandert die Nachricht in den Failed-Transport.

Dieses exponentielle Wachstum ist kein Selbstzweck, sondern schützt aktiv vor einem sogenannten Thundering-Herd-Effekt: Wenn ein externer Dienst kurzzeitig überlastet ist und tausende Nachrichten gleichzeitig scheitern, würden bei einer festen, kurzen Wartezeit alle Nachrichten fast zeitgleich erneut auf den bereits überlasteten Dienst treffen und die Situation weiter verschärfen. Ein wachsender Abstand zwischen den Versuchen gibt dem betroffenen System Zeit, sich zu erholen, bevor die nächste Welle an Anfragen eintrifft.

5. Wie eine Nachricht tatsächlich im Failed-Transport landet

Sobald isRetryable() nach dem letzten erlaubten Versuch false zurückgibt, oder wenn max_retries erreicht ist, verschiebt der Worker die Nachricht automatisch in den unter failure_transport konfigurierten Transport. Dabei reichert Messenger den Envelope mit zusätzlichen Stamps an, unter anderem einem ErrorDetailsStamp, der die Exception-Klasse, die Fehlermeldung und den Zeitpunkt des letzten Fehlschlags enthält, sowie einem RedeliveryStamp, der die Anzahl der bisherigen Versuche dokumentiert.

Wichtig ist, dass der Failed-Transport in der Praxis nicht vom regulären Worker mitverarbeitet wird, sondern als eigenständiger Transport separat konsumiert werden muss, etwa über bin/console messenger:consume failed. Dadurch bleibt die Verarbeitungsgeschwindigkeit des Haupt-Workers unabhängig von der Menge an dauerhaft gescheiterten Nachrichten, und ein Team kann bewusst entscheiden, wann und wie diese Nachrichten erneut angefasst werden.

6. messenger:failed:show, :retry und :remove im Arbeitsalltag

Für den Umgang mit dem Failed-Transport bringt Symfony drei Konsolen-Befehle mit. messenger:failed:show listet alle fehlgeschlagenen Nachrichten mit ID, Klasse und Zeitpunkt auf, und mit einer konkreten ID als Argument (messenger:failed:show 42) zeigt der Befehl zusätzlich den vollständigen Exception-Stacktrace sowie die Anzahl der bisherigen Versuche an, was für die Fehlersuche meist der erste Schritt ist.

messenger:failed:retry nimmt eine oder mehrere IDs entgegen, entfernt die Nachricht aus dem Failed-Transport und stellt sie erneut für eine Verarbeitung im Ursprungs-Handler bereit, sodass sich transiente Fehler nach einer manuellen Ursachenanalyse ohne Code-Änderung erneut versuchen lassen. messenger:failed:remove löscht eine Nachricht dagegen endgültig, was sich anbietet, wenn sich herausstellt, dass die Nachricht wegen fehlerhafter Daten grundsätzlich nie erfolgreich verarbeitet werden kann.

7. UnrecoverableExceptionInterface: Wann ein Fehler niemals retry-fähig ist

Nicht jeder Fehler profitiert von einem erneuten Versuch. Eine Nachricht mit einer ungültigen Bestellnummer oder einer Verletzung einer Geschäftsregel wird beim nächsten Versuch garantiert wieder scheitern, egal wie lange man wartet, und jeder zusätzliche Retry-Zyklus verschwendet nur Zeit und Ressourcen. Für genau diesen Fall bietet Symfony das Interface Symfony\Component\Messenger\Exception\UnrecoverableExceptionInterface, das eigene Exceptions implementieren können, sowie die fertige Klasse UnrecoverableMessageHandlingException als praktischen Standardfall.

Wird innerhalb eines Handlers eine Exception geworfen, die dieses Interface implementiert, ignoriert Messenger die konfigurierte Retry-Strategie vollständig und verschiebt die Nachricht sofort, ohne weiteren Versuch, in den Failed-Transport. Das ist besonders bei Validierungsfehlern und dauerhaft ungültigen Payloads sinnvoll, weil es die durchschnittliche Zeit bis zur sichtbaren Fehlermeldung drastisch verkürzt und unnötige Last durch aussichtslose Wiederholungen vermeidet.

8. Eine eigene Retry-Strategie für spezielle Anforderungen schreiben

Reicht die konfigurierbare MultiplierRetryStrategy nicht aus, etwa weil unterschiedliche Exception-Typen unterschiedlich behandelt werden sollen oder die Wartezeit von einem Rate-Limit-Header eines externen Dienstes abhängen soll, lässt sich eine eigene Klasse schreiben, die RetryStrategyInterface direkt implementiert. Innerhalb von isRetryable() und getWaitingTime() steht der komplette Envelope inklusive aller Stamps zur Verfügung, sodass sich beispielsweise ein RedeliveryStamp auslesen lässt, um die Anzahl bisheriger Versuche in die eigene Logik einzubeziehen.

Registriert wird eine eigene Strategie, indem man sie als regulären Service deklariert und anstelle der Parameter-Konfiguration den Service-Namen unter retry_strategy im jeweiligen Transport einträgt, etwa retry_strategy: App\Messenger\RateLimitAwareRetryStrategy. Symfony ruft diese Strategie dann exakt so auf wie die Standard-Implementierung, sodass sich der restliche Worker-Ablauf nicht ändert.

9. Den Failed-Transport in Produktion sinnvoll überwachen

Ein Failed-Transport, den niemand beobachtet, ist funktional kaum besser als ein Transport, in dem Nachrichten stillschweigend verschwinden: Er wird zwar nicht geleert, aber ohne aktive Überwachung fällt eine wachsende Anzahl gescheiterter Nachrichten oft erst auf, wenn Kunden sich beschweren. In der Praxis hat sich bewährt, die Größe der Failed-Queue regelmäßig über einen Cron-Job oder ein Monitoring-System abzufragen, etwa durch Auswertung der zugrunde liegenden Doctrine-Tabelle oder über ein eigenes Kommando, das bei Überschreiten eines Schwellenwerts einen Alert auslöst.

Zusätzlich empfiehlt es sich, eine feste Routine für die Triage etablierter gescheiterter Nachrichten festzulegen, etwa ein tägliches Review, bei dem transiente Fehler per messenger:failed:retry erneut angestoßen und dauerhaft fehlerhafte Nachrichten nach Dokumentation der Ursache per messenger:failed:remove entfernt werden. So bleibt der Failed-Transport ein aktiv gepflegtes Sicherheitsnetz statt eines stillen Friedhofs verlorener Nachrichten.

Parameter Bedeutung Beispielwert Effekt
max_retries Anzahl Wiederholungsversuche vor dem Failed-Transport 4 Der 5. Fehlversuch landet direkt im Failed-Transport
delay Basis-Wartezeit in Millisekunden vor dem ersten Retry 1000 Erster Retry erfolgt nach 1 Sekunde
multiplier Faktor für das exponentielle Wachstum der Wartezeit 3 Die Wartezeit verdreifacht sich je Versuch
max_delay Obergrenze der Wartezeit in Millisekunden 30000 Wartezeit wird bei 30 Sekunden gekappt
failure_transport Ziel-Transport für endgültig gescheiterte Nachrichten failed Nachricht landet in einer separaten Queue

Mironsoft

Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung

Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?

Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.

Architektur-Review

Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.

Legacy-Modernisierung

Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.

Test- und Qualitätssicherung

PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.

10. Zusammenfassung

Messenger Retry und Failed-Transport: Das Wichtigste auf einen Blick

Retry-Strategie

RetryStrategyInterface entscheidet nach jedem Fehlschlag über weitere Versuche und deren Wartezeit.

Backoff

MultiplierRetryStrategy erhöht die Wartezeit exponentiell und schützt so überlastete Systeme.

Failed-Transport

Nimmt endgültig gescheiterte Nachrichten auf und wird separat per Konsolen-Befehl verwaltet.

Unrecoverable

UnrecoverableExceptionInterface stoppt Retries sofort bei dauerhaft ungültigen Nachrichten.

11. FAQ: Messenger Retry und Failed-Transport: Das Wichtigste auf einen Blick

1Was ist der Unterschied zwischen RetryStrategyInterface und MultiplierRetryStrategy?
RetryStrategyInterface ist die Abstraktion mit den Methoden isRetryable und getWaitingTime, MultiplierRetryStrategy ist die von Symfony mitgelieferte Standard-Implementierung dieses Interfaces, die sich über vier Parameter konfigurieren lässt.
2Was passiert, wenn max_retries auf 0 gesetzt wird?
Dann findet nach dem ersten Fehlschlag kein weiterer Versuch statt, und die Nachricht wandert sofort in den konfigurierten Failure-Transport, sofern einer eingerichtet ist.
3Muss jeder Transport einen eigenen Failure-Transport haben?
Nein, üblicherweise wird ein einziger Failure-Transport global unter failure_transport konfiguriert und von allen Transports gemeinsam genutzt, es ist aber möglich, ihn pro Transport individuell zu überschreiben.
4Wie unterscheidet sich UnrecoverableMessageHandlingException von einer normalen Exception?
Eine normale Exception durchläuft die konfigurierte Retry-Strategie normal, während UnrecoverableMessageHandlingException das Interface UnrecoverableExceptionInterface implementiert und dadurch Retries komplett übergeht, die Nachricht landet sofort im Failed-Transport.
5Kann man die Retry-Strategie pro Nachrichtentyp statt pro Transport konfigurieren?
Direkt nicht, da die Strategie am Transport hängt. In der Praxis löst man das, indem man Nachrichtentypen mit unterschiedlichen Anforderungen über die Routing-Konfiguration auf unterschiedliche Transports mit jeweils eigener Retry-Strategie verteilt.
6Werden Nachrichten im Failed-Transport automatisch gelöscht?
Nein, sie bleiben dauerhaft erhalten, bis sie explizit per messenger:failed:retry erneut verarbeitet oder per messenger:failed:remove gelöscht werden, was eine bewusste Pflege der Queue notwendig macht.
7Verarbeitet der normale Worker auch den Failed-Transport mit?
Nur wenn er explizit dafür gestartet wird, üblicherweise läuft messenger:consume ohne den Namen des Failed-Transports und dieser wird separat über messenger:consume failed konsumiert.
8Wie findet man heraus, warum eine Nachricht im Failed-Transport gelandet ist?
Über messenger:failed:show mit der konkreten ID, das zeigt den vollständigen Exception-Stacktrace, die Fehlermeldung und die Anzahl der bisherigen Verarbeitungsversuche an.
9Lohnt sich eine eigene Retry-Strategie für kleine Projekte?
Meistens nicht, die konfigurierbare MultiplierRetryStrategy deckt die allermeisten Fälle ab. Eine eigene Implementierung lohnt sich erst bei Sonderfällen wie exception-spezifischem Verhalten oder externen Rate-Limit-Vorgaben.
10Was ist der Vorteil von exponentiellem Backoff gegenüber einer festen Wartezeit?
Bei einer festen kurzen Wartezeit treffen bei einem größeren Ausfall alle gescheiterten Nachrichten fast gleichzeitig erneut auf das betroffene System, exponentielles Backoff verteilt die Wiederholungsversuche über einen längeren Zeitraum und gibt dem System Erholungszeit.