granularer als klassisches Full Page Caching
Magentos eingebautes Full Page Caching setzt auf eindeutige URLs und HTML-Antworten, die sich vollständig als eine Einheit cachen und über Tags invalidieren lassen. GraphQL bricht dieses Modell auf: Ein einziger POST-Endpunkt, beliebig kombinierbare Query-Strings, Variablen und teils personalisierte Felder erzeugen eine Vielzahl unterschiedlicher Antworten, die sich nicht mehr sauber über eine URL identifizieren lassen. Wer Magento headless oder als API-Backend für ein eigenes Frontend betreibt, muss deshalb eine eigene Caching-Schicht auf Query-Ebene bauen. Redis eignet sich dafür besonders gut, weil sich Query-Antworten dort granular als eigene Schlüssel ablegen, mit präzisen Tags versehen und gezielt invalidieren lassen, ohne dass gleich der gesamte Cache geleert werden muss.
Inhaltsverzeichnis
- 1. Warum klassisches Full Page Caching bei GraphQL nicht ausreicht
- 2. Der bestehende FPC-Mechanismus für cacheable GraphQL-Antworten
- 3. Granulares Query-Level-Caching direkt in Redis aufbauen
- 4. Cache-Key-Generierung bei variablen Query-Parametern
- 5. Invalidierungsstrategie bei GraphQL-spezifischen Datenänderungen
- 6. Umgang mit personalisierten Feldern in zusammengesetzten Queries
- 7. TTL-Design und Vermeidung von Cache-Stampedes bei teuren Queries
- 8. Monitoring: Hit-Rate und Schlüssel-Kardinalität im Blick behalten
- 9. Grenzen und Fallstricke der eigenen GraphQL-Caching-Schicht
- 10. Zusammenfassung
- 11. FAQ
1. Warum klassisches Full Page Caching bei GraphQL nicht ausreicht
Magentos Full Page Cache basiert auf dem Prinzip, dass eine URL einer eindeutigen Seite entspricht und die komplette HTML-Antwort als ein zusammenhängender Block gecacht wird. Bei GraphQL gibt es diese Eins-zu-eins-Beziehung nicht mehr: Alle Anfragen laufen über denselben /graphql-Endpunkt, und erst der Body des POST-Requests, bestehend aus Query-String und Variablen, bestimmt, welche Daten tatsächlich zurückgegeben werden. Zwei Requests an dieselbe URL können also völlig unterschiedliche Antworten liefern, wodurch eine URL-basierte Cache-Strategie ins Leere läuft.
Zusätzlich verschärfen zusammengesetzte Queries das Problem: Ein einzelner GraphQL-Request kann mehrere Resolver gleichzeitig aufrufen, etwa Produktdaten, Kategoriebaum und Kundeninformationen in einer Antwort kombinieren. Ein pauschales Cachen der Gesamtantwort würde entweder häufig personalisierte Daten fälschlich mitcachen oder, aus Vorsicht, gar nichts cachen und damit Performance-Potenzial verschenken. Genau hier setzt eine granulare, Redis-basierte Caching-Schicht auf Query-Ebene an, die einzelne Antwortteile unabhängig voneinander behandelt.
2. Der bestehende FPC-Mechanismus für cacheable GraphQL-Antworten
Magento bringt bereits einen eingebauten Mechanismus mit, der bestimmte GraphQL-Antworten über den regulären Full Page Cache abdeckt: Für Resolver, die als cacheable markiert sind und keine personalisierten Felder enthalten, setzt Magento die Header Cache-Control: public, X-Magento-Tags und einen berechneten X-Magento-Cache-Id, der Store, Währung und weitere Kontextfaktoren einbezieht. Läuft Varnish oder das eingebaute, Redis-gestützte FPC davor, wird die komplette JSON-Antwort wie eine HTML-Seite behandelt und unter diesem Cache-Key abgelegt.
Dieser Mechanismus funktioniert gut für klassische Katalogabfragen, stößt aber an Grenzen, sobald eine Query mehrere Resolver mit unterschiedlicher Cachebarkeit kombiniert oder Magento headless ohne vorgeschaltetes FPC betrieben wird, etwa wenn ein eigenes Node.js- oder React-Backend direkt gegen die GraphQL-API spricht. In solchen Fällen bleibt nur eine eigene, feingranularere Caching-Schicht, die unabhängig vom HTTP-Antwort-Caching auf Query- und Feldebene arbeitet.
3. Granulares Query-Level-Caching direkt in Redis aufbauen
Statt die gesamte HTTP-Antwort zu cachen, cacht eine query-granulare Strategie das Ergebnis einzelner, klar abgegrenzter Resolver-Aufrufe, etwa eine Produktabfrage nach SKU oder eine Kategoriebaum-Abfrage nach Kategorie-ID. Jeder dieser Aufrufe erhält einen eigenen Redis-Schlüssel, unter dem ausschließlich das serialisierte Teilergebnis liegt, nicht die komplette zusammengesetzte Antwort. Das erlaubt es, eine zusammengesetzte Query aus mehreren, unabhängig gecachten Bausteinen zusammenzusetzen, von denen einige aus dem Cache kommen und andere frisch berechnet werden.
Technisch lässt sich das als eigener Data-Loader oder als Plugin auf die relevanten Resolver-Klassen umsetzen: Vor der eigentlichen Datenbeschaffung wird zunächst im Redis-Cache nachgesehen, ob ein passender Schlüssel existiert, und nur bei einem Miss wird der teure Resolver-Code, oft inklusive mehrerer Datenbankabfragen, tatsächlich ausgeführt und das Ergebnis anschließend mit einer sinnvollen TTL zwischengespeichert.
<?php
declare(strict_types=1);
namespace Mironsoft\GraphQlCache\Plugin;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\SerializerInterface;
/**
* Cacht das Teilergebnis eines Resolvers unabhängig von der
* gesamten HTTP-Antwort auf Query-Ebene in Redis.
*/
class CacheResolverResult
{
/**
* @param CacheInterface $cache Magento-Cache-Frontend, Redis-gestützt.
* @param SerializerInterface $serializer Zum (De-)Serialisieren des Teilergebnisses.
*/
public function __construct(
private readonly CacheInterface $cache,
private readonly SerializerInterface $serializer,
) {
}
/**
* Liest ein Teilergebnis aus dem Cache oder berechnet und speichert es neu.
*
* @param string $cacheKey Deterministischer Schlüssel aus Query-Fingerprint.
* @param callable $resolveCallback Liefert das Ergebnis bei einem Cache-Miss.
* @param array $tags Cache-Tags für gezielte Invalidierung.
* @return array
*/
public function resolveWithCache(string $cacheKey, callable $resolveCallback, array $tags): array
{
$cached = $this->cache->load($cacheKey);
if ($cached !== false) {
return $this->serializer->unserialize($cached);
}
$result = $resolveCallback();
$this->cache->save($this->serializer->serialize($result), $cacheKey, $tags, 3600);
return $result;
}
}
4. Cache-Key-Generierung bei variablen Query-Parametern
Der kritische Punkt jeder GraphQL-Caching-Strategie ist die Ableitung eines deterministischen Cache-Schlüssels aus Query, Variablen und Kontext. Zwei syntaktisch unterschiedliche, aber semantisch identische Queries, etwa mit vertauschter Feldreihenfolge oder zusätzlichen Whitespaces, müssen auf denselben Schlüssel abgebildet werden, sonst entstehen unnötig viele Cache-Einträge für eigentlich dieselbe Anfrage. Eine bewährte Lösung normalisiert die Query zunächst, etwa durch Parsen und erneutes, kanonisches Serialisieren des Abstract Syntax Tree, bevor daraus ein Hash gebildet wird.
Zusätzlich zum Query-Fingerprint muss der Schlüssel Store-View, Währung und, falls relevant, die Customer-Group-ID einbeziehen, da dieselbe Query je nach Kontext unterschiedliche Preise oder Sichtbarkeiten liefern kann. Kundenspezifische Variablen wie eine Customer-ID gehören dagegen bewusst nicht in den Standard-Schlüssel, sondern werden, wie im nächsten Abschnitt beschrieben, gesondert behandelt oder komplett vom Caching ausgenommen.
<?php
declare(strict_types=1);
/**
* Erzeugt einen deterministischen Cache-Schlüssel aus Query, Variablen
* und Kontextfaktoren wie Store und Währung.
*
* @param string $normalizedQuery Kanonisch serialisierte, normalisierte Query.
* @param array $variables GraphQL-Variablen der Anfrage.
* @param int $storeId Aktuelle Store-View-ID.
* @param string $currencyCode Aktueller Währungscode.
* @return string
*/
function buildGraphQlCacheKey(
string $normalizedQuery,
array $variables,
int $storeId,
string $currencyCode
): string {
ksort($variables);
$fingerprint = hash('sha256', $normalizedQuery . json_encode($variables));
return sprintf('gql_%d_%s_%s', $storeId, $currencyCode, $fingerprint);
}
5. Invalidierungsstrategie bei GraphQL-spezifischen Datenänderungen
Weil ein einzelner Redis-Schlüssel oft mehrere zusammengesetzte Resolver-Ergebnisse bündelt, reicht eine reine TTL-basierte Ablaufsteuerung selten aus. Stattdessen sollte jeder Cache-Eintrag mit denselben granularen Tags versehen werden, die Magento auch für klassisches FPC verwendet, etwa cat_p_123 für ein bestimmtes Produkt oder cat_c_45 für eine Kategorie, ergänzt um GraphQL-spezifische Tags für zusammengesetzte Resolver, die mehrere Entitäten gleichzeitig abfragen, etwa eine Produktlisten-Query mit Filterfacetten.
Beim Speichern eines Produkts oder einer Kategorie löst Magentos bestehende Indexer- und Cache-Invalidierungslogik ohnehin bereits die passenden Tags aus. Ein zusätzlicher Observer auf catalog_product_save_after oder vergleichbare Events kann diese Tags gezielt an die eigene GraphQL-Cache-Schicht weiterreichen, sodass nur betroffene Redis-Schlüssel gelöscht werden, während unveränderte Query-Ergebnisse für andere Produkte weiterhin aus dem Cache bedient werden.
6. Umgang mit personalisierten Feldern in zusammengesetzten Queries
Sobald eine Query personenbezogene Felder enthält, etwa den Warenkorb, hinterlegte Adressen oder individuelle Preisregeln eines eingeloggten Kunden, darf das betroffene Teilergebnis grundsätzlich nicht unter einem globalen, kundenunabhängigen Schlüssel landen. Eine robuste Strategie trennt Resolver in zwei Kategorien: öffentliche, store-weite Resolver, deren Ergebnis für alle Besucher identisch ist und aggressiv gecacht werden darf, und personalisierte Resolver, die entweder komplett vom Caching ausgenommen oder unter einem kundenspezifischen, kurzlebigen Schlüssel abgelegt werden.
In der Praxis bewährt sich eine Allowlist statt einer Blocklist: Nur Resolver, die explizit als cacheable markiert wurden, landen überhaupt in Redis. Alles andere wird bei jedem Request frisch aufgelöst. Dieser konservative Ansatz verhindert zuverlässig, dass durch ein übersehenes Feld versehentlich personenbezogene Daten eines Kunden an einen anderen ausgeliefert werden.
7. TTL-Design und Vermeidung von Cache-Stampedes bei teuren Queries
Sehr teure, rechenintensive Queries, etwa komplexe Facettensuchen mit vielen Filtern, sollten eine längere TTL erhalten als einfache Punktabfragen, da ihre Neuberechnung deutlich mehr Datenbank- und CPU-Last erzeugt. Läuft ein solcher Schlüssel gleichzeitig für sehr viele parallele Requests ab, kann es zu einem Cache-Stampede kommen, bei dem plötzlich zahlreiche Anfragen gleichzeitig denselben teuren Resolver auslösen, weil der Cache-Eintrag exakt im selben Moment abgelaufen ist.
Ein einfacher, wirksamer Schutz ist ein probabilistisches Early-Expiration-Verfahren, bei dem ein kleiner, zufälliger Vorlauf vor dem tatsächlichen Ablauf eine einzelne Neuberechnung auslöst, während alle anderen parallelen Requests noch den alten, aber noch gültigen Wert erhalten. Alternativ lässt sich mit SET key value NX PX ein kurzzeitiger Lock setzen, der verhindert, dass mehrere Prozesse gleichzeitig denselben teuren Resolver ausführen, während die übrigen Requests kurz auf das Ergebnis des ersten warten.
8. Monitoring: Hit-Rate und Schlüssel-Kardinalität im Blick behalten
Ohne Monitoring bleibt eine GraphQL-Cache-Schicht ein Blackbox-Experiment. Sinnvoll ist ein eigener Zähler pro Resolver-Typ, der Cache-Hits und Cache-Misses getrennt erfasst, sodass sich schnell erkennen lässt, welche Query-Muster tatsächlich von der Caching-Schicht profitieren und welche mangels Wiederverwendung praktisch immer einen Miss produzieren. Eine dauerhaft niedrige Hit-Rate bei einem bestimmten Resolver ist ein deutliches Signal, dass entweder der Cache-Key zu spezifisch generiert wird oder die betroffene Query in der Praxis kaum wiederholt aufgerufen wird.
Parallel dazu lohnt sich ein Blick auf die reine Schlüssel-Anzahl im GraphQL-Namensraum über redis-cli --scan --pattern 'gql_*' | wc -l, um eine unkontrolliert wachsende Kardinalität frühzeitig zu erkennen, etwa wenn durch unnormalisierte Variablen faktisch für jede Anfrage ein neuer, nie wiederverwendeter Schlüssel entsteht.
# Anzahl der GraphQL-Cache-Schlüssel im Namensraum zählen
redis-cli --scan --pattern 'gql_*' | wc -l
# Speicherverbrauch des GraphQL-Cache-Namensraums grob abschätzen
redis-cli --scan --pattern 'gql_*' | while read -r key; do
redis-cli memory usage "$key"
done | awk '{sum+=$1} END {print sum, "bytes"}'
9. Grenzen und Fallstricke der eigenen GraphQL-Caching-Schicht
Eine selbst gebaute Query-Level-Caching-Schicht ist zusätzlicher Code, der gewartet, getestet und bei jedem Magento-Upgrade auf Kompatibilität mit dem Resolver-Schema geprüft werden muss. Ändert sich die interne Struktur eines Resolvers, etwa durch ein neues Pflichtfeld im Schema, muss auch die Cache-Key-Generierung und die Invalidierungslogik entsprechend angepasst werden, sonst drohen veraltete oder inkonsistente Antworten.
Außerdem lohnt sich der Aufwand vor allem bei wiederkehrenden, ähnlichen Queries mit hohem Traffic, etwa bei einem Storefront-Frontend mit begrenztem, gut vorhersehbarem Query-Repertoire. Bei sehr heterogenen, dynamisch generierten Queries, wie sie manche GraphQL-Client-Bibliotheken mit automatischer Query-Zusammensetzung erzeugen, bleibt die Hit-Rate oft gering, und der zusätzliche Redis-Zugriff kann die Latenz gegenüber einem direkten Resolver-Aufruf sogar leicht erhöhen.
| Aspekt | Klassisches FPC | Redis Query-Level-Caching | Praxisrelevanz |
|---|---|---|---|
| Cache-Einheit | Komplette HTML-Seite pro URL | Einzelnes Resolver-Teilergebnis | Feinere Granularität bei GraphQL |
| Cache-Key | URL plus X-Magento-Vary | Query-Fingerprint plus Store/Währung | Braucht eigene Normalisierung |
| Invalidierung | Globale Tags pro Entität | Gleiche Tags, aber pro Teilergebnis | Weniger Überinvalidierung |
| Personalisierung | Vary-Header pro Segment | Explizite Allowlist cacheable Resolver | Höheres Fehlerrisiko ohne Allowlist |
| Geeignet für | Klassische Seitenaufrufe | Headless-Frontends mit hohem GraphQL-Traffic | Zusatzaufwand nur bei echtem Bedarf |
Mironsoft
Cache-Layer-Setup und Magento-Redis-Integration
Magento-Cache, der nicht richtig greift oder falsch konfiguriert ist?
Wir richten Redis als Cache- und Session-Backend für Magento sauber ein, tunen Speicherverbrauch und Eviction-Strategien und sorgen dafür, dass Full Page Cache und Session-Storage zuverlässig zusammenspielen.
Redis-Setup
Cache-, Session- und FPC-Backend produktionsreif für Magento konfigurieren.
Memory-Tuning
Speicherverbrauch und Eviction-Policies auf die tatsächliche Shop-Last abstimmen.
High-Availability-Setup
Redis Sentinel oder Cluster für ausfallsichere Magento-Umgebungen einrichten.
10. Zusammenfassung
Redis-GraphQL-Caching in Magento: Das Wichtigste auf einen Blick
Ausgangsproblem
Ein einzelner GraphQL-Endpunkt mit variablen Query-Strings lässt sich nicht über URL-basiertes Full Page Caching abdecken.
Lösungsansatz
Einzelne Resolver-Teilergebnisse werden granular in Redis gecacht, mit deterministischen Schlüsseln aus normalisierter Query, Variablen und Store-Kontext.
Invalidierung
Dieselben Tags wie beim klassischen FPC, ergänzt um GraphQL-spezifische Tags für zusammengesetzte Resolver, sorgen für gezieltes Löschen statt globalem Flush.
Grenzen
Lohnt sich vor allem bei wiederkehrenden Queries mit hohem Traffic, erzeugt aber zusätzlichen Wartungsaufwand und Fehlerrisiko bei personalisierten Feldern.