Symfony Messenger Transports im Vergleich: AMQP vs. Doctrine vs. Redis für Produktions-SaaS

#symfony-messenger-transport-vergleich
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Jedes Symfony-Messenger-Setup beginnt mit einer Zeile Konfiguration: dem Transport-DSN. Diese eine Zeile entscheidet mehr über Ihr Produktionsverhalten als der meiste Code drumherum. Sie bestimmt, ob Nachrichten einen Crash überleben, ob dringende Jobs die Warteschlange überholen können und was zuerst bricht, wenn der Traffic in die Höhe schießt. Dieser Symfony-Messenger-Transport-Vergleich behandelt die drei Optionen, zwischen denen die meisten SaaS-Teams wählen, AMQP via RabbitMQ, Doctrine und Redis, und endet mit einer Entscheidungsmatrix, die darauf basiert, was jeder Transport in Produktion tut, nicht darauf, was seine Dokumentation verspricht.

Die operative Seite von Messenger unter Last, Backpressure, Poison Pills und Rate Limiting, haben wir in Symfony Messenger at Scale behandelt. In diesem Beitrag geht es um die Entscheidung, die davor kommt: welchen Transport man überhaupt betreibt.

Was der Transport tatsächlich entscheidet

Ihre Handler wissen nicht, welcher Transport ihre Nachrichten zugestellt hat. Das ist es wert, früh gesagt zu werden, denn es bedeutet: Die Wahl ist umkehrbar. Doctrine später gegen AMQP zu tauschen ist eine Konfigurationsänderung plus eine Queue-Migration, kein Rewrite. Teams quälen sich mit dieser Entscheidung, als wäre sie permanent. Ist sie nicht.

Was der Transport festlegt, ist operatives Verhalten. Überlebt eine Nachricht einen Worker-Crash, einen Broker-Neustart, einen kompletten Server-Reboot? Kann eine Rechnungs-E-Mail einen nächtlichen Sync-Job überholen? Wie schnell erreichen neue Nachrichten die Worker, und was passiert mit einer Nachricht, die immer wieder fehlschlägt? Und wie viel neue Infrastruktur muss Ihr Team jetzt um 3 Uhr nachts betreiben und überwachen? Das sind die Fragen, bei denen sich die drei Transports wirklich unterscheiden.

Doctrine: die Queue, die Sie schon betreiben

Der Doctrine-Transport speichert Nachrichten in einer Tabelle messenger_messages in Ihrer bestehenden relationalen Datenbank. Für ein Team, das bereits PostgreSQL oder MySQL betreibt, sind die Infrastrukturkosten null. Sie fügen keinen neuen Service hinzu und keinen Fehlermodus, den Ihr Team nicht schon kennt. Backups decken Ihre Queue ab, weil Ihre Queue eine Tabelle ist.

# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: 'doctrine://default?queue_name=async'
                options:
                    redeliver_timeout: 3600

Die Durability ist so stark wie Ihre Datenbank, was üblicherweise die stärkste Garantie ist, die irgendwo in Ihrem Stack verfügbar ist. Wenn Sie innerhalb einer Doctrine-Transaktion dispatchen, committen oder rollbacken Nachricht und Geschäftsdaten gemeinsam, etwas, das Ihnen weder Redis noch RabbitMQ ohne ein zusätzliches Outbox-Pattern geben können.

Die Grenzen zeigen sich unter Volumen. Worker pollen die Tabelle, standardmäßig im Sekundentakt, die Zustelllatenz ist also durch das Polling-Intervall begrenzt. Es gibt keine Nachrichtenprioritäten; der Transport verarbeitet in Einfügereihenfolge pro Queue-Name, und der Workaround mit separaten Queue-Namen und dedizierten Workern ist grob. Das größere Problem ist Write Amplification: Jede Nachricht ist ein INSERT, ein UPDATE beim Claimen und ein DELETE beim Acknowledgen. Bei Zehntausenden Nachrichten pro Tag ist dieser Churn unsichtbar. Bei Hunderttausenden, auf PostgreSQL, setzen die Dead Tuples das Autovacuum ernsthaft unter Druck, und Ihre Queue beginnt, mit Ihrer Anwendung um I/O auf derselben Datenbankinstanz zu konkurrieren.

