Symfony Performance Audit: Profiling, Cache-Tuning und Query-Optimierung Schritt für Schritt

#symfony performance audit
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Ein Symfony Performance Audit ist keine vage "such mal nach langsamen Stellen"-Übung. Es ist eine strukturierte Sequenz: instrumentieren, messen, den Engpass identifizieren, beheben, erneut messen. Überspringst Du einen Schritt, endest Du mit einer Sammlung von Mikro-Optimierungen, die zusammen nichts ergeben, was der Nutzer bemerkt.

Dieser Guide geht diese Sequenz in der Reihenfolge durch, in der wir sie tatsächlich auf Kunden-Codebasen anwenden - von der Installation und dem Lesen von Symfony Profiler und Blackfire über die fünf Probleme, die für den Großteil der gefundenen Verlangsamungen verantwortlich sind, bis zum Vorher-Nachher-Mess-Workflow, der aus "fühlt sich schneller an" eine Zahl macht, die Du einem Stakeholder zeigen kannst.

Profiling einrichten, bevor Du eine Zeile Code anfasst

Jedes Audit beginnt gleich: nicht raten, messen. Der Symfony Profiler ist der Einstiegspunkt, weil er in jedem Symfony-Projekt mit installiertem symfony/profiler-pack bereits vorhanden ist. Falls nicht, füge ihn hinzu:

composer require --dev symfony/profiler-pack

Die Toolbar am unteren Rand jeder Seite liefert einen schnellen Überblick: Zeit, Speicher, Anzahl der Datenbank-Queries. Die wichtigste Zahl in diesem Stadium ist nicht die Gesamtzeit - es ist die Query-Anzahl. Mehr als 20 Queries auf einer typischen Seite sind ein Warnsignal. Mehr als 50 sind ein Problem. Mehr als 100 bedeuten ein N+1-Query-Problem, und es ist vermutlich nicht das einzige.

Für alles jenseits der Toolbar brauchst Du die Profiler-Detailansicht unter /_profiler. Klick Dich zum Doctrine-Panel durch und sortiere die Queries absteigend nach Zeit. Die langsamsten Queries sind nicht immer das Problem - eine Query, die in 2ms läuft, aber 200-mal pro Request ausgeführt wird, ist schlimmer als eine einzelne 50ms-Query, die Du cachen kannst.

Blackfire erweitert das deutlich. Es zeichnet einen vollständigen Call Graph mit exklusiven und inklusiven Zeiten pro Funktion auf - so siehst Du genau, wo die Wall Time tatsächlich verbracht wird, statt sie aus Query-Zahlen abzuleiten.

Installiere die Blackfire-PHP-Extension und den Probe, dann starte ein Profil über die Browser-Extension oder die CLI:

blackfire curl https://yourapp.local/your-slow-route

In der Profil-Timeline verbringst Du die meiste Audit-Zeit. Achte auf drei Dinge: breite horizontale Balken (langsame Funktionen), hohe vertikale Stapel (tiefe Call-Ketten) und wiederholte Muster (dieselbe Funktion viele Male aufgerufen). Letzteres ist, wie sich N+1 in Blackfire zeigt - Du siehst Doctrine\ORM\EntityManager::find dutzendfach in einer Schleife aufgerufen.

Die fünf häufigsten Probleme in echten Symfony-Codebasen

Diese sind nicht theoretisch. Es sind die Probleme, die beim Auditieren von Symfony-Anwendungen immer wieder auftauchen, die unter Termindruck gebaut wurden oder von Entwicklern, die Doctrine aus Dokumentationsbeispielen statt aus Produktions-Codebasen gelernt haben.

1. Fehlende Eager-Loading-Strategie bei Collections

Doctrine lädt Collection-Beziehungen standardmäßig lazy. Eine OneToMany-Collection an einer Entity ist ein Proxy, bis auf sie zugegriffen wird. Das Problem: Der Zugriff in einer Schleife - etwa beim Rendern einer Nutzerliste mit ihren Rollen - löst eine separate Query pro Entity aus. Das ist das klassische N+1 und häufiger als jedes andere Problem, das wir finden.

