Die Caching-Entscheidungen, die wirklich einen Unterschied machen

Fünf Caching-Entscheidungen für Symfony, die wirklich zählen: Layer, Key-Design, TTL, Invalidierung und was Sie nicht cachen sollten.

Ein leuchtend lila-blaues Cache-Steuerrad mit Layer-Beschriftungen am Rand: Database Queries, API Responses, Template Fragments, Application Cache, Full Page Cache und HTTP Cache.

Wenn ein Team das erste Mal Caching zu einer langsamen Symfony-Anwendung hinzufügt, ist das Ergebnis meistens Enttäuschung. Die Seite ist schneller, aber nur manchmal. Manche Nutzer sehen veraltete Daten. Der Deploy, der routinemäßig hätte sein sollen, verursacht zwei Tage später einen seltsamen Outage, wenn endlich eine Cache-Key-Kollision auslöst. Nach einem Monat Patches ist der Cache für mehr Incidents verantwortlich als die Datenbank, die er schützen sollte.

Ich habe dieses Muster oft genug gesehen, um sicher zu sein, wo es schiefgeht. Es liegt selten an der Technologie. Die Symfony-Cache-Komponente ist in Ordnung. Redis ist in Ordnung. Varnish ist in Ordnung. Das Problem ist, dass Caching fünf separate Entscheidungen sind und die meisten Teams es als eine behandeln.

Dieser Essay geht diese fünf Entscheidungen in der Reihenfolge durch, in der ich sie treffe, wenn ich eine langsame Anwendung einschätze. Treffen Sie sie richtig, in dieser Reihenfolge, und der Cache verdient sich seinen Platz. Treffen Sie sie falsch, in irgendeiner Reihenfolge, und Sie haben eine schnellere Art gebaut, falsch zu liegen.

Entscheidung 1: welcher Layer

Bevor Code geschrieben wird, entscheiden Sie, wo der Cache lebt. Jeder Layer hat andere Kosten, eine andere Invalidierungsgeschichte und andere Failure Modes.

Die vier Layer, die es zu betrachten lohnt, von außen nach innen:

  • HTTP-Cache (Varnish, CloudFront, Cloudflare). Cached ganze Responses. Günstig im Betrieb, skaliert horizontal, Invalidierung ist der schwere Teil. Am besten für anonymen Traffic und jede Response, die nicht vom Nutzer abhängt.
  • Anwendungs-Cache (Symfony-Cache-Komponente über Redis oder Memcached). Cached, was Ihr Code entscheidet. Am flexibelsten, am meisten Code zu schreiben. Gut für Teil-Responses, berechnete Werte, Fragmente.
  • Doctrine-Cache (Result-, Query-, Metadaten-Caches). Cached Doctrines eigene Arbeit. Einmal eingerichtet, meist unsichtbar. Gut für leselastige Anwendungen, in denen dieselben Queries wiederholt feuern.
  • PHP-Runtime-Caches (OPCache, APCu). Cached kompiliertes PHP und kleine schnelle Werte lokal zum Prozess. Günstig, schnell, sehr begrenzte Kapazität. Gut für heiße Konfiguration und geparste Metadaten.

Der Fehler, den ich am häufigsten sehe, ist, dass Teams nach dem Anwendungs-Cache greifen, wenn der HTTP-Cache den Job mit einem Zehntel des Codes erledigt hätte. Eine Homepage für einen anonymen Besucher, die 800ms zum Rendern braucht, braucht einen HTTP-Cache, keinen cleveren Redis-Layer. Umgekehrt ist ein personalisiertes Dashboard, das vom eingeloggten Nutzer abhängt, kein Kandidat für HTTP-Caching, egal wie clever die Vary-Header sind.

