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.
Inhaltsverzeichnis
- 1. Zwei Cache-Ebenen, zwei unterschiedliche Probleme
- 2. Query Result Cache: Wie er funktioniert
- 3. Second-Level Cache: Entity-Caching über Query-Grenzen hinweg
- 4. Wann der Result-Cache ausreicht
- 5. Wann Second-Level Cache nötig ist
- 6. Cache-Invalidierung bei Schreiboperationen
- 7. Redis und APCu als Cache-Backend im Vergleich
- 8. Konfiguration in doctrine.yaml
- 9. Performance-Messung vorher/nachher
- 10. Zusammenfassung
- 11. FAQ
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.