Die Lösung ist explizites Eager Loading mit einem DQL-Join:

// Before: triggers one query per user
$users = $userRepository->findAll();
foreach ($users as $user) {
    echo count($user->getRoles()); // N queries here
}

// After: one query with JOIN FETCH
$users = $em->createQuery(
    'SELECT u, r FROM App\Entity\User u LEFT JOIN FETCH u.roles r'
)->getResult();

Der Performance-Unterschied ist nicht marginal. Bei einer Liste von 200 Nutzern sehen wir regelmäßig einen Rückgang von 200+ Queries auf 1, womit eine 1.200ms-Antwort auf 80ms fällt.

Das wichtige Detail: JOIN FETCH ist nicht immer die richtige Antwort. Bei sehr großen Collections oder wenn Du nur Zählwerte brauchst, ist eine separate aggregierte Query oder der EXTRA_LAZY-Fetch-Modus passender. EXTRA_LAZY ergänzt fetch="EXTRA_LAZY" im Mapping und verzögert das Laden der Collection, bis sie konkret iteriert oder gezählt wird - beim Iterieren löst es aber weiterhin eine Query pro Entity aus, eine Schleife repariert es also nicht.

2. N+1-Queries in Form Types

Form Types sind eine unterschätzte Quelle von N+1-Queries. Ein EntityType-Feld in einem Formular rendert ein HTML-Select, das aus einer Query befüllt wird. Erscheint dieses Formular in einem Listenkontext - etwa einer Admin-Tabelle, in der jede Zeile ein Inline-Edit-Formular hat - feuert Doctrine eine Query pro Formular-Instanz.

Die Lösung: die Auswahlmöglichkeiten außerhalb des Formulars vorladen und hineinreichen:

// In your controller or a form event subscriber
$choices = $categoryRepository->findAll();
$form = $this->createForm(ProductType::class, $product, [
    'category_choices' => $choices,
]);

Ersetze dann im Form Type den EntityType durch einen ChoiceType, der das vorgeladene Array nutzt. Eine Query, egal wie viele Formulare rendern.

3. Übereifrige Doctrine-Event-Listener

Symfonys Event-System ist mächtig und leicht zu missbrauchen. Wir finden regelmäßig Listener auf postLoad oder prePersist, die bedingungslos Arbeit verrichten - verwandte Entities laden, externe Services aufrufen, abgeleitete Werte berechnen - selbst wenn das Event für Entities feuert, bei denen nichts davon nötig ist.

Das Symptom in Blackfire: eine Listener-Methode, die dutzendfach pro Request erscheint, weit oben im Call Stack, mit signifikanter kumulierter Zeit. Die Lösung ist fast immer ein Early Return:

public function postLoad(LifecycleEventArgs $args): void
{
    $entity = $args->getObject();
    if (!$entity instanceof ProductEntity) {
        return; // Most listener executions exit here
    }
    // ... actual work
}

Das klingt offensichtlich, aber der Code Smell entsteht meist so: Ein Listener wird für einen Entity-Typ hinzugefügt, dann wächst die Anwendung auf zwanzig Entity-Typen. Die Kosten des Listeners skalieren mit der Entity-Anzahl, nicht mit der Absicht.

Für Listener, die immer laufen müssen, aber teuer sind, solltest Du erwägen, die Arbeit über Symfony Messenger in eine Message Queue zu verlagern, statt sie synchron im Request-Lebenszyklus auszuführen.

4. Synchrone HTTP-Aufrufe im Request-Lebenszyklus

Externe API-Aufrufe, die synchron in einem Controller oder Service passieren, sind eine verlässliche Quelle langsamer Antwortzeiten, weil Deine Anwendung auf Infrastruktur wartet, die Du nicht kontrollierst. Das Symptom im Symfony Profiler ist offensichtlich: eine hohe "app"-Zeit, die größtenteils Leerlauf ist, und ein HTTP-Client-Eintrag im Profiler, der 300-800ms ausmacht.