Der Entscheidungsbaum:

  1. Ist die Response für alle Betrachter gleich, oder kann sie es sein? HTTP-Cache.
  2. Ist ein Teil der Response für alle Betrachter gleich, der Rest aber personalisiert? HTTP-Cache für die statischen Teile, ESI oder Fragment-Caching für den Rest.
  3. Wird die Arbeit von Doctrine über Requests hinweg ohne Parameteränderung wiederholt? Doctrine-Cache.
  4. Wird ein bestimmter berechneter Wert über Nutzer hinweg mit demselben Input wiederverwendet? Anwendungs-Cache.
  5. Nichts davon? Wahrscheinlich nicht cachen. Machen Sie die zugrundeliegende Operation schneller.

Punkt fünf ist wichtig. Caching ist eine Art, Komplexität zu zahlen, um Latenz zu vermeiden. Wenn die Latenz nicht unerträglich ist, ist die Komplexität es nicht wert.

Entscheidung 2: wie Schlüssel gestaltet werden

Sobald der Layer gewählt ist, ist der Cache-Schlüssel die nächste Entscheidung, und die, die Teams am meisten unterschätzen. Ein schlechter Schlüssel erzeugt entweder unsichtbare Misses (der Cache ist voll mit fast-aber-nicht-richtigen Einträgen) oder unsichtbare Kollisionen (verschiedene Daten landen unter demselben Schlüssel). Beides sind Debugging-Albträume.

Die Regeln, denen ich folge:

Nehmen Sie jeden Input auf, der den Output beeinflusst. Locale, Tenant-ID, Währung, Rolle, Feature-Flag-Zustand. Vergessen Sie einen, und Sie servieren französische Inhalte an deutsche Nutzer oder Admin-Daten an anonyme Betrachter, bis es jemandem auffällt.

Nehmen Sie die Version der Struktur auf. Erhöhen Sie die Version, wenn sich die Form des gecacheten Werts ändert. Alte Einträge werden zu Misses statt zu in-die-falsche-Form-dekodiert-Fehlern.

Machen Sie den Schlüssel menschenlesbar. Das zukünftige Sie, das um 2 Uhr nachts ein Cache-Problem debuggt, muss in der Lage sein, einen Schlüssel anzuschauen und zu wissen, was er ist.

PHP
namespace App\Cache;

use App\Domain\Identifier\TenantId;
use Webmozart\Assert\Assert;

final readonly class ProductListingCacheKey
{
    private const VERSION = 'v3';

    public function __construct(
        private TenantId $tenantId,
        private string $locale,
        private string $currency,
        private int $page,
    ) {
        Assert::regex($locale, '/^[a-z]{2}(_[A-Z]{2})?$/');
        Assert::length($currency, 3);
        Assert::greaterThanEq($page, 1);
    }

    public function toString(): string
    {
        return \sprintf(
            'product_listing.%s.%s.%s.%s.page_%d',
            self::VERSION,
            $this->tenantId->toString(),
            $this->locale,
            \mb_strtolower($this->currency),
            $this->page,
        );
    }
}

Warum ein Value Object: Die Inputs werden einmal validiert, das Format ist zentralisiert, und einen neuen Input hinzuzufügen ist eine Änderung an einer Stelle. Die Alternative ist \sprintf('listing_%s_%s_%d', ...), verstreut über fünf Aufrufer, und das ist der Keim jedes Cache-Schlüssel-Bugs, den ich je debuggt habe.

Entscheidung 3: TTL ist ein Failure-Budget, keine Vermutung

Die meisten TTL-Diskussionen klingen wie “60 Sekunden, oder vielleicht 5 Minuten, lass uns damit anfangen und sehen”. Das ist für einen Prototyp in Ordnung. Es ist keine Strategie.

Die richtige Art, eine TTL zu wählen, ist zu fragen: “Wie lange bin ich bereit, dass diese Daten falsch sind?”

  • Eine Produktliste auf einer öffentlichen E-Commerce-Seite, alle 5 Minuten aktualisiert: wahrscheinlich in Ordnung. Kunden erwarten, dass Katalogupdates einen Moment brauchen.
  • Ein Lagerbestand auf derselben Seite: nicht in Ordnung. “Auf Lager” zu zeigen, wenn es das nicht ist, ist ein echtes Kundenproblem.
  • Ein Nutzerprofil, 1 Stunde gecacht: wahrscheinlich in Ordnung.
  • Eine Zugriffskontroll-Entscheidung, 1 Stunde gecacht: katastrophal. Der Nutzer, den Sie gerade aus der Organisation entfernt haben, hat noch 59 Minuten lang Admin-Zugriff.