Unsere Faustregel: Unter etwa 50.000 Nachrichten pro Tag ohne harte Latenzanforderungen ist Doctrine der richtige Default, und alles andere kauft operative Komplexität ein, die Sie noch nicht brauchen.

Redis: schnell, mit Kleingedrucktem bei der Persistenz

Der Redis-Transport baut auf Redis Streams auf. Die Zustellung erfolgt nahezu sofort, weil Worker auf dem Stream blocken, statt eine Tabelle zu pollen, und der Durchsatz übertrifft komfortabel, was der Doctrine-Transport schafft. Wenn Sie Redis bereits für Caching oder Sessions betreiben, sind die zusätzlichen Infrastrukturkosten klein.

framework:
    messenger:
        transports:
            async_fast:
                dsn: 'redis://redis:6379/messages'
                options:
                    stream_max_entries: 100000
                    delete_after_ack: true

Das Kleingedruckte ist die Durability. Redis persistiert nur so gut auf Platte, wie Sie es konfigurieren. Mit dem Standard-RDB-Snapshotting verliert ein Crash alles seit dem letzten Snapshot, was Minuten an bereits angenommenen Nachrichten bedeuten kann. Für eine Queue ist das inakzeptabel, also wird AOF Pflicht: appendonly yes mit appendfsync everysec. Das gesteht bei einem harten Crash immer noch bis zu einer Sekunde an Writes zu, und appendfsync always kostet genug Durchsatz, dass es einen Großteil des Grundes zunichtemacht, aus dem Sie Redis gewählt haben. Managed-Redis-Produkte unterscheiden sich hier stark, prüfen Sie also, was Ihr Anbieter tatsächlich fsynct, bevor Sie ihm Jobs anvertrauen, deren Verlust Sie sich nicht leisten können.

Speicher ist die andere Grenze. Ein Queue-Backlog lebt im RAM, ein Consumer-Ausfall, der bei Doctrine ein langweiliger Vorfall mit wachsender Tabelle wäre, wird bei Redis also zu einem Eviction- oder Out-of-Memory-Vorfall. Begrenzen Sie die Stream-Länge mit stream_max_entries und alerten Sie auf Speicher, nicht nur auf Queue-Tiefe.

Redis passt zu zeitkritischer Arbeit, bei der ein seltener Verlust überlebbar ist oder AOF bewusst konfiguriert wurde: Notification-Fan-out, Cache Warming, Webhook-Retries, bei denen die Upstream-Quelle der Wahrheit ein Replay erlaubt.

AMQP: die fähigste Option, und ein weiterer Service im Betrieb

RabbitMQ über den AMQP-Transport ist die einzige der drei Optionen, die als Message Broker gebaut wurde. Er pusht Nachrichten zu den Consumern, statt gepollt zu werden, unterstützt Prioritäten pro Nachricht nativ, routet über Exchanges und Binding Keys und erledigt Dead-Lettering im Broker selbst: Eine abgelehnte Nachricht wandert in einen Dead Letter Exchange mit eigenem Routing, ganz ohne Anwendungscode. Consumer Prefetch gibt Ihnen echte Backpressure-Kontrolle, und Quorum Queues geben Ihnen replizierte Durability über mehrere Knoten hinweg.

framework:
    messenger:
        transports:
            async_priority:
                dsn: '%env(RABBITMQ_DSN)%'
                options:
                    exchange:
                        name: app_events
                        type: direct
                    queues:
                        high:
                            binding_keys: [high]
                            arguments:
                                x-max-priority: 10

Der Preis dafür: RabbitMQ ist ein Service mit eigener operativer Oberfläche: Speicher-Alarme der Erlang-VM, Disk Watermarks, Versions-Upgrades, Connection Churn durch das Prozessmodell von PHP und Clustering-Entscheidungen, sobald Sie Hochverfügbarkeit brauchen. Nichts davon ist exotisch, aber jemand im Team ist jetzt dafür verantwortlich. Wenn das niemand sein will, verschiebt ein Managed-Angebot wie CloudAMQP diese Last gegen eine monatliche Gebühr.

