Symfony Doctrine: Query Result Cache vs. Second-Level Cache im Vergleich
AI generated
SF
{ }
Symfony · Doctrine ORM · Caching
Symfony Doctrine: Query Result Cache vs. Second-Level Cache
Zwei Cache-Ebenen für zwei unterschiedliche Probleme, und wie man beide richtig einsetzt

Doctrine bringt zwei grundverschiedene Cache-Mechanismen mit, die auf den ersten Blick beide dasselbe Ziel verfolgen, nämlich weniger Last auf der Datenbank, aber strukturell komplett unterschiedlich arbeiten. Der Query Result Cache merkt sich das fertige Ergebnis einer konkreten Abfrage, während der Second-Level Cache einzelne Entities unabhängig von der Abfrage, die sie ursprünglich geladen hat, vorhält. Wer beide Mechanismen verwechselt, baut entweder eine Caching-Schicht, die bei jeder Schreiboperation veraltete Daten ausliefert, oder eine, die viel zu aufwendig für das eigentliche Problem ist. Dieser Artikel klärt die Unterschiede an konkreten Beispielen und zeigt, wie man Cache-Invalidierung sauber umsetzt.

15 Min. Lesezeit Query Result Cache Second-Level Cache · Redis

1. Zwei Cache-Ebenen, zwei unterschiedliche Probleme

Der Query Result Cache arbeitet auf der Ebene einer konkreten, ausgeführten Abfrage: Er speichert das serialisierte Ergebnis einer bestimmten DQL- oder SQL-Query zusammen mit ihren Parametern unter einem Cache-Key, der aus der Query selbst abgeleitet wird. Fragt derselbe Code später dieselbe Query mit denselben Parametern erneut ab, liefert Doctrine das gecachte Ergebnis, ohne überhaupt eine Verbindung zur Datenbank aufzubauen. Das Problem, das dieser Cache löst, ist rein performanceorientiert: Eine teure, oft ausgeführte Abfrage soll nicht bei jedem Aufruf erneut die Datenbank belasten.

Der Second-Level Cache setzt eine Ebene tiefer an, direkt bei den Entities selbst, unabhängig davon, über welche Abfrage sie ursprünglich geladen wurden. Wird eine Entity einmal über irgendeine Query geladen, landet sie im Second-Level Cache und kann danach über find() oder als Teil einer Assoziation eines anderen Objekts wiederverwendet werden, ohne erneut geladen zu werden, selbst wenn der Zugriffspfad ein völlig anderer ist als beim ersten Laden. Dieser Cache löst ein anderes Problem: Er reduziert redundantes Laden derselben Entity über viele unterschiedliche Codepfade hinweg, nicht nur die Wiederholung derselben Abfrage.

2. Query Result Cache: Wie er funktioniert

Der Query Result Cache wird pro Query explizit aktiviert, entweder über ->setResultCacheLifetime(3600) auf dem Query-Objekt oder als DQL-Hint. Ohne diese explizite Aktivierung wird keine einzige Abfrage automatisch gecacht, was ein bewusster Designentscheidung ist: Doctrine will nicht raten, welche Queries sich für Caching eignen, sondern überlässt diese Entscheidung dem Entwickler, der den fachlichen Kontext kennt. Bei Queries mit sich häufig ändernden Ergebnissen, etwa einer Bestandsanzeige, ist Caching meist kontraproduktiv, während bei selten wechselnden Referenzdaten wie einer Länderliste ein langes Cache-Lifetime von mehreren Stunden problemlos vertretbar ist.

Das folgende Beispiel zeigt eine typische Repository-Methode, die eine Liste aktiver Produktkategorien lädt, mit einem Cache-Lifetime von einer Stunde und einem expliziten Cache-Key, der sicherstellt, dass derselbe Cache-Eintrag auch bei minimalen Änderungen an der Query-Struktur weiterhin gefunden wird.


<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\Category;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

final class CategoryRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Category::class);
    }

    /**
     * @return list<Category>
     */
    public function findActiveCategories(): array
    {
        return $this->createQueryBuilder('c')
            ->andWhere('c.active = true')
            ->orderBy('c.position', 'ASC')
            ->getQuery()
            ->enableResultCache(3600, 'active_categories')
            ->getResult();
    }
}

3. Second-Level Cache: Entity-Caching über Query-Grenzen hinweg