Die erste Frage: Muss der Aufruf überhaupt im Request stattfinden? Wenn Du Daten holst, die sich selten ändern (Wechselkurse, Feature Flags, externe Produktkataloge), cache die Antwort mit der Symfony-Cache-Komponente:

$cachedData = $cache->get('external_api_data', function (ItemInterface $item) {
    $item->expiresAfter(300); // 5-minute TTL
    return $this->httpClient->request('GET', 'https://api.example.com/data')->toArray();
});

Muss der Aufruf bei jedem Request stattfinden, weil die Daten nutzerspezifisch und in Echtzeit sind, wechsle zu einem nicht-blockierenden Muster. Symfony HttpClient unterstützt asynchrone Requests nativ:

$response = $httpClient->request('GET', 'https://api.example.com/user-data');
// Do other work here while the request is in flight
$data = $response->toArray(); // Blocks only when you need the result

Löst der Aufruf einen Seiteneffekt aus (Benachrichtigung senden, in einen externen Service loggen, ein Drittsystem aktualisieren), gehört er in eine Messenger-Message, nicht in den Request.

5. Fehlende Indizes auf Fremdschlüsseln gejointer Tabellen

Doctrine-Migrationen legen nicht in allen Konfigurationen automatisch Indizes auf Fremdschlüsselspalten an. Das Ergebnis: JOIN-Queries, die in der Entwicklung (kleiner Datensatz) schnell aussehen, werden in Produktion zu Table Scans. Eine Query, die orders mit order_items über order_id joint, ohne Index auf order_items.order_id, scannt für jede Zeile in orders die gesamte order_items-Tabelle.

Die Diagnose liefert ein Slow-Query-Log oder die Ausgabe von EXPLAIN ANALYZE (PostgreSQL) bzw. EXPLAIN (MySQL) für die konkrete Query. Achte auf Seq Scan in PostgreSQL oder type: ALL in MySQL auf der gejointen Tabelle - das ist ein fehlender Index.

Die Lösung in Doctrine ist unkompliziert:

#[ORM\Column]
#[ORM\Index(name: 'idx_order_items_order_id', columns: ['order_id'])]
private int $orderId;

Dann die Migration generieren und ausführen. Auf einer Tabelle mit 500k Zeilen hat dieser Index schon 8-Sekunden-Queries in 12ms-Queries verwandelt.

Die Profiler-Timeline richtig lesen

Symfony Profiler und Blackfire erzeugen beide Timelines, messen aber Unterschiedliches. Der Symfony Profiler zeigt Wall Time in Segmenten: Kernel Request, Controller, Response. Er ist gut, um zu erkennen, welche Phase des Requests langsam ist. Blackfire zeigt einen Call Graph mit Timing auf Funktionsebene. Er ist gut, um den konkret verantwortlichen Code zu finden.

Das Muster, das die meisten Entwickler in die Irre führt, ist die Unterscheidung zwischen exklusiver und inklusiver Zeit in Blackfire. Die inklusive Zeit einer Funktion umfasst alle Zeit in den von ihr aufgerufenen Funktionen. Die exklusive Zeit ist die Zeit, die die Funktion selbst mit Ausführung verbracht hat, ohne Kinder. Eine Funktion mit hoher inklusiver, aber niedriger exklusiver Zeit ist ein Symptom - die echten Kosten stecken in einem ihrer Callees. Folge der Kette nach unten, bis Du eine Funktion mit hoher exklusiver Zeit findest. Das ist das eigentliche Problem.

Für Doctrine ist die nützlichste Ansicht die Query-Timeline, sortiert erst nach Anzahl, dann nach Zeit. Eine Query, die 1-mal erscheint und 200ms braucht, ist ein anderes Problem als eine Query, die 200-mal erscheint und je 1ms braucht. Erstere braucht Query-Optimierung (Index, umstrukturierter JOIN oder Caching). Letztere braucht eine Loader-Änderung (JOIN FETCH oder Batch Loading).