Das Muster: kurze TTLs für sicherheitsrelevante oder geschäftskritische Daten, längere TTLs für den Rest.

Dann gibt es eine zweite Überlegung, die Teams übersehen: Stampede-Schutz. Wenn 1.000 gleichzeitige Requests den Cache im selben Moment verfehlen (weil der Schlüssel gerade abgelaufen ist), bekommen Sie nicht eine teure Neuberechnung, sondern 1.000, die alle denselben Wert zurückschreiben. Die Datenbank kippt um, dann füllt sich der Cache wieder, dann ist alles in Ordnung, und Sie haben einen unerklärten Outage.

Die Symfony-Cache-Komponente erledigt das mit CacheInterface::get:

PHP
namespace App\Catalog;

use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final readonly class ProductListing
{
    public function __construct(
        private CacheInterface $cache,
        private ProductRepositoryInterface $products,
    ) {
    }

    /**
     * @return list<Product>
     */
    public function forPage(int $page): array
    {
        return $this->cache->get(
            \sprintf('product_listing.v3.page_%d', $page),
            function (ItemInterface $item) use ($page): array {
                $item->expiresAfter(300);

                return $this->products->paginate($page);
            },
        );
    }
}

Der Cache-Contract erledigt das Locking intern: nur ein Prozess berechnet den Wert neu, während die anderen warten. Diese Eigenschaft allein rechtfertigt es, den Contract statt des rohen PSR-6-Interfaces zu verwenden.

Für wirklich heiße Schlüssel kombinieren Sie es mit Early Expiration:

PHP
return $this->cache->get(
    $key,
    function (ItemInterface $item) use ($page): array {
        $item->expiresAfter(300);
        $item->tag(['product_listing']);

        return $this->products->paginate($page);
    },
    2.0, // beta: erhöht die Aggressivität früher Neuberechnung bei heißen Schlüsseln
);

Der beta-Parameter steuert die probabilistische Early-Expiration. 0 deaktiviert sie, 1.0 ist der Default, und höhere Werte führen im Mittel zu früherem Refresh. Die meisten Requests treffen weiterhin den Cache; einzelne Requests lösen eine frühe Auffrischung aus, damit Nutzer nicht auf einem kalten Schlüssel auflaufen.

Entscheidung 4: Invalidierung ist ein Codepfad, kein Wunsch

Phil Karltons Spruch, dass Cache-Invalidierung eines der zwei schweren Dinge in der Informatik ist, ist ein Klischee, weil er stimmt. Die meisten Teams unterschätzen sie, indem sie Invalidierung als Nebeneffekt behandeln: “die TTL fängt es schon irgendwann”. Die TTL fängt das meiste schon irgendwann. Das “meiste” und das “irgendwann” sind, wo die Bugs leben.

Drei ehrliche Strategien, in der Reihenfolge ihrer Betriebskosten:

Nur TTL. Am günstigsten, am wenigsten korrekt. Akzeptabel, wenn Veraltetheit tolerierbar ist und die TTL kurz genug, um die Falschheit zu begrenzen. Das Bug-Muster: jemand ändert die Daten, Sie und Ihre Nutzer sind bis zu TTL-Sekunden inkonsistent.

TTL plus explizite Invalidierung beim Schreiben. Wenn der Schreibcode weiß, welche Schlüssel betroffen sind, invalidieren Sie sie. Bessere Korrektheit, mehr Code:

PHP
namespace App\Catalog;

use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface;

