Die meisten Gespräche über Doctrine-Performance beginnen und enden bei N+1. Der Junior-Engineer findet den Profiler, sieht hundert Queries, wo eine reichen würde, fügt einen EAGER-Fetch-Mode oder ein JOIN FETCH hinzu, und die Seite lädt schneller. Der Senior nickt. Das Ticket schließt.
Das ist, soweit es reicht, in Ordnung. Aber es ist nicht das Gespräch, das bei echter Skalierung zählt. Sobald die offensichtlichen N+1-Queries weg sind, kommen Performance-Probleme nicht mehr aus der Query-Anzahl, sondern aus der Form Ihres Objekt-Graphen, den Kosten des Entity Managers und dem Mismatch zwischen Ihrer Schreib- und Ihrer Leseseite. Die verbleibenden Gewinne sind pro Fix kleiner, summieren sich aber zum Unterschied zwischen einem System, das unter Last hält, und einem, das es nicht tut.
Dieser Essay dreht sich um diese verbleibenden Gewinne. Die fünf Muster, nach denen ich greife, nachdem das N+1-Audit durch ist, grob nach Wirkung sortiert.
1. Hören Sie auf, Entitäten auf Lesepfaden zu hydrieren, die Sie nicht schreiben
Das Erste, was ich in jedem Doctrine-Performance-Engagement tue: Ich schaue mir die heißesten Lesepfade an und frage, ob dieser Pfad jemals die Entitäten mutiert, die er lädt. Bei den meisten Dashboards, Listenseiten, Suchergebnissen, Admin-Grids und API-GET-Endpoints ist die Antwort nein. Der Request liest Daten, formatiert sie, gibt sie zurück. Da wird nicht geschrieben.
Doctrine weiß das nicht. Standardmäßig hydriert es jede Zeile in eine gemanagte Entität, registriert sie in der Identity Map, verdrahtet Proxies für nicht geladene Relationen und plant sie für Change Tracking ein. Auf einer Listenansicht mit 50 Zeilen und einer Handvoll Relationen pro Zeile ist das eine Menge Maschinerie, die ohne Nutzen läuft.
Die Lösung ist, auf Lesepfaden anders zu hydrieren. Drei Optionen, in steigender Reihenfolge von Aufwand und Ertrag.
Array-Hydration für einfache Listen. Wenn die Daten eine flache Projektion sind (Spaltennamen als Keys, skalare Werte), überspringt getArrayResult() oder Query::HYDRATE_ARRAY die Entitätskonstruktion vollständig. Bei einer Liste von 500 Zeilen ist das typischerweise fünf bis zehnmal schneller als Objekt-Hydration und braucht einen Bruchteil des Speichers. Der Haken: Sie verlieren Typsicherheit und jedes Verhalten der Entität. Das ist in Ordnung für einen Response-Body, den Sie gleich JSON-encodieren.
$rows = $this->em->createQuery(
'SELECT p.id, p.title, p.publishedAt
FROM App\Entity\Post p
WHERE p.status = :status
ORDER BY p.publishedAt DESC'
)
->setParameter('status', 'published')
->setMaxResults(50)
->getArrayResult();
DTO-Hydration für strukturierte Reads. Wenn Sie ein geformtes Objekt brauchen, kein flaches Array, unterstützt Doctrine skalare Ergebnisse, die direkt in DTOs hydriert werden, über SELECT NEW:
$posts = $this->em->createQuery(
'SELECT NEW App\ReadModel\PostListItem(p.id, p.title, p.publishedAt, a.name)
FROM App\Entity\Post p
JOIN p.author a
WHERE p.status = :status
ORDER BY p.publishedAt DESC'
)->setParameter('status', 'published')->getResult();
Der DTO-Konstruktor kontrolliert den Kontrakt. Kein Lazy Loading, keine Hydration ungenutzter Spalten, keine Einträge in der Identity Map. Für Lesepfade auf Listenseiten ist das fast immer das richtige Muster. Zudem kodiert es den Read-Kontrakt in PHP-Typen, was für sich schon etwas wert ist.
Dedizierte Read Models für die heißen Seiten. Für die drei oder vier Endpoints, die den Großteil Ihres Traffics tragen, gehen Sie einen Schritt weiter und bauen ein denormalisiertes Read Model, aktualisiert über Domain Events oder einen Refresh-Job, abgefragt als flache Tabelle ohne Joins. Das ist der Punkt, an dem Sie Doctrine für diesen Lesepfad gar nicht mehr nutzen. Das Dashboard aus einem einzelnen SELECT * FROM post_list_read_model WHERE tenant_id = ? zu bedienen, ist dramatisch schneller als jede Hydration-Strategie, und die Komplexität auf der Schreibseite ist üblicherweise erträglich.
Meine Faustregel: Wenn ein Lesepfad mehr als 20 Entitäten lädt und mehr als 100 Mal pro Minute ausgeführt wird, sollte er keine Objekt-Hydration nutzen. Heben Sie ihn auf DTO- oder Read-Model-Form an.
2. Wissen Sie, was der Entity Manager kostet
Das zweite Muster dreht sich um Writes, nicht um Reads. Doctrines Unit of Work ist exzellent. Sie ist auch teuer. Jede gemanagte Entität trägt:
- Einen Eintrag in der Identity Map.
- Einen Snapshot ihres Zustands zum Ladezeitpunkt, für Change Detection.
- Potenziell einen Eintrag in den Queues für geplantes Insert, Update oder Delete.
- Registrierungen für Lifecycle Callbacks.
Für einen Request, der eine Entität lädt, modifiziert und flusht, ist das alles egal. Für einen Batch-Job, der 10.000 Entitäten in einer Schleife verarbeitet, ist alles davon relevant.
Zwei konkrete Muster kommen in fast jedem Engagement vor.
Der Batch-Job, der aus dem Speicher läuft. Klassische Doctrine-Falle. Die Schleife lädt eine Entität, modifiziert sie, flusht vielleicht und macht weiter. Nach 5.000 Iterationen hat der Prozess 2 GB Heap und wird sichtbar langsamer. Die Identity Map hält jede je geladene Entität. Change Tracking berechnet Diffs auf allen.
Der Fix ist kanonisch. Arbeit in Batches erledigen, periodisch flushen und den Manager clearen:
$batchSize = 100;
$i = 0;
$query = $this->em->createQuery('SELECT p FROM App\Entity\Post p');
foreach ($query->toIterable() as $post) {
$post->regenerateSlug();
++$i;
if (0 === $i % $batchSize) {
$this->em->flush();
$this->em->clear();
}
}
$this->em->flush();
$this->em->clear();
Drei Dinge dazu. Erstens streamt toIterable() (das das deprecatete iterate() ersetzt hat) die Ergebnisse, anstatt das ganze Set in den Speicher zu laden. Zweitens löst clear() alles vom Manager; wenn Sie über das Clear hinweg Referenzen auf Entitäten halten, bekommen Sie Fehler. Drittens ist die Batchgröße einstellbar. 100 ist ein Startpunkt; profilen Sie mit realistischen Daten.
Der Entity-Load auf dem Hot Path, den niemand bemerkt. Auf einem hochfrequentierten Endpoint kostet ein einzelner $this->em->getRepository(User::class)->find($id)-Aufruf etwa 1 ms Framework-Overhead zusätzlich zum SQL. Das summiert sich. Wenn der Endpoint 10.000 Mal pro Sekunde aufgerufen wird und die User-Daten in Redis passen, ist die richtige Antwort nicht “Doctrine schneller machen”, sondern “auf diesem Pfad gar nicht erst durch Doctrine gehen”. Cachen Sie die Projektion. Lesen Sie das DTO aus dem Cache. Gehen Sie nur bei Writes oder Cache-Misses zu Doctrine.
Der Instinkt, alles durch das ORM zu schicken, ist Bequemlichkeit, keine Pflicht. Die heißesten zehn Prozent Ihrer Endpoints können es komplett überspringen, ohne dass Ihre Architektur leidet. Identifizieren Sie diese Endpoints über Request-Volumen, nicht über Bauchgefühl.
3. Explizite Joins schlagen fetch="EAGER" (fast) jedes Mal
fetch="EAGER" auf einer Assoziation fühlt sich wie das richtige Werkzeug an, wenn die Relation immer gebraucht wird. “Der Post hat immer einen Autor, also immer den Autor mitladen.” Dann fügen Sie es ein, und die Performance wird schlechter, nicht besser. Warum?
Weil EAGER auf der Load-Strategy-Ebene greift. Jedes Mal, wenn Sie einen Post laden (auch aus einer Schleife heraus, auch via find(), auch über die Identity Map), setzt Doctrine eine separate Query für den Autor ab, wenn er nicht schon geladen ist. Das ist das Gegenteil von dem, was Sie wollten.
Das richtige Muster ist fetch="LAZY" (der Default) kombiniert mit explizitem JOIN FETCH an der Query-Stelle, wo Sie wissen, ob die Relation gebraucht wird.
$posts = $this->em->createQuery(
'SELECT p, a
FROM App\Entity\Post p
JOIN FETCH p.author a
WHERE p.status = :status'
)->setParameter('status', 'published')->getResult();
Jetzt wird der Autor in derselben SQL-Query wie der Post geladen. Auf der Listenseite, wo Sie ihn brauchen: eine Query. Auf dem Detail-Endpoint, wo Sie ihn nicht brauchen, greift der Lazy-Default und es wird nichts zusätzliches geladen.
Ich habe noch kein Projekt gesehen, in dem EAGER-Fetch-Modi flächendeckend die richtige Entscheidung waren. Ich habe viele gesehen, in denen das Abschalten von EAGER und das Einführen expliziter Joins die Render-Zeit der Seite um ein Drittel reduziert hat.
Zwei verwandte Fallen, auf die Sie achten sollten:
fetchEager auf @ManyToMany-Relationen. Fast immer ein Desaster. Ein Post mit eager geladenen tags, geladen in einer Liste von 50 Posts, feuert 50 Tag-Queries ab, es sei denn, die Tags jedes einzelnen Posts liegen zufällig schon in der Identity Map. Wechseln Sie auf Lazy, joinen Sie explizit, wenn nötig, nutzen Sie Array-Hydration für die Listenansicht.
fetch="EXTRA_LAZY" zum Zählen. Das ist die eine Stelle, an der ein eager-artiger Modus meist richtig ist. EXTRA_LAZY auf einer Collection lässt Sie $post->getComments()->count() aufrufen, ohne jeden Kommentar zu laden, weil Doctrine das in ein SELECT COUNT(*) auflöst. Für Seiten mit viel Zählerei (Badges, Benachrichtigungsglocken) ist das das richtige Muster.
4. Nutzen Sie den Second-Level-Cache vorsichtig, oder gar nicht
Doctrines Second-Level-Cache ist das Feature, das am meisten nach Silver Bullet aussieht und sich am wenigsten wie eine verhält. Das Versprechen: hydrierte Entitäten in Redis cachen, bei wiederholten Reads die Datenbank überspringen. Die Realität: Cache-Invalidierung ist schwer, die Cache-Key-Strategie ist subtil, und der Failure-Mode, wenn es schiefgeht, sind stundenlang stillschweigend ausgelieferte veraltete Daten.
Den meisten Teams sage ich, den Second-Level-Cache nicht zu nutzen, solange sie keinen konkreten, gemessenen Fall haben. Die Fälle, in denen er sich bezahlt macht:
- Eine lese-lastige Entität, die sich selten ändert (Site-Settings, Steuersätze, Konfiguration, Referenzdaten).
- Ein Pfad mit einer langsamen Query, die teuer und deren Resultset klein ist.
- Ein Lesepfad, bei dem die Staleness-Toleranz explizit ist und in Minuten gemessen wird, nicht in Sekunden.
Die Fälle, in denen er mehr schadet als nützt:
- Jede Entität, bei der “korrekt” “auf die Millisekunde aktuell” bedeutet.
- Jede Entität mit großem Relationsgraph, weil der Cache die Invalidierung von Assoziationen nachziehen muss und das oft falsch macht.
- Jedes Cache-Through-Muster, bei dem die Anwendung den Invalidierungspfad nicht besitzt (z. B. ein Schwester-Service, der in dieselbe Tabelle schreibt).
Wenn Sie ihn nutzen, nutzen Sie ihn eng und bewusst:
#[ORM\Entity]
#[ORM\Cache(usage: 'READ_ONLY', region: 'settings')]
class Setting
{
// ...
}
READ_ONLY ist die sicherste Stufe. Doctrine wirft eine Exception, wenn Sie versuchen, eine gecachte Entität zu mutieren, was Sie zwingt, explizit zu entscheiden, ob dieser Entitätstyp zur Laufzeit überhaupt veränderbar ist. NONSTRICT_READ_WRITE ist flexibler und deutlich gefährlicher. READ_WRITE bietet kohärente Invalidierung, aber zu Kosten, die den Nutzen oft zunichtemachen.
Eine günstigere und vorhersehbarere Alternative: Bauen Sie Ihre eigene Cache-Schicht oberhalb des Repositorys, mit einem klaren Invalidierungsvertrag, den Sie kontrollieren. Sie verlieren die “transparente” Magie. Sie gewinnen die Fähigkeit, über Staleness nachzudenken.
5. Profilen Sie mit Zahlen, nicht mit Gefühl
Das fünfte Muster ist ein Meta-Muster, aber ohne es summieren sich die anderen vier nicht.
Sie werden Performance-Änderungen auf Basis von Ahnungen vornehmen. Ihre Ahnungen werden manchmal falsch sein. Ohne Zahlen wissen Sie nicht, welche falsch waren, und Sie liefern “Verbesserungen” aus, die Regressionen einbauen.
Drei Werkzeuge verdienen sich ihren Platz im Workflow.
Symfony Profiler für die Entwicklung. Das Doctrine-Panel zeigt jede Query eines Requests, deren Laufzeit und deren Parameter. Bevor Sie eine Seite anfassen, halten Sie fest, was sie jetzt tut. Nach Ihrer Änderung halten Sie fest, was sie tut. Wenn die Query-Anzahl gleich ist und die Gesamtzeit gleich ist, haben Sie nichts verbessert, egal wie viel sauberer der Code aussieht.
Blackfire für gezielte Untersuchung. Wenn Sie wissen müssen, wo die Zeit tatsächlich hingeht, ist ein Blackfire-Profil eine Stunde Code-Lesen wert. Ich habe Senior-Engineers gesehen, die Tage mit Hydration-Optimierung verbracht haben, während der eigentliche Flaschenhals JSON-Serialisierung war, oder die Tage mit Query-Optimierung verbracht haben, während der eigentliche Flaschenhals ein Listener war, der bei jedem Flush lief. Blackfire sagt Ihnen schnell die Wahrheit.
OpenTelemetry für die Produktion. Was auf Ihrem Laptop mit 100 Zeilen Entwicklungsdaten passiert, ist nicht das, was in Produktion mit 5 Millionen Zeilen unter gleichzeitiger Last passiert. Instrumentieren Sie die Anwendung mit OpenTelemetry, exportieren Sie die Traces an einen Ort, den Sie abfragen können, und schauen Sie sich die p95- und p99-Latenzen der Pfade an, die Sie interessieren. Mittelwerte werden Sie belügen. Perzentile zeigen Ihnen, was die langsamsten fünf Prozent Ihrer Nutzer tatsächlich erleben.
Die Regel: keine Performance-Aussage kommt in eine PR-Beschreibung ohne Vorher-Nachher-Messung. “Query-Performance verbessert” ist keine PR-Zusammenfassung. “Post-List-p95-Latenz von 420 ms auf 180 ms gesenkt (5.000 Zeilen, Staging)” schon.
Die Muster, die ich fast immer falsch eingesetzt sehe
Eine kurze Liste von Anti-Mustern, die sich als Optimierungen tarnen:
fetch="EAGER" einfügen, um ein N+1 zu beheben. Übertüncht das Problem mit dem falschen Werkzeug. Das richtige Werkzeug ist ein expliziter Join an der Query oder eine DTO-Projektion.
@Cache-Annotationen überall “für alle Fälle” einsetzen. Jetzt sind Ihre Cache-Semantiken überall unklar und Fehler sind stumm. Entfernen Sie sie; setzen Sie sie nur wieder ein, wenn Sie einen gemessenen Grund haben.
persist() auf bereits gemanagten Entitäten. Harmlos, aber ein Zeichen, dass der Autor des Codes sich über den Objektzustand unsicher ist. Lesen Sie die Identity Map, bevor Sie persistieren.
Flushen innerhalb einer Schleife. Sofern Sie nicht bewusst batchen, killt ein Flush pro Iteration den Durchsatz. Akkumulieren, einmal flushen. Wenn Sie batchen, alle N flushen und clearen.
Den QueryBuilder nutzen, wo ein DQL-String kürzer und klarer wäre. Der QueryBuilder rechtfertigt sich für dynamische Queries (Filter, optionale Joins). Für statische Queries ist ein DQL-String einfacher zu lesen, einfacher zu optimieren und weniger Code.
Ein sequenzieller Plan für ein Doctrine-Performance-Engagement
Setzt man die Muster zu einem einwöchigen Engagement zusammen, sieht es so aus.
Tag 1: Messen. Blackfire-Profile auf den zehn nach Request-Volumen größten Endpoints ziehen. p50-, p95- und p99-Latenzen für jeden festhalten. Doctrine-Queries pro Request zählen. Das ist die Baseline. Heute kein Code.
Tag 2: Lesepfade auditen. Jeden der Top-10-Endpoints als read-only oder read-write klassifizieren. Für die read-only identifizieren, welche noch Objekt-Hydration nutzen und wo es eine klare DTO-Projektions-Gelegenheit gibt. Kandidaten notieren.
Tag 3: Writes auditen. Die Batch-Jobs und hochvolumigen Schreibpfade anschauen. Alle identifizieren, die nicht batchen und clearen. Alle identifizieren, die innerhalb einer Schleife flushen. Alle identifizieren, die über ein großes Resultset iterieren.
Tag 4: Die drei wichtigsten Lesepfade fixen. Objekt-Hydration zu DTO-Hydration umbauen. Vorher und nachher mit Blackfire und produktionsähnlichen Daten messen. Ausliefern. Keine anderen Änderungen im selben PR.
Tag 5: Die drei wichtigsten Schreibpfade fixen. Batching und Clearing einführen. Durchsatzänderung auf einem realistischen Datenset messen. Ausliefern.
Am Ende der Woche sollten Sie sinnvolle, gemessene Verbesserungen auf sechs Codepfaden haben, mit Vorher-Nachher-Zahlen an jedem PR. So sieht finanzierbare Performance-Arbeit aus.
Wann Doctrine nicht die Antwort ist
Das ORM ist ein scharfes Werkzeug. Es ist auch ein Allzweckwerkzeug, und Allzweckwerkzeuge verlieren an den Rändern gegen spezialisierte. Wenn Ihre Last so aussieht:
- Ein read-lastiges System mit stark denormalisierten Zugriffsmustern.
- Eine Reporting-Engine, die aggregierte Queries über Millionen von Zeilen fährt.
- Ein Event-Ingest mit hohem Durchsatz, der schreibt und danach vergisst.
Dann verdient sich Doctrine auf diesem Pfad vermutlich nicht sein Geld. Für den ersten Fall ist eine dedizierte Read-Model-Tabelle oder eine Materialized View schneller. Für den zweiten Fall zerlegt ein Query-Tool, das um spaltenorientierte Daten weiß (DuckDB für Embedded, ClickHouse für Server-seitig), Doctrine um eine Größenordnung. Für den dritten Fall wollen Sie rohe SQL-Inserts oder ein Message-Bus-Muster, keinen gemanagten Entity-Graphen.
Zu wissen, wann man das ORM verlässt, gehört zum Beherrschen dazu. Die Gewinne aus der richtigen Werkzeugwahl pro Pfad sind größer als jede Menge Hydration-Tuning.
Wenn Sie auf eine Symfony-Anwendung schauen, die unter Last langsamer wird, und die N+1-Fixes die Zahlen nicht mehr bewegen, startet mein Scaling-Engagement mit einem gemessenen Doctrine-Audit. Eine Woche auf den heißesten zehn Endpoints, Vorher-Nachher-Zahlen in jedem PR und ein Katalog der Lesepfade, die das ORM komplett verlassen sollten.
Referenzen
- Doctrine ORM: Batch Processing: kanonische Referenz für
toIterable(), periodisches Flushen und Clear-Muster. - Doctrine ORM: DQL (SELECT NEW): dokumentiert die DTO-Konstruktor-Projektion, die in Muster 1 verwendet wird.
- Doctrine ORM: Second-Level Cache: offizielle Referenz zu Cache-Regionen und der Semantik von
READ_ONLY/NONSTRICT_READ_WRITE/READ_WRITE. - Symfony Profiler: der Symfony-Entwicklungs-Profiler mit seinem Doctrine-Query-Panel.
- Blackfire: deterministischer PHP-Profiler für die Engpass-Untersuchung.
- OpenTelemetry PHP: sprachspezifische Dokumentation für Tracing und Metriken in produktiven PHP-Anwendungen.
- DuckDB: embedded spaltenorientierte OLAP-Datenbank, im Reporting-Abschnitt erwähnt.
- ClickHouse: server-seitige spaltenorientierte Analytics-Datenbank, ebenfalls dort erwähnt.