Die PDP lädt langsam. Nicht katastrophal langsam, nur leicht-zu-langsam, die Art von Langsamkeit, die die ersten paar Runden Performance-Arbeit überlebt, weil sie auf einer Standardproduktseite gut aussieht und erst auffällt, wenn jemand ein Configurable mit vielen Optionen profiliert.
Du öffnest den New-Relic-Trace. Die Render-Spanne liegt bei 1,8 Sekunden, und der Großteil davon ist MySQL. Du bohrst hinein. Es gibt 178 einzelne Queries auf dieser einen PDP, und 156 davon sehen fast identisch aus: SELECT * FROM cataloginventory_stock_item WHERE product_id = ? mit jedes Mal einer anderen ID. Achtundvierzig dieser Child-IDs gehören den Varianten des Configurables. Der Rest kommt woanders her. Willkommen bei N+1 auf einer Magento-2-Produktseite.
TL;DR
- Ein konfigurierbares Produkt mit 50 Kindern, das über
getUsedProducts()geladen wird, kostet dich im Default-Magento-Rendering-Pfad plus einer Handvoll üblicher Plugins grob drei zusätzliche Queries pro Child: ein Stock-Lookup, ein bis zwei Per-Attribut-Lookups. Das sind 150+ zusätzliche Queries pro PDP. - Die üblichen Wege, wie es schlimmer wird:
getUsedProducts()wird mehr als einmal pro Request aufgerufen, Plugins aufConfigurable::getUsedProductsiterieren die Kinder nochmal durch, und Feed-Exporter rufen$productRepository->getById($id)in einer Schleife. - Der Fix hat jedes Mal dieselbe Form: IDs vorab sammeln, alles in einer Collection mit
addFieldToFilter('entity_id', ['in' => $ids])laden,joinAttributefür die gebrauchten Attribute, und Stock im Bulk perStockItemRepository::getItems($productIds)holen. - N+1 ist einer der am leichtesten erkennbaren Bugs. Es ist auch einer der am konsequentesten ausgelieferten. Wenn du nie nachgeschaut hast, hast du ihn.
Was N+1 in Magento bedeutet
Das Muster ist älter als Magento. Du holst eine Liste von N Dingen in einer Query, dann holst du ein assoziiertes Ding pro Item, einzeln, in einer Schleife. Du landest bei 1 + N Queries, wo du mit zwei hättest auskommen können.
Magento hat zwei APIs zum Datenladen, und beide können in diese Falle geraten. Collections, die eine einzige SQL-Query für eine Batch von Zeilen absetzen, und Repositories, die für „gib mir das eine Ding mit dieser ID" gebaut sind. Die Repository-API ist bequem und liest sich am Call-Site gut. In einer Schleife gerufen, ist sie eine Katastrophe. Jedes $this->productRepository->getById($id) ist ein Product-Load, ein EAV-Attribute-Join und ein Haufen Decorator-Overhead, und du bezahlst das alles N-mal.
Die Kurzfassung: Collections sind Ihre Freunde, und jedes Mal, wenn Sie eine Schleife über IDs sehen, die eine „ById"- / „bySku"- / „ByCode"-Methode aufruft, geh bis zum Gegenbeweis davon aus, dass du einen Bug vor dir hast.
Der Haupttäter: getUsedProducts() wird mehr als einmal aufgerufen
Konfigurierbare Produkte in Magento haben einen Helper: Magento\ConfigurableProduct\Model\Product\Type\Configurable::getUsedProducts(). Er lädt die Kinder als Collection und cacht sie für den Rest des Requests auf der Configurable-Instanz. Soweit in Ordnung. Die Probleme beginnen, wenn etwas den Cache zerbricht.
Der häufigste Bruch: Plugins. Zwei oder drei Module ergänzen jeweils ein Plugin um getUsedProducts, jedes ruft die Original-Methode, um die Kinder zu holen, jedes macht „irgendetwas" mit dem Ergebnis, und jeder Aufruf landet auf einer frischen Instanz, die den Cache nicht teilt. Du endest damit, dass getUsedProducts pro Request drei- oder viermal auf demselben Configurable aufgerufen wird, und jeder Aufruf fährt die Query plus einen Haufen Per-Child-Arbeit erneut.
Eine einfache Diagnose: ein error_log mit debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2) in einem Plugin auf Magento\ConfigurableProduct\Model\Product\Type\Configurable::getUsedProducts ergänzen. Eine PDP laden. Aufrufe zählen. Einer pro Request ist gesund. Mehr als einer ist das Erste, was gefixt werden muss, weil alles weiter unten darauf multipliziert.
OX_DisplayOutOfStock zum Beispiel liefert ein Plugin auf Configurable::getUsedProducts, das das Original aufruft, die Kinder erneut iteriert, um die nicht-lieferbaren rauszufiltern, und innerhalb dieser Iteration einen Stock-Lookup pro Child fährt. Auf einer PDP mit 50 Kindern, und wenn das Vanilla-Magento-Rendering ebenfalls iteriert, hast du zwei komplette Durchläufe, bevor dein eigener Code die Liste überhaupt anfasst.
Die zweite Ebene: Stock-Lookups pro Child
Für jedes Child, das der Rendering-Pfad durchwandert, muss Magento 2 wissen: „Ist dieses Child gerade auf Lager?" Die bequeme API ist $this->_stockRegistry->getStockItem($childId). In einem foreach über 50 Kinder aufgerufen sind das 50 einzelne cataloginventory_stock_item-Queries, eine pro Child.
Der Fix ist StockItemRepository::getItems($productIds), der ein Array von Product-IDs annimmt und die Stock-Items in einer einzigen Query zurückgibt. Dieselben Daten, ein Roundtrip statt N.
1// der Bug 2foreach ($configurable->getUsedProducts() as $child) { 3 $stock = $this->stockRegistry->getStockItem($child->getId()); 4 if (!$stock->getIsInStock()) { 5 continue; 6 } 7 // ... 8} 9 10// der Fix11$childIds = array_map(fn($c) => (int) $c->getId(), $configurable->getUsedProducts());12$stockItems = $this->stockItemRepository->getItems($childIds);13$stockByProduct = [];14foreach ($stockItems as $item) {15 $stockByProduct[$item->getProductId()] = $item;16}17 18foreach ($configurable->getUsedProducts() as $child) {19 $stock = $stockByProduct[$child->getId()] ?? null;20 if ($stock === null || !$stock->getIsInStock()) {21 continue;22 }23 // ...24}
Gleiche Business-Logik, gleiche Ausgabe, eine Query statt fünfzig.
Die dritte Ebene: Attribute-Lookups pro Child
Das letzte übliche N+1 auf diesem Pfad: $child->getResource()->getAttribute('color') innerhalb derselben Schleife aufgerufen. Jeder Aufruf geht, wenn das Attribut nicht schon auf dem Child geladen ist, zurück zu eav_attribute, um die Metadaten zu lesen, und manchmal zu catalog_product_entity_varchar (oder welche Backend-Tabelle das Attribut auch hat), um den Wert zu lesen. Einmal billig. Auf 50 Kindern mit zwei Attributen pro Child sind das 100 zusätzliche Queries, die du nicht eingeplant hattest.
Der Fix ist, die Collection vorab nach den Attributen zu fragen. Zwei Formen, je nachdem, wie du die Kinder bekommen hast:
1// wenn du den Load besitzt, addAttributeToSelect auf der Collection nutzen 2$collection = $this->childCollectionFactory->create() 3 ->addAttributeToSelect(['color', 'size', 'manufacturer']) 4 ->addFieldToFilter('entity_id', ['in' => $childIds]); 5 6// wenn du aus getUsedProducts liest und den Load nicht ändern kannst, 7// die Attribute in einem Zug auf die schon geladenen Objekte zwingen 8$this->productRepository->getList( 9 $this->searchCriteriaBuilder10 ->addFilter('entity_id', $childIds, 'in')11 ->create()12);
addAttributeToSelect ergänzt das Attribut im darunterliegenden Select der Collection; die Kinder kommen mit dem Attribut vorgeladen zurück, und der getResource()->getAttribute()-Aufruf in der Schleife ist ein No-op.
Die Feed-Export-Variante
Dasselbe Muster, anderer Schauplatz. Statt einer PDP, die ein Configurable rendert, hast du einen Feed-Export-Cron-Job, der einen Google-Merchant-Center-Feed mit 10.000 Produkten erzeugt. Die Controller- oder Cron-Klasse macht grob Folgendes:
1foreach ($productIds as $id) {2 $product = $this->productRepository->getById($id);3 $parentId = $this->configurableType->getParentIdsByChild($id)[0] ?? null;4 $parent = $parentId !== null ? $this->productRepository->getById($parentId) : null;5 // Feed-Zeile bauen6}
Zehntausend Iterationen, drei productRepository->getById-Aufrufe pro Iteration, plus eine getParentIdsByChild-Query. Dreißigtausend einzelne Product-Loads. Der Cron, der in fünf Minuten fertig sein soll, läuft eine Stunde. Schlimmer noch: jeder Load bringt seine eigenen EAV-Attribute-Joins mit, und der Query-Count blüht ins Sechsstellige auf.
Der Fix ist, die Produkte in Batches über eine Collection zu laden, nicht einzeln:
1$batchSize = 500; 2foreach (array_chunk($productIds, $batchSize) as $batch) { 3 $collection = $this->productCollectionFactory->create() 4 ->addAttributeToSelect($feedAttributes) // die 5-10, die du wirklich nutzt 5 ->addFieldToFilter('entity_id', ['in' => $batch]); 6 7 $stockItems = $this->stockItemRepository->getItems($batch); 8 9 foreach ($collection as $product) {10 // Feed-Zeile bauen, $stockItems[$product->getId()] für den Stock lesen11 }12}
Batching ist wichtig. Ein einzelnes addFieldToFilter('entity_id', ['in' => $tenThousandIds]) erzeugt eine SQL-IN (...)-Klausel, die die meisten MySQL-Server akzeptieren, aber manche verschlucken sich daran. 500 auf einmal ist die langweilige, sichere Größe.
Der Fix: eine Query pro N Dinge, nicht N Queries pro Ding
Die Vorlage für jeden N+1-Fix in Magento hat dieselben drei Schritte.
- IDs, die du brauchst, vor der Schleife sammeln. Meistens hast du sie schon auf dem Parent-Objekt oder im initialen Query-Ergebnis.
- Alles in einer oder zwei Queries per Collection oder bulk-fähiger Repository-Methode vorladen. Das Ergebnis per ID in einem schlichten PHP-Array vor-indexieren.
- Die Schleife über die ursprüngliche Liste laufen lassen und die vorgeladenen Daten aus dem Array lesen, statt die Per-Item-API zu rufen.
Fast jeder Bulk-Performance-Fix in Magento 2 läuft auf diese Form hinaus. Feed-Exports, Report-Builder, PDP-Rendering-Pfade und im Grunde jeder heiße Code-Pfad, der eine Liste von Kindern mit Per-Child-Lookups durchläuft. Der Fix ist immer: „bewege den Lookup aus der Schleife heraus".
Vorher und nachher, mit Belegen
Zurück zu unserem 50-Kinder-Configurable.
Vorher: 1 Query zum Laden des Configurables, 1 für getUsedProducts (mal wie oft die Plugins ihn erneut rufen, nennen wir es drei), 50 Stock-Queries, 100 Attribute-Queries (zwei pro Child), plus weitere 25 Queries für Dinge wie URL-Rewrites, Category-Membership und Image-Galleries. Grob 180 Queries und rund 1,5 Sekunden PDP-TTFB, der Großteil MySQL-Roundtrips.
Nachher: 1 Query für das Configurable, 1 für die Children-Collection mit den nötigen Attributen gejoint, 1 für Bulk-Stock über getItems, plus die restlichen Overhead-Queries. Grob 30 Queries und unter 400 ms MySQL-Zeit.
Die Zahlen sind nicht chirurgisch. Sie landen irgendwo in einem Bereich, abhängig von der Modul-Last des Shops, der Größe des Attribute-Sets und den Marketplace-Erweiterungen mit eigenen Plugins auf dem Configurable-Type. Die Form ist aber verlässlich. Die drei N+1-Ebenen auf einer belebten PDP mit vielen Varianten zu fixen, bewegt die TTFB um ungefähr eine Sekunde, und das ist für echte Kunden sichtbar.
Weiterführende Artikel
- Varnish-Hit-Rate unter 30 % auf Magento 2 diagnostizieren. Die meisten Shops entdecken N+1, weil die PDP sich langsam anfühlt. Das Erste, was jeder prüft, ist die Cache-Seite, und die hat oft ihre eigenen unabhängigen Probleme. Fahren Sie diesen Audit zuerst, und du weißt, ob du auf einen Cache-Miss oder einen echten PHP-Bottleneck schaust.
Die N+1-Erkennungs-Checkliste
-
01
Query-Count auf einem bekannt-kranken Pfad als Baseline
New Relic, Tideways oder bin/magento dev:profiler:enable, dann den Request beobachten. Eine PDP mit 40+ Kindern, die mehr als 50 SQL-Queries absetzt, ist fast sicher N+1-lastig. Die Zahl notieren.
-
02
Nach getById und getBySku in Schleifen greppen
grep -rn --include='*.php' -E '(productRepository|categoryRepository|stockRegistry)->\w+ById' app/code/. Jeder Treffer innerhalb eines foreach- oder for-Blocks ist ein Kandidat. Lesen, bestätigen.
-
03
Jedes Plugin auf Configurable::getUsedProducts auditieren
grep -rn --include='*.xml' 'getUsedProducts' app/code/ vendor/. Mehr als ein Plugin von mehr als einem Vendor ist ein starkes Signal, dass die Methode mehrfach pro Request aufgerufen wird, was alles dahinter multipliziert.
-
04
Per-Child-Stock-Lookups auf getItems umziehen
Jedes foreach mit $stockRegistry->getStockItem($id) innendrin durch einen einzigen StockItemRepository::getItems($ids)-Aufruf vor der Schleife ersetzen, Ergebnis per Product-ID vor-indexieren.
-
05
Per-Child-Attribute-Lookups auf den Collection-Load umziehen
addAttributeToSelect auf der Collection, die die Kinder produziert hat. Wenn die Kinder aus getUsedProducts kommen und Sie den Load nicht anfassen können, die Daten über einen searchCriteria-Aufruf statt per-Child-Resource-Reads neu hydrieren.
-
06
Feed-Exports und Reports batchen
Niemals getById über mehr als eine Handvoll IDs in einer Schleife. array_chunk in 500er-Batches, jede Batch als Collection mit nur den Attributen, die der Feed braucht, Bulk-Stock pro Batch vorladen.
-
07
Nach jedem Fix neu profilen
Gleicher Trace, gleicher Pfad. Query-Count sollte auf dem PDP-Pfad um Faktor fünf bis zehn fallen, auf Feed-Export-Pfaden um Faktor hundert oder mehr. TTFB sollte etwa um die vorherige MySQL-Zeit sinken.
-
08
Einen CI-Guard ergänzen
Eine statische Analyseregel oder ein einfacher Test, der den Query-Log für einen PDP-Request dumpt und scheitert, wenn der Count einen sinnvollen Schwellwert überschreitet (40 für eine Configurable-PDP ist großzügig). Günstige Versicherung gegen das nächste Plugin, das den Bug wieder einbaut.
N+1 ist selten das einzige, was deinen Shop bremst
Wir fahren fokussierte Magento-2-Performance-Audits. Du bekommst eine Query-Count-Baseline, die konkrete Offender-Liste pro Pfad und einen konkreten Batch-Loading-Refactor-Plan. Zwei bis drei Tage. Direkt am Code.