Background-Job-Architektur: Queues, Worker und Fehlerbehandlung
Jeder Produktionsvorfall mit "stillen Fehlern", zu dem ich gerufen wurde, laesst sich auf dieselbe Stelle zurueckfuehren: ein Background-Job-System, das auf dem Dashboard in Ordnung aussah, aber wochenlang unbemerkt Arbeit verlor. Das Muster ist fast jedes Mal identisch. Ein Worker stuerzt mitten in einem Task ab, die Nachricht wird erneut zugestellt, der Handler ist nicht idempotent, und eine Kundin bekommt am Ende zwei Rechnungen oder gar keine. Ein Retry-Sturm gegen eine fragile Drittanbieter-API saettigt eine ausgehende IP und loest Rate-Limiting fuer die gesamte Anwendung aus. Ein geplanter Job erzeugt in fuenf Minuten 200.000 Eintraege in der Queue, und die voreingestellte FIFO-Queue sorgt dafuer, dass die dringenden Passwort-Reset-Mails hinter einem Massen-Marketing-Versand feststecken.
Eine solide Background-Job-Architektur bedeutet nicht, eine Queue-Bibliothek auszuwaehlen. Es geht darum, fuer die Fehlerzustaende zu entwerfen, denen Du in Produktion begegnest: Abstuerze, Timeouts, Poison-Messages, Consumer-Saettigung und Bedienfehler. Dieser Beitrag geht die Entscheidungen durch, die wirklich zaehlen, mit konkreten Symfony-Messenger-Beispielen fuer Teams, die PHP in Produktion betreiben.
Warum Background-Job-Architektur tragende Infrastruktur ist
Jede ernstzunehmende Webanwendung muss irgendwann Arbeit ausserhalb des Request-Response-Zyklus erledigen. Transaktionale E-Mails versenden, PDF-Rechnungen erzeugen, mit CRM-Systemen synchronisieren, Bild-Uploads verarbeiten, geplante Exporte ausfuehren - all das gehoert in Background-Worker und nicht in eine blockierte HTTP-Antwort.
Die Versuchung ist, das als geloestes Problem zu behandeln: eine Job-Bibliothek installieren, sie auf Redis zeigen lassen, dispatch() aufrufen und weitermachen. Fuer einen Prototyp funktioniert das. In Produktion bricht es in dem Moment, in dem eines der folgenden Dinge passiert - und irgendwann passieren sie alle. Einem Consumer geht der Speicher aus und er wird vom Kernel beendet, waehrend er eine Reservierung auf einer Nachricht haelt. Ein Redis-Neustart verliert Nachrichten im Flug, die nicht persistiert wurden. Ein Drittanbieter-Webhook-Empfaenger gibt eine Stunde lang 503 zurueck, und naive Retries verstaerken den Ausfall in Deinem eigenen System. Ein Deployment liefert einen kaputten Handler aus, der bei jeder Nachricht eine Exception wirft, und innerhalb von Minuten hat die Queue Millionen geplanter Retries.
Die Teams, die diese Vorfaelle vermeiden, hatten kein Glueck. Sie haben bewusste Architekturentscheidungen getroffen, die leicht zu uebernehmen sind, wenn Du weisst, worauf Du achten musst.
Den richtigen Broker fuer Dein Lastprofil waehlen
Die erste Entscheidung ist, ueber welchen Transport Jobs laufen. Die drei praktischen Optionen fuer die meisten europaeischen SaaS-Teams sind Redis, RabbitMQ und eine datenbankgestuetzte Queue ueber den Doctrine-Transport. Jede hat ein anderes Fehlerprofil, und die richtige Wahl haengt von Deinen Anforderungen an Haltbarkeit und Durchsatz ab.
Redis ist schnell, einfach und die richtige Wahl fuer Arbeit mit hohem Volumen und geringem Risiko - Bildskalierung, Cache-Warming, Befuellung von Analytics-Pipelines. Was Redis ohne sorgfaeltige Konfiguration nicht bietet, ist Haltbarkeit. Ein abgestuerzter Redis-Knoten mit appendonly no verliert jede Nachricht im Speicher. Selbst mit AOF-Persistenz kann eine Nachricht zwischen Enqueue und fsync verloren gehen. Fuer Arbeit, die Du Dir nicht leisten kannst zu verlieren, reicht Redis allein nicht aus.
RabbitMQ ist die pragmatische Standardwahl fuer geschaeftskritische asynchrone Arbeit. Persistente Queues ueberstehen Broker-Neustarts, Publisher Confirms geben Dir At-least-once-Zustellsemantik, und die Management-Oberflaeche ist fuer Operatoren nuetzlich, die Queue-Tiefe und Consumer-Gesundheit inspizieren. Der Betriebsaufwand ist hoeher als bei Redis - Du betreibst eine Erlang-Anwendung, die eigenes Monitoring und Kapazitaetsplanung braucht - aber die Haltbarkeitsgarantien rechtfertigen das fuer die meisten Produktionsfaelle.
Der Doctrine-Transport, der Nachrichten in eine Tabelle Deiner Hauptdatenbank schreibt, ist eine unterschaetzte Option fuer kleine bis mittlere Teams. Der Durchsatz ist begrenzt, aber Du bekommst transaktionales Enqueue gratis: Eine Nachricht, die in derselben Datenbanktransaktion wie die Geschaeftslogik versendet wird, committet entweder beide oder rollt beide zurueck. Kein separater Broker bedeutet keinen separaten Fehlerzustand zu ueberwachen. Fuer Teams, die Tausende statt Millionen Jobs pro Tag verarbeiten, ist diese Einfachheit oft der richtige Kompromiss.
Wenn Deine Anwendung alle drei Lastprofile mischt - manche Jobs sind Fire-and-forget-Analytics, manche sind Finanztransaktionen, die nie verloren gehen duerfen - lautet die richtige Antwort nicht, einen Transport zu waehlen, sondern unterschiedliche Nachrichtenklassen anhand ihrer Haltbarkeitsanforderungen an unterschiedliche Transports zu routen.
Handler entwerfen, die Retries ueberstehen
Die wichtigste Eigenschaft eines Background-Job-Handlers ist Idempotenz: Ihn zweimal auszufuehren muss dasselbe beobachtbare Ergebnis liefern wie ihn einmal auszufuehren. Idempotenz ist nicht verhandelbar, weil At-least-once-Zustellung das Beste ist, was Dein Broker bieten kann. Ein Worker, der eine Nachricht verarbeitet, das Ergebnis in die Datenbank schreibt und dann abstuerzt, bevor er dem Broker bestaetigt, wird diese Nachricht beim Neustart erneut sehen. Wenn Dein Handler bei jeder Ausfuehrung eine E-Mail versendet, bekommt die Kundin zwei. Wenn Dein Handler bei jeder Ausfuehrung eine Kreditkarte belastet, hast Du ein viel groesseres Problem.
Idempotenz umzusetzen bedeutet meist eines von zwei Mustern. Das erste ist die Nutzung eines natuerlichen Idempotenzschluessels aus der Geschaeftsdomaene - einer Order-ID, einer Webhook-Event-ID, einer UUID einer Nutzeraktion - und zu pruefen, ob das Ergebnis bereits existiert, bevor man handelt:
#[AsMessageHandler]
final class ProcessPaymentHandler
{
public function __construct(
private PaymentRepository $payments,
private StripeClient $stripe,
) {}
public function __invoke(ProcessPayment $message): void
{
// Idempotency: skip if we already have a charge for this order
$existing = $this->payments->findByOrderId($message->orderId);
if ($existing !== null && $existing->isCaptured()) {
return;
}
$charge = $this->stripe->charges->create([
'amount' => $message->amountCents,
'currency' => $message->currency,
'idempotency_key' => sprintf('order-%d', $message->orderId),
]);
$this->payments->recordCharge($message->orderId, $charge->id);
}
}
Das zweite Muster ist das Speichern eines Logs verarbeiteter Nachrichten mit Unique Constraints, sodass das Einfuegen derselben Nachrichten-ID zweimal auf Datenbankebene fehlschlaegt und der Handler es als No-op behandeln kann. Beide Ansaetze funktionieren; entscheidend ist, dass die Entscheidung explizit getroffen wird, nicht zufaellig.
Die zweite wichtige Eigenschaft ist begrenzte Ausfuehrungszeit. Ein Handler, der bei einem langsamen HTTP-Aufruf unbegrenzt haengen kann, wird irgendwann Deinen Worker-Pool erschoepfen. Jeder externe Aufruf in einem Handler sollte ein explizites Timeout auf Client-Ebene haben, und Handler sollten generell darauf abzielen, innerhalb von Dutzenden Sekunden fertig zu sein. Jobs, die wirklich Minuten laufen muessen, sollten in kleinere Schritte zerlegt werden, die jeweils schnell abschliessen.
Retry-Strategie: Exponentielles Backoff, keine Endlosschleifen
Ein fehlgeschlagener Job sollte erneut versucht werden - aber nicht sofort und nicht fuer immer. Symfony Messenger stellt pro Transport eine Retry-Strategie bereit, die beide Anliegen bei richtiger Konfiguration standardmaessig korrekt behandelt:
framework:
messenger:
transports:
async_critical:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 5
delay: 2000
multiplier: 3
max_delay: 600000
failure_transport: failed
Diese Konfiguration versucht es bis zu fuenfmal, beginnend mit einer Verzoegerung von 2 Sekunden, die sich bei jedem Versuch verdreifacht (2s, 6s, 18s, 54s, 162s), gedeckelt bei 10 Minuten. Das exponentielle Backoff gibt voruebergehenden Fehlern Zeit zur Erholung, ohne das Problem zu verstaerken, und die Retry-Obergrenze stellt sicher, dass eine wirklich kaputte Nachricht irgendwann der Retry-Schleife in die Dead-Letter-Queue entkommt, statt fuer immer CPU zu verbrennen.
Ein haeufiger Fehler ist, alle Exceptions gleich zu behandeln. Eine NetworkException aus einem API-Aufruf sollte wahrscheinlich erneut versucht werden - der entfernte Dienst ist vielleicht kurz nicht verfuegbar. Eine ValidationException, weil die Nachricht Daten enthaelt, die aus der Datenbank geloescht wurden, sollte wahrscheinlich nicht - kein noch so langes Warten laesst den referenzierten Datensatz wieder erscheinen. Messenger unterstuetzt diese Unterscheidung, indem Handler eine UnrecoverableMessageHandlingException werfen koennen, um eine Nachricht ohne Retry direkt in die Dead-Letter-Queue zu schicken:
if ($order === null) {
throw new UnrecoverableMessageHandlingException(
sprintf('Order %d no longer exists', $message->orderId)
);
}
Exceptions auf diese Weise zu klassifizieren verhindert Retry-Stuerme bei Nachrichten, die nie erfolgreich sein werden, und haelt die Dead-Letter-Queue auf wirklich behebbare Fehler fokussiert.
Dead-Letter-Queues sind nicht optional
Eine Dead-Letter-Queue (DLQ) ist der Ort, an den Nachrichten gehen, wenn sie ihr Retry-Budget erschoepft haben oder als nicht behebbar eingestuft wurden. Eine zu betreiben ist der Unterschied zwischen stillem Datenverlust und einem diagnostizierbaren, behebbaren Fehler.
In der Praxis muessen drei Dinge vorhanden sein. Erstens muss jeder Transport ein failure_transport konfiguriert haben, damit erschoepfte Nachrichten erfasst statt verworfen werden. Zweitens muss die DLQ ueberwacht werden - eine wachsende Dead-Letter-Queue ist ein Produktionsalarm, keine Dashboard-Kuriositaet. Drittens brauchen Operatoren Werkzeuge, um fehlgeschlagene Nachrichten zu inspizieren und erneut zu verarbeiten:
# Inspect failed messages
bin/console messenger:failed:show
# Show a specific failure in detail
bin/console messenger:failed:show 42 -vv
# Retry a failed message after fixing the underlying issue
bin/console messenger:failed:retry 42
# Remove a message that cannot be reprocessed
bin/console messenger:failed:remove 42
Eine DLQ ohne einen menschlichen Prozess, der sie ueberprueft, ist schlimmer als gar keine DLQ - sie schafft falsches Vertrauen, waehrend Daten still in einer Queue auflaufen, die niemand liest. Eine woechentliche oder taegliche DLQ-Durchsicht als Teil der Engineering-Rotation zu etablieren ist genauso wichtig wie die Infrastruktur selbst.
Priorisierung: Lass Marketing-Versaende keine Passwort-Resets aushungern
Die Standardannahme einer einzelnen, FIFO-verarbeiteten Queue bricht in dem Moment zusammen, in dem ein Job mit niedriger Prioritaet hohes Volumen erzeugt. Einen NewsletterSender gegen 200.000 Abonnenten laufen zu lassen sollte nicht die Passwort-Reset-Mail verzoegern, auf die eine Nutzerin gerade jetzt wartet.
Die Loesung sind Priority Queues: Route kritische Nachrichten an einen Transport mit dedizierter Consumer-Kapazitaet und geringerer Queue-Tiefe. In Symfony Messenger ist das eine Routing-Konfiguration:
framework:
messenger:
transports:
critical: '%env(MESSENGER_CRITICAL_DSN)%'
default: '%env(MESSENGER_DEFAULT_DSN)%'
bulk: '%env(MESSENGER_BULK_DSN)%'
routing:
'App\Message\PasswordResetEmail': critical
'App\Message\OrderConfirmation': critical
'App\Message\SyncCrmContact': default
'App\Message\MarketingBlastSegment': bulk
Betreibe dedizierte Worker-Prozesse pro Transport, damit eine Flut von Bulk-Arbeit kritische Consumer nicht blockieren kann:
bin/console messenger:consume critical --limit=1000 --memory-limit=256M
bin/console messenger:consume default --limit=1000 --memory-limit=256M
bin/console messenger:consume bulk --limit=1000 --memory-limit=256M
Diese Trennung ist guenstig einzurichten und verbessert sowohl die durchschnittliche als auch die Tail-Latenz fuer die Arbeit, die Nutzer tatsaechlich bemerken, dramatisch.
Worker-Lebenszyklus: Speicher, Supervision und sauberes Herunterfahren
Worker sind langlaufende PHP-Prozesse, und PHPs Speicherverhalten ueber lange Laufzeiten ist nicht grossartig. Ein Worker sollte regelmaessig neu starten, um Speicher zurueckzugewinnen - die Flags --limit und --memory-limit bei messenger:consume erledigen das, indem sie den Prozess nach N Nachrichten oder M Megabyte sauber beenden. Ein Prozess-Supervisor - systemd, supervisord oder Kubernetes - startet den beendeten Worker dann innerhalb von Sekunden neu.
Sauberes Herunterfahren ist genauso wichtig wie der Neustart. Wenn ein Deployment einen Worker ersetzt, sollte der laufende Prozess seine aktuelle Nachricht beenden und sie dem Broker bestaetigen, bevor er sich beendet, statt sie mitten im Flug zu verwerfen. Messenger behandelt SIGTERM von Haus aus korrekt, aber Dein Orchestrator braucht eine Shutdown-Gnadenfrist, die lang genug fuer den am laengsten laufenden Handler ist - typischerweise sind 30 bis 60 Sekunden sicher.
Den Worker-Pool selbst zu ueberwachen ist die andere Haelfte der Gleichung. Queue-Tiefe, Consumer-Anzahl und Consumer-Lag sind die drei Metriken, die Dir sagen, ob das System gesund ist. Eine Consumer-Anzahl, die waehrend der Geschaeftszeiten auf null faellt, ist ein Pager-Alarm. Eine Queue-Tiefe, die unbegrenzt waechst, ist ein Pager-Alarm. Consumer-Lag ueber zehn Minuten auf einer kritischen Queue ist ein Pager-Alarm. Alles andere ist ein Dashboard. Teams mit solidem Code-Quality-Consulting haben diese Alarme typischerweise in ihre Incident Response eingebunden, bevor das Job-System in Produktion geht, nicht nach dem ersten Post-mortem zu einem stillen Fehler.
Alles zusammensetzen
Ein produktionsreifes Background-Job-System hat fuenf tragende Bestandteile. Transports werden bewusst pro Haltbarkeitsprofil gewaehlt, mit kritischer Arbeit auf RabbitMQ oder Aequivalent statt Redis allein. Handler sind von Grund auf idempotent und werfen nicht behebbare Exceptions fuer Nachrichten, die nicht erfolgreich sein koennen. Retries nutzen exponentielles Backoff mit endlicher Obergrenze, keine Endlos-Retry-Schleifen. Jeder Transport hat eine ueberwachte Dead-Letter-Queue mit einem menschlichen Ueberpruefungsprozess. Und Priority Queues mit getrennten Worker-Pools verhindern, dass Bulk-Arbeit nutzerseitige Jobs aushungert.
Nichts davon erfordert exotische Infrastruktur. Symfony Messenger auf einem korrekt konfigurierten RabbitMQ-Cluster, betrieben mit supervisord und ueberwacht mit Prometheus und Grafana, laeuft auf bescheidener Hardware bequem mit zig Millionen Nachrichten pro Tag. Die Investition steckt im Design, nicht im Werkzeug.
Wenn Dein Team stille Fehler, wachsende Dead-Letter-Rueckstaende oder Worker-Neustarts erlebt, die nie ganz mit der Queue-Tiefe Schritt halten, bietet Wolf-Tech Architektur-Reviews an, die sich speziell auf die Zuverlaessigkeit asynchroner Verarbeitung konzentrieren. Wir haben die gesamte Bandbreite der oben beschriebenen Fehlerzustaende auf echten Produktionssystemen debuggt, oft als Teil umfassenderer Legacy-Code-Optimierung. Erreiche uns unter hello@wolf-tech.io oder besuche wolf-tech.io fuer eine kostenlose Erstberatung.