final readonly class ProductWriter
{
    public function __construct(
        private ProductRepositoryInterface $products,
        private TagAwareAdapterInterface $cache,
    ) {
    }

    public function update(Product $product): void
    {
        $this->products->save($product);
        $this->cache->invalidateTags(['product_listing', \sprintf('product_%s', $product->getId()->toString())]);
    }
}

Tag-basierte Invalidierung lässt Sie jeden Cache-Eintrag zielen, der das geänderte Produkt erwähnt hat, ohne jeden Schlüssel kennen zu müssen. Es kostet Sie einen tag-fähigen Adapter und etwas Cache-Speicher-Overhead. Es kauft Ihnen Korrektheit, die mit der Anzahl der Einträge skaliert.

Event-getriebene Invalidierung. Wenn der Schreibvorgang nicht weiß, welche Schlüssel betroffen sind (Änderungen, die über viele Caches ausstrahlen), hört ein Event-Subscriber auf Domain-Events und invalidiert breit:

PHP
namespace App\Catalog\EventSubscriber;

use App\Domain\Event\ProductPriceChanged;
use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

final readonly class InvalidateProductCacheOnPriceChange
{
    public function __construct(
        private TagAwareAdapterInterface $cache,
    ) {
    }

    #[AsEventListener(event: ProductPriceChanged::class)]
    public function __invoke(ProductPriceChanged $event): void
    {
        $this->cache->invalidateTags([
            'product_listing',
            \sprintf('product_%s', $event->productId->toString()),
            \sprintf('category_%s', $event->categoryId->toString()),
        ]);
    }
}

Der Tausch ist, dass der Subscriber jetzt ein Stück Infrastruktur ist, das neben den Schreibpfaden gepflegt werden muss. Wenn ein neuer Schreibpfad hinzugefügt wird, der Caches invalidieren soll, muss sich jemand erinnern, dass der Subscriber existiert. Es ist die richtige Wahl, wenn die Alternative ein Schreibpfad ist, der über jeden Cache Bescheid wissen muss, was seine eigene Art von Brüchigkeit ist.

Das Muster, das nicht funktioniert: unmittelbar vor dem Schreiben zu invalidieren. Das Fenster zwischen “Cache geleert” und “Schreibvorgang committet” ist, wo ein anderer Leser den Cache mit dem Vor-Schreib-Wert füllt, und jetzt sind Sie bis zur TTL bei veralteten Daten festgesetzt.

Invalidieren Sie nach dem committeten Schreibvorgang, nicht davor.

Entscheidung 5: was nicht zu cachen ist

Caching hat einen Preis. Der Preis ist nicht nur die Speicherung. Es ist die Komplexität, die jeden anderen Engineer dazu zwingt, zu überlegen, ob er frische Daten liest oder nicht, jeden Deploy dazu zwingt, zu überlegen, ob veraltete Daten gefährlich sind, jede Bug-Untersuchung zu zwingen, “ist das ein Cache-Problem?” zu erwägen.

Diese Kosten sind es wert für Dinge, die langsam und heiß sind. Sie sind es nicht wert für Dinge, die langsam und kalt sind, oder schnell und heiß, oder schnell und kalt.

Die Liste der Dinge, gegen deren Caching ich zurückdränge:

Alles Personalisierte, bei dem die Pro-Nutzer-Berechnung günstig ist. Der Cache-Schlüssel muss den Nutzer enthalten, was bedeutet, dass er nicht wirklich geteilt wird. Sie haben einen Pro-Nutzer-Storage-Layer ohne Nutzen gebaut.

Alles, was bereits in Mikrosekunden läuft. Eine 200-Mikrosekunden-Funktion ist nicht langsam. Sie zu cachen fügt einen Netzwerk-Roundtrip zu Redis hinzu (~500 Mikrosekunden) und spart nichts.

Alles, bei dem die Quelle der Wahrheit der Cache ist. Wenn der Cache der einzige Ort ist, an dem ein Wert lebt, ist er kein Cache mehr, sondern eine Datenbank, die bei einem Neustart Daten verliert. Das klingt offensichtlich; ich habe es dieses Jahr dreimal gesehen.

