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.
Inhaltsverzeichnis
- 1. Warum asynchrone Nachrichten in der Praxis regelmäßig fehlschlagen
- 2. Das RetryStrategyInterface als zentrale Abstraktion
- 3. Die Standard-Implementierung MultiplierRetryStrategy im Detail konfigurieren
- 4. Wie exponentielles Backoff das System vor Überlastung schützt
- 5. Wie eine Nachricht tatsächlich im Failed-Transport landet
- 6. messenger:failed:show, :retry und :remove im Arbeitsalltag
- 7. UnrecoverableExceptionInterface: Wann ein Fehler niemals retry-fähig ist
- 8. Eine eigene Retry-Strategie für spezielle Anforderungen schreiben
- 9. Den Failed-Transport in Produktion sinnvoll überwachen
- 10. Zusammenfassung
- 11. FAQ
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.