Der Vorher-Nachher-Mess-Workflow

Ein Audit ohne Messungen ist nur Refactoring mit Meinungen. Der Workflow, den wir nutzen:

Erstens: Baseline etablieren. Nutze eine konsistente Testumgebung - gleiche Hardware oder derselbe Cloud-Instanztyp, gleiche Datenbankgröße, keine aktiven Caching-Schichten. Führe mindestens fünf Requests auf die Ziel-Route aus und notiere Median-Antwortzeit und Query-Anzahl aus dem Profiler. Nimm nicht den ersten Request (Cold Start) und nicht den besten (glücklicher Cache-Hit).

Zweitens: eine Änderung nach der anderen. Behebe ein Problem, miss erneut mit derselben Methodik. So kennst Du den tatsächlichen Effekt jeder Korrektur statt einer kombinierten Zahl, die Du nicht erklären kannst.

Drittens: mit produktionsnahen Daten messen. Ein N+1 auf einer Tabelle mit 50 Zeilen ist in der Entwicklung unsichtbar, aber katastrophal auf einer Tabelle mit 50.000 Zeilen. Spiele entweder einen Produktions-Datenbank-Snapshot in Deine Staging-Umgebung ein oder erzeuge realistische Datenmengen, bevor Du Schlüsse ziehst.

Viertens: die Zahlen dokumentieren. Eine Vorher-Nachher-Tabelle mit Route, Query-Anzahl, Median-Antwortzeit und p95-Antwortzeit gibt Dir etwas Konkretes für Dein Team oder Deinen Kunden. "Wir haben die Query-Anzahl auf der Produktlistenseite von 247 auf 3 reduziert und die p95-Antwortzeit von 1.800ms auf 95ms gesenkt" ist ein aussagekräftiges Ergebnis. "Wir haben die Datenbank-Queries optimiert" ist es nicht.

Wenn aus einem Performance Audit mehr wird

Manchmal zeigt ein Symfony Performance Audit, dass das Problem nicht der Code ist - sondern die Architektur. Ein Symfony-Monolith mit 600 Doctrine-Entities, 1.200 Event-Listenern und einem Service-Container, der 800 Services pro Request auflöst, bleibt langsam, egal wie gut die einzelnen Queries optimiert sind. Der Profiler zeigt es: Jeder Request berührt alles.

An diesem Punkt verschiebt sich das Gespräch von "welche Query optimieren" zu "was muss extrahiert, aufgeteilt oder auf Infrastrukturebene gecacht werden". Das ist ein anderes Projekt als ein gezieltes Audit.

Wenn Du diese Symptome siehst - Audit-Befunde, die oberflächlich behebbar sind, aber nach einem Monat zurückkommen, weil die zugrunde liegende Struktur sie kontinuierlich produziert - lohnt sich ein breiteres Architektur-Review. Die Services Code Quality Consulting und Legacy-Code-Optimierung von Wolf-Tech sind genau für dieses Szenario gemacht: eine strukturierte Bewertung, die Dir sagt, ob Du das Bestehende tunen oder die Bauweise ändern solltest.

Für gezielte Performance-Probleme, die klar innerhalb einer einzelnen Route oder eines Services liegen, reicht meist ein fokussiertes Audit-und-Fix-Projekt. Erreiche uns unter hello@wolf-tech.io oder über wolf-tech.io, beschreibe, was Du siehst, und wir sagen Dir schnell, welche Art von Projekt passt.

Der Profiler sagt Dir, was langsam ist. Die Behebung ist meist unkompliziert, sobald Du weißt, wonach Du suchst. Der schwierige Teil ist die Messdisziplin, um zu bestätigen, dass es funktioniert hat - und das nächste Problem zu erwischen, bevor es Produktion erreicht.