Der Second-Level Cache wird pro Entity-Klasse aktiviert, entweder über das Attribut #[Cache] direkt an der Klasse oder über die XML/YAML-Mapping-Konfiguration, und lässt sich zusätzlich pro Assoziation feingranular steuern. Einmal aktiviert, sorgt Doctrine automatisch dafür, dass eine geladene Entity im Cache landet und bei jedem späteren Zugriff, egal über welchen Codepfad, aus dem Cache statt aus der Datenbank kommt, solange sie nicht explizit invalidiert wurde.

Der entscheidende Unterschied zum Query Result Cache liegt in der Wiederverwendbarkeit: Eine per Second-Level Cache gecachte Category-Entity wird sowohl bei einem direkten find(5) als auch beim Laden über eine Product-Assoziation aus dem Cache bedient, während ein Query Result Cache nur bei exakt derselben Abfrage mit denselben Parametern greift. Diese Wiederverwendbarkeit macht den Second-Level Cache mächtiger, aber auch komplexer in der Invalidierung, da eine einzelne Entity-Änderung potenziell viele verschiedene Cache-Einträge betrifft, die über unterschiedliche Queries entstanden sind.

4. Wann der Result-Cache ausreicht

Der Query Result Cache ist die richtige Wahl, wenn eine konkrete, klar identifizierbare Abfrage häufig mit denselben oder sehr ähnlichen Parametern ausgeführt wird, etwa eine Startseite, die immer dieselben fünf hervorgehobenen Produkte lädt, oder ein Dropdown mit Versandländern, das bei jedem Formularaufruf identisch ist. In diesen Fällen ist die Abfrage selbst der stabile Bezugspunkt, nicht die einzelnen Entities, und der Result-Cache löst das Problem mit minimalem Konfigurationsaufwand.

Auch bei Reports oder Aggregationsabfragen, die keine einzelnen Entities, sondern berechnete Werte wie Summen oder Durchschnitte zurückgeben, ist der Result-Cache die einzig sinnvolle Option, denn der Second-Level Cache arbeitet ausschließlich auf Entity-Ebene und kann mit reinen Skalarwerten aus einer aggregierten Query nichts anfangen. Für ein Dashboard, das täglich einmal aktualisierte Umsatzzahlen zeigt, reicht ein Result-Cache mit einem Lifetime von 24 Stunden vollkommen aus.

5. Wann Second-Level Cache nötig ist

Second-Level Cache lohnt sich, sobald dieselben Entities über viele unterschiedliche, nicht vorhersehbare Codepfade wiederholt geladen werden, etwa eine Country-Entity, die sowohl bei der Adressvalidierung, im Checkout, in der Versandkostenberechnung als auch im Admin-Backend referenziert wird. Ohne Second-Level Cache würde jede dieser Stellen die Entity separat aus der Datenbank laden, selbst wenn sie im selben Request-Zyklus bereits an anderer Stelle geladen wurde, denn der Identity Map von Doctrine gilt nur innerhalb eines einzelnen Entity Managers und Requests, nicht über Requests hinweg.

Besonders wertvoll ist Second-Level Cache für Referenzdaten mit geringer Änderungsfrequenz, aber hoher Lesefrequenz, wie Länder, Währungen, Steuerklassen oder Produktkategorien. Bei ständig wechselnden Daten wie Lagerbeständen oder Preisen, die sich mehrfach pro Minute ändern können, ist Second-Level Cache dagegen riskant, da die Invalidierungslogik komplex wird und die Wahrscheinlichkeit veralteter Daten im Cache mit der Änderungsfrequenz steigt.

6. Cache-Invalidierung bei Schreiboperationen

Second-Level Cache invalidiert sich bei Änderungen über denselben Entity Manager weitgehend automatisch: Ruft man $entityManager->flush() nach einer Änderung an einer gecachten Entity auf, aktualisiert Doctrine den entsprechenden Cache-Eintrag automatisch mit. Kritisch wird es erst, wenn Daten außerhalb von Doctrine geändert werden, etwa durch ein direktes SQL-Update in einem Migrations-Script oder durch einen anderen Prozess, der denselben Datenbestand über eine andere Verbindung modifiziert. In diesen Fällen muss der Cache-Eintrag explizit über $cache->evictEntity(Category::class, $id) oder komplett per Region geleert werden.

