Symfony Cache Component: HTTP-Caching, Application Cache und die Muster, die sie zusammen funktionieren lassen
Frag ein paar Symfony-Entwickler, wie Caching in ihrer App funktioniert, und die Antworten passen selten zusammen. Einer redet über Cache-Control-Header, ein anderer über $cache->get() mit Callback, und jemand erwähnt den Doctrine Result Cache, ohne sicher zu sein, ob der Redis oder das Dateisystem nutzt. Die Symfony Cache Component besteht in Wirklichkeit aus drei getrennten Mechanismen, die sich zufällig ein Wort teilen, und die Verwirrung beginnt, sobald man von einem erwartet, dass er den Job eines anderen erledigt.
Dieser Beitrag zeigt, was jede Schicht tut, wo sie im Request-Lifecycle sitzt und wie du alle drei so verdrahtest, dass sie zusammenarbeiten. Das durchgehende Beispiel ist ein Multi-Tenant-SaaS-Dashboard: Jeder Nutzer sieht personalisierte Daten, aber die meisten dieser Daten ändern sich nur ein paar Mal pro Stunde. Genau das ist der Fall, in dem eine einzelne Cache-Schicht nie ausreicht.
Drei Caches in der Symfony Cache Component
Am einfachsten hält man sie auseinander, indem man fragt, an welcher Stelle des Requests jeder eingreift.
HTTP-Caching passiert, bevor dein Anwendungscode läuft. Ein Reverse Proxy (Symfonys eigener HttpCache-Kernel, Varnish oder Nginx) schaut sich den eingehenden Request an, findet eine gespeicherte passende Response, prüft deren Freshness-Header und liefert sie aus. Dein Controller wird nie ausgeführt. PHP-FPM nimmt den Request nie an. Das ist die einzige Schicht, die die Serverlast für eine bestimmte URL auf nahezu null drücken kann.
Der Application Cache passiert in deinem Code. Du packst eine teure Operation (eine langsame Query, einen externen API-Aufruf, ein berechnetes Aggregat) in einen Cache-Aufruf und lässt die Component entscheiden, ob sie ausgeführt oder der gespeicherte Wert zurückgegeben wird. Der Controller läuft, aber der teure Teil darin vielleicht nicht.
Der Doctrine Result Cache sitzt eine Ebene tiefer. Er speichert die hydrierten Zeilen einer bestimmten DQL-Query, damit dieselbe Query mit denselben Parametern die Datenbank überspringt. Dein Controller läuft, deine Repository-Methode läuft, und nur der Roundtrip zu PostgreSQL oder MySQL entfällt.
Jede Schicht ist umso günstiger, je näher sie am Edge liegt, und umso schwerer zu personalisieren, je näher sie am Edge liegt. Zu entscheiden, welche Daten in welche Schicht gehören, ist der Großteil der Designarbeit, wenn man die Symfony Cache Component richtig einsetzen will.
HTTP-Caching mit HttpCache und Cache-Headern
Symfonys HttpCache ist ein in PHP geschriebener Reverse Proxy, aktiviert über framework.http_cache: true. Er ist langsamer als Varnish, weil er weiterhin in PHP läuft, braucht aber keine zusätzliche Infrastruktur und verhält sich wie ein echter Proxy. Das macht ihn zu einem guten Weg, deine Header zu validieren, bevor du Varnish davor setzt.
Die Header sind das, was wirklich zählt. Eine Response wird für einen Shared Cache cachebar, wenn sie es sagt:
$response->setPublic();
$response->setSharedMaxAge(300);
$response->headers->addCacheControlDirective('must-revalidate');
s-maxage richtet sich an Shared Caches (Proxies), max-age an den Browser. Bei einem Dashboard willst du fast immer, dass der Proxy cacht und der Browser revalidiert, also ist s-maxage der Wert, den du hoch setzt.
Jetzt der naheliegende Einwand: Dashboard-Responses sind personalisiert, und ein Proxy kann die Seite von Nutzer A nicht an Nutzer B ausliefern. Das stimmt, und genau hier geben die meisten Teams HTTP-Caching für eingeloggte Bereiche auf. Zwei Techniken holen dich zum Großteil zurück.
Die erste sind Edge Side Includes. Die Seitenhülle bleibt ungecacht (oder wird per Vary: Cookie pro Nutzer gecacht, was meist eine schlechte Idee ist), und die teuren Fragmente werden als separate ESI-Requests mit eigenen Cache-Lebensdauern gerendert. Ein tenant-weites Widget "Offene Rechnungen" kann s-maxage=600 tragen und von jedem Nutzer dieses Tenants geteilt werden, während der Header mit dem Nutzernamen gar nicht gecacht wird. Symfony unterstützt ESI nativ über framework.esi: true und die Twig-Funktion render_esi().
Die zweite Technik ist, personalisierte Daten komplett aus dem HTML herauszulösen. Die Seite wird zu einer statischen Hülle, die der Proxy ausliefert, und die personalisierten Zahlen kommen aus einem JSON-Endpoint, der den unten beschriebenen Application Cache nutzt. Mehr Arbeit im Vorfeld, aber sie zahlt sich mit wachsendem Traffic aus.
Für die Invalidierung reichen reine TTLs selten aus. Wenn ein Tenant-Admin eine Rechnung bearbeitet, soll das Fragment "Offene Rechnungen" jetzt aktualisiert werden, nicht in zehn Minuten. Varnish unterstützt Tag-basiertes Purging über sein xkey-Modul, und das FOSHttpCacheBundle stellt das als Cache-Tags-artigen Header auf Responses bereit, plus einen Invalidierungs-Service, den du aus deinem Domain-Code aufrufst. Symfonys eingebauter HttpCache unterstützt kein Tag-basiertes Purging. Wenn gezielte Invalidierung eine Anforderung ist, plane von Anfang an mit Varnish.
Die Cache Contracts und Tag-basierte Invalidierung
Der Application Cache ist der Ort, an dem der Großteil des alltäglichen Cachings stattfindet. Die moderne API ist Symfony\Contracts\Cache\CacheInterface, und ihr Kern ist eine einzige Methode:
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
public function __construct(private CacheInterface $cache) {}
public function openInvoiceTotal(int $tenantId): Money
{
return $this->cache->get(
sprintf('tenant.%d.open_invoice_total', $tenantId),
function (ItemInterface $item) use ($tenantId): Money {
$item->expiresAfter(600);
return $this->invoices->sumOpenForTenant($tenantId);
}
);
}
Der Callback läuft nur bei einem Miss. Das ältere PSR-6-Muster aus getItem(), isHit(), set(), save() ist geschwätziger und tut nichts gegen Stampedes. Die Contracts-API übernimmt das für dich, und darauf kommen wir noch zurück.
Tags sind das Feature, das aus "einem Key-Value-Store" etwas macht, über das man nachdenken kann. Injiziere stattdessen TagAwareCacheInterface, tagge Items im Callback und invalidiere per Tag, wenn sich die zugrunde liegenden Daten ändern:
$item->tag(['tenant.' . $tenantId, 'invoices']);
// später, im Handler für Rechnungsänderungen
$this->cache->invalidateTags(['tenant.' . $tenantId]);
Das ist das Muster, das ein Multi-Tenant-Dashboard handhabbar macht. Jeder gecachte Wert gehört zu einem Tenant-Tag. Wenn sich für diesen Tenant irgendetwas ändert, leerst du dessen Tag und sonst nichts. Andere Tenants behalten ihren warmen Cache. Ohne Tags landest du entweder beim Leeren von allem, was einen Stampede auslöst, oder bei einer handgepflegten Liste von Keys, die innerhalb einer Woche veraltet.
Tag-Unterstützung braucht einen Adapter, der sie implementiert. RedisTagAwareAdapter ist die richtige Wahl für Produktion. TagAwareAdapter um einen einfachen Adapter herum funktioniert, speichert Tag-Metadaten aber als zusätzliche Items und ist messbar langsamer.
Stampede-Schutz, eingebaut in die Contracts
Ein Cache Stampede entsteht, wenn ein beliebtes Item abläuft und fünfzig gleichzeitige Requests alle auf einmal einen Miss haben, alle den teuren Callback ausführen und alle dasselbe Ergebnis zurückschreiben. Bei einem Aggregat pro Tenant, das zwei Sekunden Rechenzeit braucht, können fünfzig parallele Ausführungen die Datenbank für alle lahmlegen.
Die Contracts-API bringt zwei Verteidigungen mit. Die erste ist Locking: Während ein Prozess einen Wert berechnet, warten andere Prozesse für denselben Key auf das Ergebnis, statt es erneut zu berechnen. Die zweite ist probabilistische vorzeitige Expiration. Jedes Item merkt sich, wie lange sein Callback zur Berechnung gebraucht hat, und je näher die TTL rückt, desto höher ist die Wahrscheinlichkeit, dass ein Request den Wert vorzeitig neu berechnet, innerhalb eines einzigen Requests, während alle anderen weiter den noch gültigen gespeicherten Wert lesen. Das $beta-Argument von get() steuert das. Der Standardwert 1.0 passt für fast alle, und INF erzwingt eine sofortige Neuberechnung (nützlich in einem Warm-up-Command).
Allein das ist der Grund, CacheInterface::get() mit Callback statt der PSR-6-API zu verwenden. Wer "prüfen, berechnen, speichern" im eigenen Code auf Redis nachbaut, wirft diesen Schutz weg, und das ist fast immer eine Regression.
Doctrine Result Cache und wo er sich mit dem Application Cache überschneidet
Doctrine kann das Ergebnis einer bestimmten Query cachen:
$query = $this->createQueryBuilder('i')
->where('i.tenant = :tenant')->andWhere('i.status = :status')
->setParameters(['tenant' => $tenantId, 'status' => 'open'])
->getQuery();
$query->enableResultCache(300, 'tenant_' . $tenantId . '_open_invoices');
Richte ihn auf einen Symfony Cache Pool statt auf seinen eigenen Driver, damit er die Redis-Verbindung teilt:
doctrine:
orm:
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
Die Frage, die selten gestellt wird: Braucht man das überhaupt noch, wenn man den Application Cache hat? In den meisten Fällen lautet die Antwort nein. Wenn eine Service-Methode die Query bereits in $cache->get() einpackt, bringt das erneute Cachen derselben Query in Doctrine nichts und erzeugt zwei TTLs, die sich widersprechen können. Der Doctrine Result Cache ist außerdem nicht tag-aware. Du kannst tenant_42_open_invoices nicht per Tag invalidieren, nur per Key oder durch Warten.
Seinen Platz verdient sich der Result Cache bei kleinen, geteilten Referenzdaten, die viele verschiedene Codepfade unabhängig voneinander abfragen: Währungstabellen, Plan-Definitionen, Feature-Flags. Diese Queries haben keine Tenant-Dimension, ändern sich selten und werden an Dutzenden Stellen aufgerufen, an denen ein Cache-Aufruf um jede einzelne nur Rauschen wäre.
Eine brauchbare Regel: Der Application Cache hält Domain-Ergebnisse, die Besitzer und Invalidierungs-Trigger haben. Der Doctrine Result Cache hält Referenzdaten mit kurzer TTL und ganz ohne Invalidierungslogik.
Redis-Konfiguration, die in Produktion standhält
Alles oben setzt einen gemeinsamen Store voraus, und bei mehreren Servern heißt das Redis. Die Basiskonfiguration:
framework:
cache:
app: cache.adapter.redis_tag_aware
default_redis_provider: '%env(REDIS_URL)%'
default_marshaller: cache.default_marshaller
pools:
cache.dashboard:
adapter: cache.app
default_lifetime: 600
cache.reference_data:
adapter: cache.app
default_lifetime: 3600
Ein paar Entscheidungen hier verdienen eine Erklärung.
Getrennte Pools pro Anliegen erlauben es, einen Bereich zu leeren, ohne die anderen anzufassen. bin/console cache:pool:clear cache.dashboard nach einem Deployment, das die Datenstruktur des Dashboards ändert, lässt die Referenzdaten warm. Pools bekommen außerdem ein eigenes Namespace-Präfix, sodass eine Key-Kollision zwischen zwei Features unmöglich ist.
Der Marshaller entscheidet, wie PHP-Werte in Bytes umgewandelt werden. Der Standard nutzt serialize(). Wenn die Extension igbinary installiert ist, greift DefaultMarshaller automatisch darauf zurück und erzeugt kleinere Payloads und schnelleres Unserialisieren. Bei einem Aggregat, das ein großes Array von DTOs ist, ist der Unterschied sowohl im Redis-Speicher als auch in der Request-Zeit spürbar. Ein zusätzlicher DeflateMarshaller komprimiert weiter, auf Kosten von CPU. Miss, bevor du Kompression aktivierst. Für kleine Werte ist sie ein Nettoverlust.
Wähle von Anfang an redis_tag_aware statt einfachem redis. Ein späterer Wechsel bedeutet, dass sich jedes bestehende Key-Layout ändert und der Cache beim Deploy kalt wird, was an einem vollen Montagmorgen genau der Stampede ist, den du mit diesem ganzen Artikel vermeiden wolltest.
Und schließlich: Setze eine lokale Schicht vor Redis. ChainAdapter mit einem ArrayAdapter (pro Request) oder ApcuAdapter (pro Server) beseitigt wiederholte Roundtrips für Werte, die in einem Request viele Male gelesen werden. Das Dashboard liest die Plan-Definition des Tenants an einem Dutzend Stellen. Ohne Chain sind das ein Dutzend Redis-Aufrufe pro Request.
Die Schichten für das Dashboard zusammensetzen
So teilen sich die drei Caches die Arbeit für das Multi-Tenant-Dashboard.
Die HTML-Hülle liefert Varnish mit s-maxage=300 aus, mit ESI-Fragmenten für die tenant-weiten Widgets. Diese Fragmente tragen Cache-Tags für den Tenant, und die Update-Handler für Rechnungen und Projekte purgen diese Tags. Ein Nutzer in Tenant 42 sieht Sekunden nach der Rechnungsänderung eines Kollegen ein frisches Widget. Ein Nutzer in Tenant 43 ist davon unberührt.
Die personalisierten Zahlen (zugewiesene Aufgaben, ungelesene Benachrichtigungen) werden von einem JSON-Endpoint geladen. Dieser Endpoint ist für HTTP-Zwecke als private markiert und nutzt den Application Cache mit Keys, die nach Tenant und Nutzer gescoped und mit beiden getaggt sind, mit 60 Sekunden TTL und Stampede-Schutz für den Ansturm zur vollen Stunde, wenn alle gleichzeitig ihr Dashboard öffnen.
Referenzdaten (Plan-Limits, Währungen, Status-Labels) kommen aus dem Doctrine Result Cache mit einer TTL von einer Stunde, hinter einer APCu-Chain, sodass jeder Server für diese Tabellen höchstens einmal pro Stunde Redis anfragt.
Die Invalidierung läuft in eine Richtung. Ein Domain-Event feuert, ein Subscriber invalidiert die Application-Cache-Tags für den Tenant und purgt die passenden HTTP-Cache-Tags. Niemand fasst je den Doctrine Result Cache an, weil er nichts Tenant-spezifisches enthält.
Die Fehlerbilder, die dieses Design vermeidet, sind die, die wir in Performance-Audits am häufigsten sehen: ein globaler Cache-Flush bei jedem Schreibzugriff, personalisierte Daten, die über einen falsch konfigurierten public-Header zwischen Nutzern durchsickern, und Aggregat-Queries, die bei Ablauf die Datenbank überrennen. Jedes davon ist ein Schichtungsfehler, und kein Redis-Tuning der Welt behebt einen Schichtungsfehler.
Wo du anfängst, wenn deine App nichts davon hat
Wenn du Caching nachträglich in eine bestehende Symfony-Anwendung einbaust, ist die Reihenfolge wichtig. Beginne mit dem Application Cache und Tags, weil dort meist die größten Einsparungen liegen und das Risiko am geringsten ist. Den Stampede-Schutz bekommst du über die Contracts-API gratis dazu. Dann miss, und füge HTTP-Caching nur für die Endpoints hinzu, die den Profiler weiterhin dominieren. Der Doctrine Result Cache kommt zuletzt, und nur für Referenzdaten.
Wenn die Codebasis so weit gewachsen ist, dass niemand mehr sicher weiß, welche Schicht was cacht, ist das eine häufige und lösbare Situation. Wir machen diese Art von Arbeit regelmäßig im Rahmen von Legacy-Code-Optimierung und größeren Projekten in der individuellen Softwareentwicklung. Schreib an hello@wolf-tech.io oder schau auf wolf-tech.io vorbei, dann sprechen wir darüber, wie dein konkreter Request-Pfad aussieht.