AMQP verdient seinen Platz, wenn Sie ein anhaltendes sechsstelliges Tagesvolumen haben, echte Prioritätsklassen, mehrere Consumer mit unterschiedlichen Routing-Anforderungen oder Nicht-PHP-Services, die denselben Broker teilen sollen.

Symfony-Messenger-Transport-Vergleich: die Entscheidungsmatrix

DoctrineRedisAMQP (RabbitMQ)
Neue InfrastrukturKeineKeine, wenn Redis existiertRabbitMQ-Cluster
DurabilityDatenbank-Niveau, transaktional mit Ihren DatenAOF-abhängig, bis zu 1s VerlustfensterBroker-Niveau, Quorum Queues
PrioritätenNein, nur separate QueuesNein, nur separate StreamsNativ pro Nachricht
ZustelllatenzPolling, etwa 1sNahezu sofortNahezu sofort, Push
Komfortables VolumenBis ~50k Nachrichten/TagHunderttausende pro TagMillionen pro Tag
Backlog-RisikoTable Bloat, Vacuum-DruckRAM-ErschöpfungDisk Watermark
Neue Ops-LastKeinePersistenz-TuningEin echtes zweites System

Lesen Sie die Tabelle von oben nach unten gegen Ihre Anforderungen. Die meisten Teams landen zuerst bei Doctrine, ergänzen als Zweites Redis für eine latenzsensible Queue und führen RabbitMQ erst ein, wenn Volumen oder Routing-Komplexität es erzwingen. Zwei Transports parallel zu betreiben ist normal und gut unterstützt. Insbesondere der Failure-Transport sollte üblicherweise auf Doctrine bleiben, egal was den heißen Pfad bedient, denn fehlgeschlagene Nachrichten sollen alles überleben außer dem Verlust der Datenbank.

framework:
    messenger:
        failure_transport: failed
        transports:
            failed:
                dsn: 'doctrine://default?queue_name=failed'
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2
                    max_delay: 60000

Was auch immer Sie wählen: Überwachen Sie das Alter der Queue, nicht nur die Tiefe. Eine Queue mit 10.000 frischen Nachrichten ist ein geschäftiger Nachmittag. Eine Queue, deren älteste Nachricht 40 Minuten alt ist, ist ein Incident. Exportieren Sie beides nach Prometheus: eine Query aus count(*) plus min(created_at) für Doctrine, XLEN und die älteste Entry-ID für Redis und das Plugin rabbitmq_prometheus für AMQP.

Wählen Sie für Ihre echte Last, nicht für die eingebildete

Der häufigste Fehler, den wir in Code-Audits sehen, ist ein Transport, der für Traffic gewählt wurde, der nie kam: ein RabbitMQ-Cluster mit drei Knoten, der 8.000 Nachrichten am Tag päppelt, betrieben von einem Zwei-Personen-Team. Der zweithäufigste ist das Gegenteil, eine Doctrine-Queue, die still und leise eine Produktionsdatenbank degradiert, weil niemand die Entscheidung überprüft hat, nachdem sich das Volumen verzehnfacht hat.

Messen Sie Ihr echtes Tagesvolumen, schreiben Sie Ihre tatsächliche Latenzanforderung auf und wählen Sie den günstigsten Transport, der beides erfüllt. Prüfen Sie einmal im Jahr neu. Die Konfigurationsänderung ist der einfache Teil.

Wenn Sie ein zweites Paar Augen für Ihr Messenger-Setup wollen oder Sie ein SaaS-Backend bauen und die Queue-Architektur von Anfang an richtig aufsetzen wollen, schreiben Sie an hello@wolf-tech.io oder besuchen Sie wolf-tech.io. Wir sagen Ihnen, ob Ihre Queue langweilig ist, und genau das sollte eine Queue sein.