Der Query Result Cache dagegen invalidiert sich niemals automatisch, denn Doctrine hat keine Möglichkeit zu wissen, welche Tabellen eine beliebige DQL-Query tatsächlich betrifft, ohne die Query selbst zu parsen und semantisch zu verstehen. Nach jeder relevanten Schreiboperation muss der Cache-Eintrag deshalb explizit über denselben Cache-Key gelöscht werden, den man beim Setzen verwendet hat, weshalb es sich lohnt, Cache-Keys konsequent an einer zentralen Stelle zu definieren, etwa als Konstanten im jeweiligen Repository, statt sie an mehreren Stellen im Code als Strings zu wiederholen.

7. Redis und APCu als Cache-Backend im Vergleich

APCu speichert Cache-Daten im geteilten Speicher des PHP-Prozesses auf demselben Server und ist dadurch extrem schnell, da kein Netzwerk-Roundtrip nötig ist. Der entscheidende Nachteil zeigt sich bei mehreren Applikationsservern hinter einem Load Balancer: Jeder Server hält seinen eigenen, unabhängigen APCu-Cache, wodurch eine Invalidierung auf Server A den Cache auf Server B nicht beeinflusst, was zu inkonsistenten Zuständen führt, je nachdem, welcher Server einen Request bedient.

Redis löst dieses Problem, indem der Cache zentral auf einem separaten Server liegt, den sich alle Applikationsserver teilen, sodass eine Invalidierung sofort überall wirkt. Der Preis dafür ist ein Netzwerk-Roundtrip pro Cache-Zugriff, der auch bei einem im selben Rechenzentrum stehenden Redis meist im niedrigen einstelligen Millisekundenbereich liegt, aber eben nicht null ist. In der Praxis ist Redis für den Second-Level Cache bei mehreren Applikationsservern praktisch alternativlos, während APCu für den Result-Cache bei einem Single-Server-Setup oder als lokale L1-Ebene vor einem Redis-basierten L2-Cache weiterhin sinnvoll bleibt.

8. Konfiguration in doctrine.yaml

Die Konfiguration beider Cache-Ebenen erfolgt zentral unter dem orm-Schlüssel in config/packages/doctrine.yaml. Für den Result-Cache reicht in der Regel ein einzelner query_cache_driver-Eintrag, der auf einen konfigurierten Cache-Pool verweist, während für den Second-Level Cache zusätzlich second_level_cache.enabled: true sowie eine oder mehrere regions definiert werden müssen, die jeweils eigene Lifetime-Einstellungen und einen eigenen Cache-Treiber haben können.

Es empfiehlt sich, für Referenzdaten mit sehr geringer Änderungsfrequenz eine eigene Region mit langem Lifetime, etwa 'reference_data', anzulegen, und für häufiger wechselnde, aber dennoch cachewürdige Entities eine zweite Region mit kürzerem Lifetime. Diese Trennung verhindert, dass eine pauschale, für alle Entities gleiche Cache-Konfiguration entweder zu aggressiv für volatile Daten oder zu konservativ für stabile Referenzdaten ausfällt.

9. Performance-Messung vorher/nachher

Bevor eine Cache-Ebene eingeführt wird, lohnt sich eine Baseline-Messung mit dem Symfony Profiler oder einem Tool wie Blackfire, das die Anzahl der ausgeführten Queries und deren kumulierte Dauer für einen typischen Request sichtbar macht. Ohne diese Baseline lässt sich der tatsächliche Nutzen einer Caching-Maßnahme später nicht objektiv belegen, und es besteht das Risiko, Komplexität für einen Effekt einzuführen, der sich als vernachlässigbar herausstellt.

Nach Einführung des Caches sollte dieselbe Messung wiederholt werden, idealerweise unter realistischer Last mit einem Tool wie k6 oder Apache Bench, um den Effekt nicht nur an der Anzahl der Datenbankabfragen, sondern auch an der tatsächlichen Antwortzeit unter gleichzeitigen Requests zu belegen. Ein häufig unterschätzter Effekt ist, dass ein zu aggressiv konfigurierter Second-Level Cache bei sehr vielen unterschiedlichen Entities mehr Speicher im Redis-Server belegt, als er an Datenbanklast einspart, weshalb auch der Speicherverbrauch des Cache-Backends selbst Teil der Vorher-Nachher-Messung sein sollte.

Kriterium Query Result Cache Second-Level Cache Empfehlung
Cache-Ebene Fertiges Query-Ergebnis Einzelne Entity Je nach Zugriffsmuster wählen
Wiederverwendbarkeit Nur exakt gleiche Query Über beliebige Zugriffspfade Second-Level bei vielen Codepfaden
Invalidierung Immer manuell nötig Bei flush() über ORM automatisch Second-Level bei ORM-only Writes
Aggregationsabfragen Funktioniert problemlos Nicht anwendbar Result Cache für Reports
Empfohlenes Backend APCu oder Redis Redis bei mehreren Servern Redis bei Multi-Server-Setup

