Symfony Messenger ist eine dieser Komponenten, die in der Demo wunderschön aussehen und in den nächsten achtzehn Monaten still und leise Produktionsnarben sammeln. Die erste Message wird in einem Tutorial dispatched, ein Worker wird mit messenger:consume gestartet, und alle gehen zufrieden nach Hause. Zwei Jahre später hat dasselbe Team 14 Transports, drei Retry-Strategien, eine failed_high-Queue, in die niemand schaut, und einen Slack-Channel, in dem jemand jeden Dienstag fragt, warum der Worker schon wieder OOM ist.
Dieser Essay ist die Punchlist von allem, was ich Teams zu beheben sage, bevor Messenger zu ihrem Generator für Produktionsincidents wird. Er setzt voraus, dass es bei Ihnen schon läuft. Er handelt von der zweiten Meile, nicht von der ersten.
1. Wählen Sie Ihren Transport nach Durabilität, nicht nach Vertrautheit
Der Doctrine-Default-Transport ist in Ordnung für Entwicklung und die kleinsten Produktionslasten. Er hört auf in Ordnung zu sein, sobald Sie mehr als einen Worker, mehr als eine Handvoll Messages pro Sekunde oder eine Datenbank haben, die schon zu tun hat.
Der ehrliche Vergleich:
- Doctrine-Transport. Pro: keine neue Infrastruktur. Contra: Consume-Zyklen nutzen Datenbank-Locking (mit
FOR UPDATE SKIP LOCKED, wo unterstützt, und Fallbacks sonst), das überraschend gut skalieren kann, aber Last auf dieselbe Datenbank legt, die Ihre Anwendung bedient. Akzeptabel bis zu ein paar hundert Messages pro Sekunde auf einem gesunden Postgres. Schmerzhaft danach. - AMQP (RabbitMQ). Pro: zweckgebaut, Fan-out-Exchanges, ausgereiftes Operations-Tooling. Contra: Sie besitzen jetzt einen Broker. Backpressure ist nicht gratis. Dead-Letter-Exchanges sind mächtig, aber leicht falsch zu konfigurieren.
- Redis Streams. Pro: niedrige Latenz, harmoniert mit einem bestehenden Redis-Cache. Contra: Persistenz-Semantik hängt von Ihrer Redis-Konfiguration ab, und “ich habe eine Message verloren” ist ein Debugging-Albtraum, wenn niemand sicher ist, ob
appendfsyncgesetzt war. - Amazon SQS / Google Pub-Sub. Pro: der Broker ist das Problem von jemand anderem. Contra: At-least-once-Delivery ist eine harte Bedingung, Visibility Timeouts sind ein Footgun, und die Symfony-Adapter hinken Features hinterher, die die Plattform anbietet.
Das Muster, das ich am häufigsten sehe, ist ein Team, das den Transport wählt, den es aus einem anderen Grund schon laufen hat. Das ist für die ersten 90% der Fälle in Ordnung. Die anderen 10% sind, wo Sie Durabilitätsgarantien gebraucht hätten, die der gewählte Transport nicht gibt, und das während eines Outages herausgefunden haben.
Entscheiden Sie zuerst über Durabilität. Wenn eine verlorene Message akzeptabel ist, funktioniert alles. Wenn eine verlorene Message ein Problem für Kunden ist, schreiben Sie auf, was Ihr Transport garantiert, und prüfen Sie, ob er die Failure Modes überlebt, die Sie tatsächlich interessieren.
2. Markieren Sie Messages mit Marker-Interfaces, nicht mit Transport-Namen
Ein Muster, das sich jedes Mal auszahlt:
namespace App\Messenger;
interface SyncMessage
{
}
interface AsyncMessage
{
}
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
sync: 'sync://'
routing:
'App\Messenger\AsyncMessage': async
'App\Messenger\SyncMessage': sync
Jetzt ist jede Message ein Interface-Implement vom korrekten Routing entfernt, und der Routing-Block wächst nie über diese zwei Zeilen hinaus. Die Alternative ist, dass der Routing-Block zu einer Wand aus Message-Klassennamen wird, mit einem langsamen Drift, bei dem manche Messages falsch geroutet werden, weil jemand vergessen hat, die Konfiguration anzupassen, als er eine neue Klasse hinzufügte.
Wenn Sie drei Transports brauchen (hohe Priorität, normal, Batch), fügen Sie drei Interfaces hinzu. Der Punkt ist, dass die Routing-Entscheidung eine Eigenschaft der Message ist, neben ihr deklariert, nicht eine Konfigurationsdatei, die jemand zu aktualisieren vergisst.
3. Retries sind eine Strategie, kein Default
Die Default-Retry-Strategie in Symfony sind drei Versuche mit exponentiellem Backoff. Es ist ein vernünftiger Startpunkt für genau eine Form von Failure: ein transienter Downstream, der sich in Sekunden erholt. Die meisten Produktionsfehler haben nicht diese Form.
Die Formen, die ich sehe:
- Transient (Netzwerk-Aussetzer, Dependency-Restarts). Retry hilft. Drei Versuche bei 1s, 5s, 30s fängt es meist auf.
- Persistent (Downstream ist kaputt, die Message wird nie erfolgreich sein). Retry schadet. Sie verbrennen Worker-Kapazität an einer Message, die nie ankommen wird.
- Poison (die Message selbst ist fehlerhaft, der Handler wird ewig werfen). Retry macht das System aktiv schlimmer: Sie reproduzieren denselben Bug dreimal statt einmal und verzögern jede andere Message in der Queue um das Retry-Budget.
Die Lösung ist Retry-Konfiguration pro Message-Typ:
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 5
max_delay: 60000
async_no_retry:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 0
Routen Sie dann Messages, bei denen Retry nie hilft (zum Beispiel “sende diesen exakten Webhook jetzt oder nie”) an async_no_retry. Der Default ist nicht mehr die einzige Option.
Für wirklich nuancierte Fälle implementieren Sie RetryStrategyInterface und entscheiden Retry pro Exception-Typ:
namespace App\Messenger;
use App\Repository\Exception\NotFoundException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Retry\MultiplierRetryStrategy;
use Symfony\Component\Messenger\Retry\RetryStrategyInterface;
final readonly class SmartRetryStrategy implements RetryStrategyInterface
{
public function __construct(
private MultiplierRetryStrategy $fallback,
) {
}
public function isRetryable(Envelope $message, ?\Throwable $throwable = null): bool
{
if ($throwable instanceof NotFoundException) {
return false;
}
return $this->fallback->isRetryable($message, $throwable);
}
public function getWaitingTime(Envelope $message, ?\Throwable $throwable = null): int
{
return $this->fallback->getWaitingTime($message, $throwable);
}
}
Eine NotFoundException aus einem Repository ist fast immer eine Poison-Message: die Entity existiert nicht, Retry wird das nicht ändern. Aussteigen, loggen, weiter.
4. Die Dead-Letter-Queue ist nicht optional
Jeder asynchrone Transport braucht einen konfigurierten Failure-Transport, und jemand muss hineinschauen.
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
failed:
dsn: 'doctrine://default?queue_name=failed'
So weit steht es in den Docs. Was nicht in den Docs steht:
- Setzen Sie einen Alert auf die Failure-Queue-Tiefe. Alles oberhalb eines Schwellenwerts (10? 50? hängt vom Volumen ab) weckt jemanden. Eine Failure-Queue mit 4.000 Messages drin ist keine Queue, sondern eine Gruft.
- Automatisieren Sie das Review fehlgeschlagener Messages. Ein wöchentlicher Cron, der
messenger:failed:showausführt und Anzahl plus Top-Exception-Typen in einen Chat-Kanal postet, hält die Queue davon ab, ein Ort zu werden, an dem Messages vergessen werden. - Seien Sie bewusst beim Retry aus der Dead-Letter-Queue.
messenger:failed:retryohne Filter auszuführen ist ein Rezept dafür, eine alte Poison-Message zu retryen, die genau gleich scheitern und wieder in der Dead-Letter-Queue landen wird. Filtern Sie nach Exception-Typ oder Message-Klasse.
Ein guter Test der Messenger-Reife einer Organisation: fragen Sie, wie viele Messages gerade im Failure-Transport liegen, und wie viele länger als eine Woche dort sind. Wenn niemand es weiß, haben Sie ein Problem, das Sie nicht kennen.
5. Worker sind Prozesse mit Budgets
Ein langlebiger PHP-Worker tut etwas, wofür die Sprache nicht wirklich entworfen wurde. PHPs Request-Lifecycle nimmt einen frischen Prozess pro Request an. Doctrines Identity Map, Hydratoren und Event-Manager erwarten einen kurzlebigen Prozess. Langlebige Worker sammeln Speicher, veraltete Entity-Referenzen und gecachete Metadaten auf eine Weise an, die in Stunde 6 eines 24-Stunden-Laufs zubeißt.
Die Verteidigung sind harte Limits, gesetzt auf jedem Worker:
php bin/console messenger:consume async \
--memory-limit=128M \
--time-limit=3600 \
--limit=1000
Was das tut: tötet den Worker nach 128 MB Resident, nach 1 Stunde Wandzeit oder nach 1.000 Messages, je nachdem was zuerst eintritt. Der Supervisor (systemd, Supervisor, Kubernetes) startet ihn neu. Der frische Prozess hat eine frische Doctrine-Identity-Map und ein sauberes Speicherprofil.
Warum alle drei: Speicherlimits fangen Lecks, Zeitlimits fangen langsame Lecks, die das Speicher-Limit nicht reißen, Message-Count-Limits fangen Probleme, die mit Messages statt mit Zeit skalieren.
Im Handler selbst sind dann zwei Muster wichtig:
namespace App\Messenger;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class ProcessReportHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
private ReportRepositoryInterface $reports,
) {
}
public function __invoke(ProcessReport $message): void
{
$report = $this->reports->get($message->reportId);
// ... Verarbeitung ...
$this->entityManager->flush();
$this->entityManager->clear();
}
}
clear() nach flush() gibt Entity-Referenzen frei, die die Identity Map hält. Ohne das wächst die Identity Map mit jeder verarbeiteten Message, bis der Worker an Speichermangel stirbt. Mit dem Aufruf kann der Worker Tausende von Messages verarbeiten.
6. Idempotenz ist Ihr Problem, nicht das von Messenger
Fast jeder Transport gibt Ihnen At-least-once-Delivery. Das heißt, dieselbe Message kann zweimal verarbeitet werden. Wenn Ihr Handler nicht idempotent ist, wird das Duplikat irgendwann wehtun.
Die Muster, die funktionieren:
Idempotenz-Schlüssel. Betten Sie einen eindeutigen Schlüssel in die Message ein und prüfen Sie ihn beim Handle:
namespace App\Messenger\Notification;
use App\Messenger\AsyncMessage;
use Symfony\Component\Uid\Ulid;
final readonly class SendInvoiceEmail implements AsyncMessage
{
public function __construct(
public string $invoiceId,
public Ulid $idempotencyKey,
) {
}
}
namespace App\Messenger\Notification;
use App\Messenger\Notification\Repository\ProcessedMessageRepositoryInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class SendInvoiceEmailHandler
{
public function __construct(
private ProcessedMessageRepositoryInterface $processed,
private InvoiceMailer $mailer,
) {
}
public function __invoke(SendInvoiceEmail $message): void
{
if ($this->processed->exists($message->idempotencyKey)) {
return;
}
$this->mailer->send($message->invoiceId);
$this->processed->record($message->idempotencyKey);
}
}
Ein Unique-Constraint auf idempotency_key in der Datenbank tut denselben Job mit einem Roundtrip weniger. Der Punkt ist derselbe: State, nicht Hoffnung.
Idempotente Operationen. Besser als Schlüssel zu tracken ist, die Operation selbst idempotent zu machen. UPDATE invoice SET sent_at = NOW() WHERE id = ? AND sent_at IS NULL interessiert es nicht, ob es zweimal aufgerufen wird. Der zweite Aufruf updated null Zeilen.
Das Muster, das ich am stärksten empfehle: Idempotenz an der Grenze. Der Handler darf zweimal aufgerufen werden. Was der Handler an die Außenwelt tut (eine E-Mail senden, eine Karte belasten, an eine Drittpartei-API schreiben) braucht entweder einen deterministischen Input oder einen Check, der Duplikate verhindert.
7. Observability schlägt Hoffnung
Der schnellste Weg zu erfahren, dass Ihr Messenger-Setup kaputt ist, ist es von einem Kunden zu erfahren. Der zweitschnellste ist es von einem Deploy zu erfahren, der plötzlich scheitert. Wie Sie es eigentlich erfahren wollen, ist von einem Dashboard, das seit zwei Stunden schreit.
Minimum viable Observability:
- Queue-Tiefe pro Transport. Ein Dashboard-Graph. Alerts, wenn er länger als die erwartete Verarbeitungszeit über einem Schwellenwert bleibt. RabbitMQ exposed das nativ, Doctrine-Transport braucht einen
SELECT count(*)-Cron, SQS exposedApproximateNumberOfMessages. - Verteilung der Handler-Dauer. Ein Histogramm pro Message-Klasse. Ein Handler, der vor einer Woche 200ms brauchte und heute 4s, ist eine Nachricht, auch wenn er noch erfolgreich ist.
- Failure-Rate pro Message-Klasse. Gesamte Failures über gesamte Handles, pro Typ. Eine Failure-Rate von 0,1% ist normal, 5% ist ein Incident.
- Größe der Dead-Letter-Queue. Schon oben behandelt. Es lohnt sich zu wiederholen, weil Leute die Queue einrichten, nie hineinschauen und sich verbrennen.
Sie brauchen keinen vollständigen APM-Stack dafür. Ein paar Symfony Messenger Middleware-Implementierungen und ein Prometheus-Exporter decken den meisten Wert ab:
namespace App\Messenger\Middleware;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
final readonly class TimingMiddleware implements MiddlewareInterface
{
public function __construct(
private MetricsCollectorInterface $metrics,
) {
}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$start = \microtime(true);
$messageClass = $envelope->getMessage()::class;
try {
$envelope = $stack->next()->handle($envelope, $stack);
$this->metrics->recordSuccess($messageClass, \microtime(true) - $start);
return $envelope;
} catch (\Throwable $exception) {
$this->metrics->recordFailure($messageClass, $exception::class);
throw $exception;
}
}
}
8. Schema-Änderungen brechen Replays
Sobald Sie eine Failure-Queue haben, haben Sie Messages von vor einer Woche, die in der Datenbank liegen, kodiert in dem Symfony-Serializer, der zum Dispatch-Zeitpunkt aktiv war. Wenn Sie eine Message-Klasse ändern (ein Feld umbenennen, einen Typ ändern, ein Nicht-Nullable-Konstruktor-Argument hinzufügen), explodieren Retries auf alten Messages entweder laut oder, schlimmer, gelingen mit subtil falschen Daten.
Die Verteidigungen:
- Behandeln Sie Message-Klassen als Wire-Formate. Sobald eine Message in Produktion ausgeliefert wurde, behandeln Sie sie wie eine externe API. Felder hinzufügen, nicht entfernen. Neue Felder nullable machen. Nicht umbenennen. Nicht umtypen.
- Versionieren Sie Messages explizit, wenn Sie die Form ändern müssen.
SendInvoiceEmailwird zuSendInvoiceEmailV2. Beide Handler existieren für ein Release-Fenster. Alte Messages laufen über den V1-Handler ab. Wenn die Queue leer ist, wird V1 entfernt. - Drainen vor dem Deploy von Breaking Changes. Dispatch stoppen, Worker konsumieren lassen, deployen. Das ist Operations-Arbeit, kein Code-Muster, aber es ist die einfachste Antwort, wenn das Message-Volumen niedrig genug ist.
Versionierung ist, was Teams als Erstes auslassen und als Erstes bereuen. Drei Messages in Produktion nach einem Rename, eine einstündige Debugging-Session später fügt das Team den nächsten zehn Messages Versionierung hinzu.
9. Machen Sie den Bus nicht zum Gott-Objekt
Das Muster, gegen das ich am stärksten zurückdränge: “jede Operation geht über den Bus, einschließlich der nicht-asynchronen.”
Die Begründung klingt verlockend. Ein Muster. Leicht zu lesen. Zukunftssicher.
In der Praxis erzeugt das:
- Synchrone Controller, die für eine 2ms-Operation durch fünf Schichten von Envelope-Wrapping gehen.
- Stack Traces, die in einem Controller starten, durch die Messenger-Middleware gehen, in einem Handler enden, und rückwärts gelesen werden müssen.
- Einen Bus, der den Job dreier verschiedener Dinge erledigt (Request-Handling, Command-Handling, Event-Publishing) und alle verwirrt, was was ist.
Die Variante, die funktioniert: asynchrone Arbeit geht über den Bus, synchrone Arbeit bleibt als Service. Ein Controller ruft einen Service. Ein Service tut die synchrone Arbeit. Wenn ein Teil dieser Arbeit asynchron sein muss, dispatched der Service eine Message. Der Bus ist für das Überqueren der synchron/asynchron-Grenze, nicht für alles.
10. Die Checkliste der ersten 90 Tage
Wenn Sie sechs Monate in Messenger sind und das Gewicht spüren, ist das die Punchlist, die ich mit Teams durchgehe:
- Jeder Transport hat einen
failure_transportkonfiguriert. - Failure-Queue-Tiefe wird grafisch dargestellt und alarmiert.
- Worker laufen mit
--memory-limit,--time-limitund--limit. - Handler rufen
EntityManagerInterface::clear()nachflush()auf. - Pro-Message-Retry-Strategien existieren für die Message-Typen, bei denen der Default falsch ist.
- Marker-Interfaces (
SyncMessage,AsyncMessage) treiben das Routing, nicht eine Wand aus Klassennamen in YAML. - Idempotenz ist pro Message-Klasse dokumentiert, mit einer schriftlichen Antwort auf “was, wenn das zweimal läuft?”.
- Mindestens eine Middleware erfasst Handler-Dauer und Failure-Rate pro Message-Klasse.
- Message-Klassen haben eine dokumentierte Versionierungs-Policy.
- Das Team ist sich einig, dass der Bus für das Überqueren von Async ist, nicht für jeden Controller.
Die meisten davon sind ein Nachmittag Arbeit jeweils. Alle zusammen ändern den Failure-Modus von “es vom Kunden erfahren” zu “es vom Graphen erfahren”, und das ist die einzige Progression, die wirklich zählt.
Wenn Ihr Messenger-Setup anfängt, sich wie ein Ort anzufühlen, an dem Messages verschwinden, enthält unser Scaling-Engagement ein Messenger-Production-Review, das diese Checkliste gegen Ihre Transports, Alerts und Handler-Code durchgeht und eine priorisierte Liste von Fixes produziert, die Sie in zwei Wochen ausliefern können.
Referenzen
- Symfony Messenger Dokumentation : die offizielle Komponentenreferenz mit Transports, Middleware und Retry-Strategien.
RetryStrategyInterface: das Interface, das implementiert wird, wenn die exponentielle Default-Strategie nicht ausreicht.- Doctrine SKIP LOCKED Dokumentation : einer der Postgres-Locking-Modi, die der Doctrine-Transport nutzt, wenn die Plattform ihn unterstützt.
- RabbitMQ Dead-Letter-Exchanges : das Broker-Level-Dead-Letter-Feature, nützlich, wenn der Failure-Transport nicht reicht.
- Amazon SQS Visibility Timeout : das SQS-Konzept, das auf Messengers Redelivery abbildet und auf AWS die häufigste Quelle doppelter Verarbeitung ist.