im Hyvä-Theme performant halten
Langsame Bucket-Aggregationen auf der Kategorieseite kosten in Hyvä-Shops oft mehr Zeit als das eigentliche Rendering. Wer Facet-Anzahl, GraphQL-Query und Caching gezielt aufeinander abstimmt, bringt die Layered Navigation wieder auf ein Tempo, das zur restlichen Frontend-Performance passt.
Inhaltsverzeichnis
- 1. Warum Facetten-Aggregationen die Kategorieseite ausbremsen
- 2. Wie Magento Aggregationen für die Layered Navigation technisch aufbaut
- 3. Facet-Anzahl und -Sortierung in der Adminverwaltung sinnvoll begrenzen
- 4. GraphQL-Query für die Layered Navigation gezielt schlank halten
- 5. Caching-Strategie für Facet-Ergebnisse im Hyvä-Frontend
- 6. Hyvä-Implementierung: Alpine-Loading-States und Debounce bei Filter-Änderungen
- 7. Monitoring: Slow Log und Profile API zur Identifikation teurer Aggregationen
- 8. Praxisbeispiel: Messbare Verbesserung nach gezielter Reduktion
- 9. Checkliste: Facetten-Performance dauerhaft im Griff behalten
- 10. Zusammenfassung
- 11. FAQ
1. Warum Facetten-Aggregationen die Kategorieseite ausbremsen
Jede Terms-Aggregation, die Elasticsearch oder OpenSearch für ein filterbares Attribut berechnet, zählt die Treffer für jeden einzelnen Attributwert innerhalb der aktuellen Produktmenge neu. Bei einem Attribut wie Farbe mit zweihundert Werten und einer Kategorie mit mehreren tausend Produkten summiert sich das schnell zu einer teuren Operation, die bei jedem einzelnen Seitenaufruf erneut ausgeführt wird, unabhängig davon, ob sich die zugrunde liegenden Daten seit der letzten Anfrage überhaupt verändert haben.
In einem Hyvä-Theme läuft die Layered Navigation über eine GraphQL-Query, die Produktliste und Aggregationen typischerweise in einem einzigen Request abruft. Verzögert sich die Aggregation im Suchcluster, verzögert sich damit direkt die Zeit bis zum ersten Byte der gesamten Kategorieseite, denn der Resolver wartet auf die vollständige Antwort, bevor Magento überhaupt mit dem Rendern beginnen kann. Diese Kopplung macht Facetten-Performance zu einem Frontend-Problem, auch wenn die eigentliche Ursache tief im Suchcluster liegt.
2. Wie Magento Aggregationen für die Layered Navigation technisch aufbaut
Der Aggregation-Builder in Magento\Elasticsearch\SearchAdapter\Aggregation\Builder erzeugt für jedes als filterbar markierte Attribut eine eigene Terms-Aggregation innerhalb derselben Suchanfrage. Ohne explizite Begrenzung liefert der Cluster standardmäßig eine relativ große Anzahl an Buckets pro Attribut zurück, auch für Werte, die in der aktuellen Ergebnismenge nur ein oder zwei Treffer haben und für Kunden praktisch nie relevant werden.
Seit Magento 2.4.7 läuft dieselbe Aggregationslogik wahlweise gegen OpenSearch statt Elasticsearch, über eine Kompatibilitätsschicht, die die Query-DSL weitgehend unverändert lässt. Unterschiede zeigen sich vor allem im Circuit-Breaker-Verhalten und bei den Standardwerten für max_result_window, wodurch dieselbe Kategorie mit vielen Filterattributen je nach Cluster-Typ unterschiedlich schnell oder unterschiedlich fehleranfällig reagieren kann.
query LayeredNavigation($categoryId: String!) {
products(filter: { category_id: { eq: $categoryId } }) {
total_count
aggregations {
attribute_code
label
count
options {
label
value
count
}
}
}
}
3. Facet-Anzahl und -Sortierung in der Adminverwaltung sinnvoll begrenzen
Der wirksamste Hebel liegt oft nicht im Code, sondern in der Attributverwaltung: Jedes Attribut, das unter Stores > Attributes > Product auf Use in Layered Navigation gesetzt ist, erzeugt eine zusätzliche Aggregation bei jedem Kategorieaufruf. Eine ehrliche Bestandsaufnahme, welche Attribute tatsächlich zum Filtern genutzt werden und welche nur historisch aktiviert wurden, reduziert die Anzahl paralleler Aggregationen oft um die Hälfte, ohne dass Kunden einen Unterschied bemerken.
Für die verbleibenden Attribute lohnt sich zusätzlich ein Plugin, das die Anzahl zurückgegebener Optionen pro Facette serverseitig begrenzt und nach Trefferzahl sortiert, statt dem Cluster die volle unsortierte Bucket-Liste zu überlassen. So sieht der Kunde zuerst die relevantesten Werte, während der Cluster gleichzeitig weniger Daten über das Netzwerk zurückgeben muss.
<?php
declare(strict_types=1);
namespace Mironsoft\SearchOptimize\Plugin;
use Magento\Elasticsearch\SearchAdapter\Aggregation\Builder\Bucket\TermBucketBuilder;
/**
* Begrenzt die Anzahl der Bucket-Optionen pro Terms-Aggregation, um teure
* Facetten mit vielen Attributwerten spürbar zu entlasten.
*/
class LimitFacetBucketSizePlugin
{
private const MAX_BUCKET_SIZE = 20;
/**
* Setzt die maximale Bucket-Größe vor dem Aufbau der Aggregation.
*
* @param TermBucketBuilder $subject
* @param array $bucketConfig
* @param array $context
* @return array
*/
public function beforeBuild(TermBucketBuilder $subject, array $bucketConfig, array $context): array
{
$bucketConfig['size'] = self::MAX_BUCKET_SIZE;
return [$bucketConfig, $context];
}
}
4. GraphQL-Query für die Layered Navigation gezielt schlank halten
Viele Hyvä-Implementierungen fragen Produktliste und Aggregationen in exakt derselben Query ab, unabhängig davon, ob der Nutzer gerade nur einen Filter umschaltet oder eine neue Seite aufruft. Bei einem reinen Filterwechsel ändert sich die Produktliste zwar auch, doch Felder für Bilder, Beschreibung oder Varianten werden in diesem Moment gar nicht neu gerendert, weil Alpine nur den Facet-Bereich austauscht. Eine schlankere, dedizierte Query für den Filterfall reduziert sowohl die Resolver-Arbeit auf Magento-Seite als auch die Antwortgröße.
Zusätzlich lässt sich mit der @include-Direktive steuern, ob Preisbereichs-Buckets, die serverseitig aufwendig zu berechnen sind, überhaupt mitgeliefert werden. Ist das Preisfilter-Widget im aktuellen Viewport gar nicht sichtbar, etwa weil es hinter einem eingeklappten Akkordeon liegt, kann die Variable clientseitig auf false gesetzt werden, und der Cluster spart sich die Histogramm-Berechnung komplett.
query FacetsOnly($categoryId: String!, $withPriceBuckets: Boolean!) {
products(filter: { category_id: { eq: $categoryId } }) {
total_count
aggregations {
attribute_code
count
options {
label
value
count
}
}
price_range @include(if: $withPriceBuckets) {
minimum_price {
regular_price { value }
}
}
}
}
5. Caching-Strategie für Facet-Ergebnisse im Hyvä-Frontend
Der Full Page Cache greift zuverlässig nur für die unveränderte Kategorieseite ohne aktive Filter, denn jede Filterkombination erzeugt eine eigene GraphQL-Anfrage mit eigenen Variablen, die als POST-Request üblicherweise gar nicht cachefähig ist. In der Praxis bedeutet das: Sobald ein Kunde einen Filter setzt, trifft die Anfrage ungebremst auf den Suchcluster, selbst wenn dieselbe Kombination Sekunden zuvor bereits von einem anderen Kunden angefragt wurde.
Ein Redis-basierter Zwischenspeicher auf Resolver-Ebene, der Aggregationsergebnisse anhand eines Hashes aus Kategorie-ID und sortierten Filterparametern für wenige Minuten vorhält, entlastet den Cluster spürbar, ohne dass Kunden veraltete Treffer sehen. Invalidiert wird dieser Cache am einfachsten über dieselben Events, die ohnehin den Suchindex aktualisieren, etwa beim Speichern eines Produkts oder nach einem Bestandsabgleich, sodass keine zusätzliche Invalidierungslogik gepflegt werden muss.
<?php
declare(strict_types=1);
namespace Mironsoft\SearchOptimize\Model;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\SerializerInterface;
/**
* Cached Aggregationsergebnisse pro Kategorie-Filter-Kombination für eine
* kurze TTL, um wiederholte teure Terms-Aggregationen zu vermeiden.
*/
class FacetResultCache
{
private const CACHE_TAG = 'mironsoft_facet_result';
private const TTL_SECONDS = 300;
/**
* @param CacheInterface $cache
* @param SerializerInterface $serializer
*/
public function __construct(
private readonly CacheInterface $cache,
private readonly SerializerInterface $serializer,
) {
}
/**
* Liest ein gecachtes Aggregationsergebnis, sofern vorhanden.
*
* @param string $categoryId
* @param array $filters
* @return array|null
*/
public function get(string $categoryId, array $filters): ?array
{
$key = $this->buildKey($categoryId, $filters);
$cached = $this->cache->load($key);
return $cached ? $this->serializer->unserialize($cached) : null;
}
/**
* Speichert ein Aggregationsergebnis mit begrenzter Lebensdauer.
*
* @param string $categoryId
* @param array $filters
* @param array $result
* @return void
*/
public function save(string $categoryId, array $filters, array $result): void
{
$key = $this->buildKey($categoryId, $filters);
$this->cache->save($this->serializer->serialize($result), $key, [self::CACHE_TAG], self::TTL_SECONDS);
}
/**
* Baut einen deterministischen Cache-Key aus Kategorie und Filtern.
*
* @param string $categoryId
* @param array $filters
* @return string
*/
private function buildKey(string $categoryId, array $filters): string
{
ksort($filters);
return self::CACHE_TAG . '_' . $categoryId . '_' . md5(json_encode($filters));
}
}
6. Hyvä-Implementierung: Alpine-Loading-States und Debounce bei Filter-Änderungen
Klickt ein Nutzer mehrere Checkboxen kurz hintereinander an, feuert eine naive Implementierung für jeden Klick sofort eine eigene GraphQL-Anfrage mit voller Aggregationsberechnung, obwohl nur das Endergebnis des letzten Klicks tatsächlich relevant ist. Ein Debounce von etwa dreihundert Millisekunden im Alpine-Store der Facetten-Komponente reduziert die Zahl unnötiger Anfragen deutlich, ohne dass sich die Bedienung für den Nutzer träge anfühlt.
Parallel dazu lohnt sich ein AbortController, der eine noch laufende Anfrage aktiv abbricht, sobald eine neuere gestartet wird. Ohne diesen Mechanismus arbeitet der Suchcluster unter Umständen an einer Aggregation, deren Ergebnis im Frontend längst verworfen wurde, weil der Nutzer inzwischen einen anderen Filter gewählt hat, was reine Rechenzeit verschwendet, die an anderer Stelle fehlt.
<div x-data="facetNavigation()" x-init="init()">
<template x-for="facet in facets" :key="facet.attribute_code">
<div class="mb-4">
<p class="font-semibold" x-text="facet.label"></p>
<template x-for="option in facet.options" :key="option.value">
<label class="flex items-center gap-2">
<input type="checkbox" @change="toggleOption(facet.attribute_code, option.value)">
<span x-text="`${option.label} (${option.count})`"></span>
</label>
</template>
</div>
</template>
<p x-show="isLoading" class="text-sm text-slate-500">Aktualisiere Ergebnisse ...</p>
</div>
<script>
function facetNavigation() {
return {
facets: [],
isLoading: false,
abortController: null,
debounceTimer: null,
toggleOption(attributeCode, value) {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.fetchFacets(attributeCode, value), 300);
},
fetchFacets(attributeCode, value) {
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
this.isLoading = true;
fetch('/graphql', { method: 'POST', signal: this.abortController.signal })
.then((response) => response.json())
.then((data) => { this.facets = data.data.products.aggregations; })
.finally(() => { this.isLoading = false; });
},
};
}
</script>
7. Monitoring: Slow Log und Profile API zur Identifikation teurer Aggregationen
Bevor an Konfiguration oder Code geschraubt wird, lohnt sich ein Blick in den Slow Log des Suchclusters, der mit einem Schwellenwert von beispielsweise fünfzig Millisekunden für Query- und Fetch-Phase aktiviert wird. So lässt sich schnell eingrenzen, welche Kategorien und welche Attributkombinationen tatsächlich zu den langsamsten Anfragen im Livebetrieb gehören, statt auf Verdacht zu optimieren.
Für die Detailanalyse liefert die Profile-API von Elasticsearch und OpenSearch eine Aufschlüsselung der Zeit pro einzelner Aggregation innerhalb einer Anfrage. Damit lässt sich exakt bestimmen, ob ein einzelnes Attribut mit besonders vielen Werten für den Großteil der Gesamtzeit verantwortlich ist, oder ob die Summe vieler kleiner Aggregationen das eigentliche Problem darstellt, was jeweils eine ganz unterschiedliche Optimierungsstrategie nach sich zieht.
curl -s -X POST "https://search.internal:9200/catalogsearch_product_1/_search" \
-H "Content-Type: application/json" \
-d '{
"profile": true,
"size": 0,
"aggs": {
"color_bucket": { "terms": { "field": "color", "size": 200 } }
}
}' | jq '.profile.shards[0].aggregations'
8. Praxisbeispiel: Messbare Verbesserung nach gezielter Reduktion
In einem typischen Hyvä-Projekt mit vierzehn als filterbar markierten Attributen pro Kategorie ließ sich die serverseitige Aggregationszeit spürbar senken, nachdem gemeinsam mit dem Fachbereich geprüft wurde, welche Attribute tatsächlich zum Filtern genutzt werden. Sechs davon erwiesen sich als nie oder kaum verwendet, drei weitere ließen sich zu einem kombinierten Filter zusammenfassen, sodass am Ende nur fünf aktive Aggregationen pro Kategorieaufruf übrig blieben.
Kombiniert mit der Bucket-Größenbegrenzung und dem Resolver-Cache aus den vorherigen Abschnitten sank die Zeit bis zum ersten Byte auf stark frequentierten Kategorieseiten deutlich, was sich unmittelbar in den Core Web Vitals und in einer spürbar reaktionsschnelleren Layered Navigation niederschlug. Wichtig war dabei, jede Änderung zunächst auf einer Staging-Umgebung mit realistischer Kataloggröße zu verifizieren, bevor sie live ging, denn kleine Testkataloge verschleiern Aggregationsprobleme fast immer.
9. Checkliste: Facetten-Performance dauerhaft im Griff behalten
Facetten-Performance ist kein einmaliges Optimierungsprojekt, sondern eine laufende Aufgabe, die mit jedem neuen Attribut und jeder Katalogerweiterung neu bewertet werden sollte. Ein regelmäßiger Blick in Slow Log und Profile API, kombiniert mit einer festen Regel, wann ein neues Attribut überhaupt als filterbar markiert werden darf, verhindert, dass sich das Problem über die Zeit unbemerkt wieder aufbaut.
Die folgende Übersicht fasst die wichtigsten Stellschrauben aus diesem Artikel zusammen, sortiert nach Aufwand und erwarteter Wirkung, damit sich Teams zuerst auf die Maßnahmen mit dem besten Verhältnis von Aufwand zu Ergebnis konzentrieren können.
| Maßnahme | Wirkung auf Aggregationszeit | Umsetzungsaufwand | Risiko bei Unterlassung |
|---|---|---|---|
| Ungenutzte Filterattribute deaktivieren | Sehr hoch | Gering, reine Konfiguration | Wachsende Aggregationslast bei jedem neuen Attribut |
| Bucket-Größe pro Facette begrenzen | Hoch | Mittel, ein Plugin | Unnötig große Antworten und Netzwerk-Overhead |
| Resolver-Cache für Filterkombinationen | Hoch | Mittel bis hoch | Wiederholte teure Aggregationen bei populären Filtern |
| GraphQL-Query für Filterfälle aufteilen | Mittel | Mittel, Frontend-Anpassung | Unnötiger Overhead bei jedem Klick |
| Debounce und AbortController im Alpine-Store | Mittel | Gering | Überflüssige parallele Anfragen bei schnellen Klicks |
| Slow Log und Profile API dauerhaft aktiv lassen | Mittel, aber wichtig für Früherkennung | Gering | Probleme fallen erst bei Kundenbeschwerden auf |
Mironsoft
Hyvä-Theme-Entwicklung und Luma-Migration
Noch auf Luma unterwegs oder ein Hyvä-Theme, das nicht rund läuft?
Wir entwickeln Hyvä-Themes für Magento von Grund auf oder migrieren bestehende Luma-Shops sauber, mit Tailwind CSS, Alpine.js und ohne unnötiges JavaScript-Gepäck.
Luma-zu-Hyvä-Migration
Bestehenden Shop strukturiert und ohne Funktionsverlust auf Hyvä umstellen.
Custom-Theme-Entwicklung
Individuelles Hyvä-Theme nach Design-Vorgaben von Grund auf umsetzen.
Performance-Optimierung
Core Web Vitals und Ladezeiten im Hyvä-Frontend gezielt verbessern.
10. Zusammenfassung
Facetten-Performance im Hyvä-Theme
Kernproblem
Zu viele und zu große Terms-Aggregationen pro Kategorieaufruf verzögern die gesamte Seite.
Wichtigster Hebel
Ungenutzte Filterattribute deaktivieren und Bucket-Größe serverseitig begrenzen.
Frontend-Anpassung
GraphQL-Query für reine Filterwechsel schlank halten und Alpine mit Debounce sowie AbortController absichern.
Langfristig
Slow Log und Profile API regelmäßig prüfen, bevor neue Attribute als filterbar markiert werden.