Mironsoft

Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung

Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?

Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.

Architektur-Review

Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.

Legacy-Modernisierung

Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.

Test- und Qualitätssicherung

PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.

10. Zusammenfassung

Doctrine Caching: Das Wichtigste auf einen Blick

Query Result Cache

Merkt sich fertige Abfrageergebnisse, ideal für wiederholte, identische Queries.

Second-Level Cache

Cacht einzelne Entities über beliebige Zugriffspfade und Query-Grenzen hinweg.

Invalidierung

Second-Level automatisch bei ORM-Writes, Result Cache immer manuell nötig.

Backend-Wahl

APCu für Single-Server, Redis zwingend bei mehreren Applikationsservern.

11. FAQ: Doctrine Caching: Das Wichtigste auf einen Blick

1Was ist der grundlegende Unterschied zwischen Query Result Cache und Second-Level Cache?
Der Query Result Cache speichert das fertige Ergebnis einer konkreten Abfrage. Der Second-Level Cache speichert einzelne Entities unabhängig davon, über welche Abfrage sie geladen wurden, und macht sie über beliebige Zugriffspfade wiederverwendbar.
2Wird der Query Result Cache automatisch für alle Queries aktiviert?
Nein, Caching muss pro Query explizit aktiviert werden, etwa über enableResultCache auf dem Query-Objekt. Doctrine cacht standardmäßig keine einzige Abfrage automatisch.
3Kann ich den Second-Level Cache für Aggregationsabfragen wie SUM oder COUNT nutzen?
Nein, der Second-Level Cache arbeitet ausschließlich auf Entity-Ebene. Für Abfragen, die berechnete Skalarwerte statt Entities zurückgeben, ist der Query Result Cache die richtige Wahl.
4Muss ich den Second-Level Cache manuell invalidieren?
Bei Änderungen über denselben Entity Manager und einen anschließenden flush-Aufruf aktualisiert Doctrine den Cache automatisch. Nur bei Änderungen außerhalb des ORM, etwa per direktem SQL, ist eine manuelle Invalidierung über evictEntity nötig.
5Warum ist APCu bei mehreren Applikationsservern problematisch?
APCu speichert Daten im lokalen Speicher jedes einzelnen PHP-Prozesses. Bei mehreren Servern hinter einem Load Balancer hat jeder Server einen eigenen, unabhängigen Cache, wodurch eine Invalidierung auf einem Server die anderen Server nicht erreicht.
6Wann lohnt sich Redis gegenüber APCu trotz des Netzwerk-Overheads?
Immer dann, wenn mehrere Applikationsserver denselben Cache konsistent halten müssen, insbesondere beim Second-Level Cache. Der Netzwerk-Overhead liegt bei einem im selben Rechenzentrum stehenden Redis meist im niedrigen einstelligen Millisekundenbereich.
7Welche Entities eignen sich besonders für Second-Level Cache?
Referenzdaten mit geringer Änderungsfrequenz, aber hoher Lesefrequenz, etwa Länder, Währungen oder Steuerklassen. Bei ständig wechselnden Daten wie Lagerbeständen ist Second-Level Cache riskant.
8Wie messe ich den tatsächlichen Nutzen einer neu eingeführten Cache-Ebene?
Mit einer Baseline-Messung vor der Einführung über den Symfony Profiler oder ein Tool wie Blackfire, gefolgt von derselben Messung danach unter realistischer Last, idealerweise inklusive Speicherverbrauch des Cache-Backends selbst.
9Kann ich Query Result Cache und Second-Level Cache gleichzeitig nutzen?
Ja, beide Mechanismen schließen sich nicht aus und werden in der Praxis häufig kombiniert, etwa Second-Level Cache für häufig referenzierte Entities und Result Cache für spezifische Report-Queries.
10Was passiert, wenn ich einen Cache-Key im Result-Cache nach einer Schreiboperation vergesse zu löschen?
Der Cache liefert dann veraltete Daten bis zum Ablauf des konfigurierten Lifetimes, ohne dass ein Fehler sichtbar wird. Deshalb lohnt es sich, Cache-Keys zentral als Konstanten zu definieren und die Invalidierung an derselben Stelle wie die Schreiboperation zu verankern.