Alles, dessen Invalidierungsregeln Sie nicht in einem Satz beschreiben können. Wenn “wann muss dieser Eintrag verschwinden” schwer zu artikulieren ist, wird der Cache falsch sein, und Sie werden nicht wissen, wann.

Die Tabelle, die ich auf einem Klebezettel habe, wenn ich über einen neuen Cache nachdenke:

Frage Ja Nein
Ist die zugrundeliegende Operation langsam? Weiter Nicht cachen
Ist das Ergebnis für viele Betrachter gleich? Weiter Stark überdenken
Können Sie die Invalidierungsregel in einem Satz schreiben? Weiter Noch nicht cachen
Ist das System korrekt, wenn der Cache leer ist? Weiter Das ist eine Datenbank, behandeln Sie sie so

Drei davon müssen “Weiter” sein, bevor Caching-Code geschrieben wird.

Alles zusammen

Ein durchgespieltes Beispiel: eine Kategorieseite auf einer E-Commerce-Site, langsam, weil sie sechs Joins über ein schlecht indiziertes Schema macht.

  • Layer. Öffentlich, anonym, hoher Traffic. Der HTTP-Cache-Layer ist das primäre Werkzeug. Varnish oder CloudFront mit 60 Sekunden TTL auf der Seite selbst.
  • Schlüssel-Design. Der HTTP-Cache-Schlüssel enthält die URL, die Geräteklasse (mobil vs. desktop) via Vary und die Locale. Ein eingeloggter Nutzer-Request umgeht den Cache komplett über einen Cache-Control: private, no-store-Header, der im Controller gesetzt wird.
  • TTL. 60 Sekunden für das HTML, 1 Stunde für die zugrundeliegenden Produktbilder (die statisch genug sind). Stampede-Schutz kommt aus Varnishs grace-Modus.
  • Invalidierung. Die Kategorieseite wird via API aus Varnish gepurged bei drei Events: ein Produkt wird hinzugefügt, entfernt oder ändert seine Kategorie. Das CMS triggert den Purge als Teil seines Publish-Flows.
  • Was wir nicht gecacht haben. Die personalisierte “kürzlich angesehen”-Sidebar, die als ESI-Fragment mit eigenen Cache-Headern gerendert wird, pro Nutzer aus dem Anwendungs-Cache befüllt.

Die langsame Seite ist jetzt schnell für 99% der Betrachter, der personalisierte Anteil ist frisch, und die Invalidierungspfade sind drei Zeilen Code im Publish-Flow. Gesamte Redis-Nutzung: null. Gesamte Team-Komplexität hinzugefügt: klein. Gesamte Latenz entfernt: etwa 700ms p95.

Das ist die Form von Caching, die sich auszahlt. Die Form, die sich nicht auszahlt, ist die, in der das Team einen Redis-Layer hinzufügt, weil es einen Blogpost über Redis gelesen hat, und drei Monate später nicht mehr argumentieren kann, ob die Daten frisch sind.

Caching ist fünf Entscheidungen, nicht eine. Treffen Sie sie in dieser Reihenfolge, und der Cache verdient sich seinen Platz. Treffen Sie sie außer der Reihe oder lassen eine aus, und Sie haben einen neuen Weg ausgeliefert, dass Ihre Anwendung falsch liegt.


Wenn Ihre Anwendung Caching hat, das niemand mehr anfassen will, enthält unser Scaling-Engagement ein Cache-Audit, das jeden Caching-Layer in Ihrem Stack abbildet, die Schlüssel ohne Invalidierung identifiziert und einen Sanierungsplan für die produziert, die Incidents verursachen.

Referenzen

Bereit, Ihre Architektur in den Griff zu bekommen?

Buchen Sie ein kostenloses 30-minütiges Gespräch mit Silas. Kein Verkaufsgespräch, nur ein direkter Austausch über Ihre Herausforderungen.

Antwort in der Regel innerhalb von 24 Stunden.

Kostenloses Gespräch buchen