Von der versteckten Query-Explosion zum Eager-Loading-Fix
Ein einzelner Listen-Query gefolgt von einer Query pro Element im Loop wirkt im Entwicklungsalltag harmlos, verwandelt sich unter Produktionslast jedoch in hunderte oder tausende Datenbankaufrufe pro Seitenaufruf. Dieser Artikel zeigt, wie sich das N+1-Query-Problem in ORM- und Collection-Code erkennt, mit Eager Loading und Batch-Queries beheben und mit Profiling-Tools dauerhaft überwachen lässt.
Inhaltsverzeichnis
- 1. Was das N+1-Query-Problem ist und warum es so teuer wird
- 2. Wie sich N+1 in ORM- und Collection-Code versteckt
- 3. Vorher/Nachher: ein konkretes PHP-Beispiel
- 4. Eager-Loading-Strategien: JOIN und Batch-Loading
- 5. Eager Loading vs. Lazy Loading: die Abwägung
- 6. N+1 erkennen: Query-Logging und Profiling-Tools
- 7. N+1 in Magento-spezifischen Kontexten
- 8. Caching als Ergänzung, nicht als Ersatz
- 9. Prävention: Code Review und automatisierte Query-Zähler-Tests
- 10. Zusammenfassung
- 11. FAQ
1. Was das N+1-Query-Problem ist und warum es so teuer wird
Das N+1-Query-Problem beschreibt ein wiederkehrendes Antipattern beim Zugriff auf relationale Daten: Eine erste Query lädt eine Liste von N Datensätzen, zum Beispiel alle Bestellungen einer Seite. Anschließend läuft im Code eine Schleife über diese Liste, und für jeden einzelnen Datensatz wird eine weitere Query nachgeladen, etwa um den zugehörigen Kunden oder die Versandadresse zu holen. Statt zwei Queries insgesamt entstehen N+1 Queries: eine für die Liste, N für die Details. Bei zehn Datensätzen fällt das kaum auf. Bei tausend Datensätzen summiert sich der Overhead zu einer spürbaren Verzögerung, die in Lasttests oder im Live-Betrieb oft erst mit steigenden Nutzerzahlen sichtbar wird.
Der Grund, warum dieses Muster so verbreitet ist, liegt in der Bequemlichkeit moderner Datenzugriffs-APIs. Eine Zeile wie $order->getCustomer()->getName() liest sich harmlos, verbirgt aber eine vollständige Datenbank-Query hinter einem einfachen Methodenaufruf. Entwickler sehen im Code keine Query, nur einen Objektzugriff, und übersehen deshalb leicht, dass jede Iteration der Schleife einen zusätzlichen Datenbank-Roundtrip auslöst. Jede Query trägt einen konstanten Overhead durch Verbindungsaufbau, Parsing, Ausführungsplanung und Netzwerklatenz zwischen Applikation und Datenbankserver, unabhängig von der Ergebnisgröße. Multipliziert mit der Listenlänge wird aus konstantem Overhead ein linear wachsender Flaschenhals, der ganze Anfragen dominiert.
2. Wie sich N+1 in ORM- und Collection-Code versteckt
ORMs wie Doctrine oder Eloquent-ähnliche Query Builder setzen standardmäßig auf Lazy Loading: Eine Relation wird erst dann aus der Datenbank geladen, wenn tatsächlich auf sie zugegriffen wird. Das ist im Einzelfall sinnvoll, weil nicht jede geladene Entität auch jede Relation benötigt. Problematisch wird es, sobald eine Sammlung von Entitäten iteriert und dabei auf eine lazy geladene Relation zugegriffen wird. Jede Iteration löst dann ihre eigene Query aus, weil der Lazy-Proxy pro Objektinstanz entscheidet, ob die Relation schon geladen ist, nicht pro Sammlung. Das ORM selbst kann dieses Muster nicht erkennen, weil es keinen Überblick über den kompletten Schleifendurchlauf hat, sondern nur auf einzelne Zugriffe reagiert.
Magento Collections verstecken dasselbe Problem hinter einer anderen Fassade. Ein foreach über eine ProductCollection, kombiniert mit $product->getResource()->load($product) oder einem zusätzlichen Repository-Aufruf pro Produkt innerhalb der Schleife, erzeugt exakt dasselbe N+1-Muster, nur ohne klassischen ORM-Lazy-Proxy. Auch scheinbar harmlose Helper-Methoden, die intern ein Repository ansprechen, etwa zur Ermittlung des Lagerbestands oder einer EAV-Attributgruppe, reproduzieren das Muster, wenn sie pro Listenelement aufgerufen werden. Code Reviews übersehen das leicht, weil jede einzelne Zeile für sich betrachtet korrekt und idiomatisch aussieht.
3. Vorher/Nachher: ein konkretes PHP-Beispiel
Das folgende Beispiel zeigt den klassischen Fall: Eine Liste von Bestellungen wird geladen, und für jede Bestellung wird der Kundenname einzeln nachgeladen. Bei 200 Bestellungen entstehen 201 Queries, obwohl die Kundendaten problemlos in einer einzigen zusätzlichen Query mitgeladen werden könnten.
<?php
declare(strict_types=1);
// BEFORE: classic N+1 pattern - one query for the list, one query per item in the loop
final class OrderListService
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly CustomerRepositoryInterface $customerRepository
) {
}
/**
* @return array<int, array{order_number: string, customer_name: string}>
*/
public function getOrderSummaries(SearchCriteriaInterface $criteria): array
{
$orders = $this->orderRepository->getList($criteria)->getItems(); // 1 query
$summaries = [];
foreach ($orders as $order) {
// N additional queries: one customer lookup per order
$customer = $this->customerRepository->getById($order->getCustomerId());
$summaries[] = [
'order_number' => $order->getIncrementId(),
'customer_name' => $customer->getFirstname() . ' ' . $customer->getLastname(),
];
}
return $summaries; // 200 orders = 201 queries
}
}
Die Lösung besteht darin, die IDs aller benötigten Kunden vorab zu sammeln und in einer einzigen Query per IN-Klausel zu laden, bevor die Schleife beginnt. Innerhalb der Schleife greift der Code dann nur noch auf ein bereits im Speicher gehaltenes Array zu, ohne weitere Datenbankzugriffe. Aus 201 Queries werden zwei: eine für die Bestellungen, eine für alle betroffenen Kunden gemeinsam. Die Antwortzeit sinkt dabei nicht proportional zur Anzahl der eingesparten Queries, sondern oft stärker, weil auch Verbindungsoverhead und Netzwerklatenz pro Roundtrip wegfallen.
<?php
declare(strict_types=1);
// AFTER: batch load all customers in a single IN-clause query
final class OrderListService
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly CustomerRepositoryInterface $customerRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder
) {
}
/**
* @return array<int, array{order_number: string, customer_name: string}>
*/
public function getOrderSummaries(SearchCriteriaInterface $criteria): array
{
$orders = $this->orderRepository->getList($criteria)->getItems(); // 1 query
// Collect all customer IDs before entering the loop
$customerIds = array_unique(array_map(
static fn (OrderInterface $order): int => (int) $order->getCustomerId(),
$orders
));
// Single batch query using an IN-clause instead of N single lookups
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('entity_id', $customerIds, 'in')
->create();
$customers = $this->customerRepository->getList($searchCriteria)->getItems();
// Index customers by ID for O(1) in-memory lookup inside the loop
$customersById = [];
foreach ($customers as $customer) {
$customersById[$customer->getId()] = $customer;
}
$summaries = [];
foreach ($orders as $order) {
$customer = $customersById[(int) $order->getCustomerId()] ?? null; // no query here
$summaries[] = [
'order_number' => $order->getIncrementId(),
'customer_name' => $customer !== null
? $customer->getFirstname() . ' ' . $customer->getLastname()
: 'unknown',
];
}
return $summaries; // 200 orders = 2 queries total
}
}
4. Eager-Loading-Strategien: JOIN und Batch-Loading
Es gibt zwei grundsätzliche Strategien, um N+1 durch Eager Loading zu vermeiden. Die erste ist JOIN-basiertes Eager Loading: Die Relation wird direkt über einen SQL-JOIN in derselben Query mitgeladen, sodass Liste und Detail in einem einzigen Ergebnis-Set zurückkommen. Das reduziert die Query-Zahl auf eins, kann aber bei mehreren 1:n-Relationen zu einem kartesischen Produkt führen, das die Ergebnismenge unnötig aufbläht und Netzwerktraffic sowie Deduplizierungsaufwand im Anwendungscode erhöht.
Die zweite Strategie ist Batch-Loading über eine IN-Klausel, wie im vorherigen Beispiel gezeigt: Zuerst wird die Hauptliste geladen, dann werden alle benötigten Fremdschlüssel gesammelt und in einer zweiten, separaten Query gebündelt nachgeladen. Diese Strategie skaliert besser bei mehreren unabhängigen Relationen, weil jede Relation ihre eigene Batch-Query bekommt, statt sich im JOIN zu vervielfachen. Magentos addAttributeToSelect() und joinField() auf Collections implementieren im Kern genau dieses Prinzip: Attribute und verknüpfte Tabellen werden gezielt in die Haupt-Query eingebunden, statt pro Produkt separat nachgeladen zu werden.
<?php
declare(strict_types=1);
// WRONG: loads the "manufacturer" attribute per product inside the loop
$collection = $this->productCollectionFactory->create();
$collection->addAttributeToSelect(['name', 'sku']);
foreach ($collection as $product) {
// Triggers a separate EAV lookup for every product in the collection
$manufacturer = $product->getResource()->getAttribute('manufacturer')
->getFrontend()
->getValue($product);
}
// RIGHT: pull the attribute into the collection's main query up front
$collection = $this->productCollectionFactory->create();
$collection->addAttributeToSelect(['name', 'sku', 'manufacturer']);
// Join stock data via a single JOIN instead of a per-product resource load
$collection->joinField(
'qty',
'cataloginventory_stock_item',
'qty',
'product_id=entity_id',
'{{table}}.stock_id=1',
'left'
);
foreach ($collection as $product) {
$manufacturer = $product->getData('manufacturer'); // already in memory, no query
$qty = $product->getData('qty'); // already in memory, no query
}
5. Eager Loading vs. Lazy Loading: die Abwägung
Eager Loading ist kein Allheilmittel. Wer präventiv jede mögliche Relation eager lädt, um N+1 kategorisch auszuschließen, lädt oft deutlich mehr Daten als eine konkrete Anfrage tatsächlich benötigt. Eine Bestellliste, die nur Bestellnummer und Status anzeigen soll, aber vollständige Kunden-, Adress- und Positionsdaten eager mitlädt, verschwendet Speicher und Netzwerkbandbreite für Daten, die nie gerendert werden. Der Tradeoff verläuft also zwischen Query-Anzahl und geladener Datenmenge, nicht zwischen gut und schlecht.
Lazy Loading ist völlig legitim, wenn eine Relation nur in wenigen Ausnahmefällen benötigt wird, etwa in einer Detailansicht für ein einzelnes Element statt einer Liste. Dort entsteht kein N+1-Problem, weil kein Loop über N Elemente existiert, sondern höchstens ein einzelner zusätzlicher Zugriff. Die Faustregel: Lazy Loading ist unproblematisch bei Einzelobjekten, wird aber gefährlich, sobald ein Zugriff auf eine lazy Relation innerhalb einer Schleife über eine Collection steht. Genau an dieser Stelle lohnt sich gezieltes Eager Loading statt einer pauschalen Umstellung der gesamten Anwendung.
6. N+1 erkennen: Query-Logging und Profiling-Tools
N+1 lässt sich selten allein durch Code-Lesen zuverlässig erkennen, weil die Schleife und der versteckte Query-Aufruf oft in unterschiedlichen Methoden oder sogar unterschiedlichen Klassen liegen. Zuverlässiger ist es, tatsächlich ausgeführte Queries zu zählen. Magentos eingebauter Datenbank-Profiler, aktivierbar über die Umgebungsvariable MAGE_PROFILER, protokolliert jede einzelne Query mit Ausführungszeit und Aufrufstelle und macht wiederholte, nahezu identische Queries mit unterschiedlicher WHERE-ID auf einen Blick sichtbar.
Xdebug mit Function-Tracing zeigt den vollständigen Aufrufstack und deckt auf, aus welcher Methode heraus eine Query wiederholt ausgelöst wird, während Blackfire zusätzlich Query-Zahl und Zeitanteil pro Codepfad in einem Flame-Graph visualisiert und N+1-Muster oft direkt als auffällige, sich wiederholende Balken erkennen lässt. Für datenbanknahe Diagnose eignet sich das MySQL General Query Log oder der Slow Query Log mit niedrigem long_query_time-Schwellenwert: Werden dieselbe Query-Struktur mit wechselnden Parametern innerhalb weniger Millisekunden dutzende Male geloggt, ist das ein eindeutiges Signal für N+1.
# Enable the MySQL general query log temporarily to spot N+1 patterns
mysql -u root -p -e "SET GLOBAL general_log = 'ON'; SET GLOBAL general_log_file = '/var/log/mysql/general.log';"
# Reproduce the request under investigation, then inspect the log
tail -n 2000 /var/log/mysql/general.log | grep "SELECT" > queries.log
# Group near-identical queries by their normalized structure and count occurrences
sed -E 's/[0-9]+/?/g' queries.log | sort | uniq -c | sort -rn | head -n 10
# Typical N+1 signature: one query pattern repeated hundreds of times
# 487 SELECT * FROM customer_entity WHERE entity_id = ?
# versus the expected single batch query:
# 1 SELECT * FROM customer_entity WHERE entity_id IN (?, ?, ?, ...)
# Disable the general log again once done, it has noticeable overhead in production
mysql -u root -p -e "SET GLOBAL general_log = 'OFF';"
7. N+1 in Magento-spezifischen Kontexten
Magentos EAV-Modell (Entity-Attribute-Value) ist ein besonders anfälliger Kandidat für N+1, weil jedes zusätzliche Produktattribut potenziell in einer eigenen Tabelle liegt. Wird ein individuelles Attribut wie color oder manufacturer nicht über addAttributeToSelect() in die Collection eingebunden, sondern nachträglich pro Produkt über getResource()->getAttribute() geladen, entsteht klassisches N+1 direkt im Kernsystem. Auch Custom-Repositories, die intern get() statt getList() mit Suchkriterien verwenden, reproduzieren das Muster, wenn sie innerhalb eines Loops über eine ID-Liste aufgerufen werden, statt eine gebündelte Abfrage mit einem IN-Filter zu nutzen.
Ein weniger offensichtlicher Fall entsteht durch Plugins und Observer: Ein around-Plugin auf eine häufig aufgerufene Methode, das intern eine zusätzliche Query ausführt, etwa zur Prüfung einer Kundengruppe oder eines Store-spezifischen Preises, multipliziert sich automatisch mit jedem Aufruf der Originalmethode. Weil Plugins deklarativ über di.xml eingebunden werden, ist ihr Performance-Einfluss im aufrufenden Code nicht sichtbar und wird bei Code Reviews besonders leicht übersehen. Dasselbe gilt für Event-Observer, die auf ein Event reagieren, das innerhalb einer Schleife mehrfach ausgelöst wird.
8. Caching als Ergänzung, nicht als Ersatz
Ein Objekt- oder Ergebnis-Cache wie Redis kann die spürbaren Auswirkungen von N+1 kurzfristig abmildern, weil wiederholte identische Queries nach dem ersten Aufruf aus dem Cache statt aus der Datenbank bedient werden. Das reduziert die Latenz für nachfolgende Anfragen, beseitigt aber nicht die Grundursache: Die Anzahl der Cache-Lookups bleibt weiterhin proportional zur Listengröße, und beim ersten unkalten Aufruf, nach einem Cache-Flush oder bei individuellen Filterkombinationen entsteht die volle Query-Last unverändert.
Wer Caching als alleinige Lösung für N+1 einsetzt, verschiebt das Problem auf Cache-Warmup-Phasen, Deployments und Edge Cases mit geringer Trefferquote, etwa personalisierte Preise oder kundenspezifische Sortierungen, die sich naturgemäß schlecht cachen lassen. Die robustere Reihenfolge lautet deshalb: zuerst die Query-Struktur durch Eager Loading oder Batch-Loading fixen, und Caching danach zusätzlich einsetzen, um auch die reduzierte, bereits optimierte Query-Zahl weiter zu beschleunigen. Caching auf ein strukturell fehlerhaftes Zugriffsmuster zu legen, maskiert das Problem nur, bis Cache-Größe oder Trefferquote unter Last einbrechen.
9. Prävention: Code Review und automatisierte Query-Zähler-Tests
Die wirksamste Prävention beginnt im Code Review mit einer gezielten Frage: Löst dieser Codepfad innerhalb einer Schleife über eine Collection eine zusätzliche Datenbank- oder Repository-Anfrage aus? Reviewer, die diese Frage konsequent bei jedem foreach über eine Sammlung von Entitäten stellen, finden die meisten N+1-Fälle, bevor sie überhaupt gemergt werden. Ergänzend helfen Linting-Regeln oder statische Analyse-Skripte, die nach Repository- oder Resource-Model-Aufrufen innerhalb bekannter Schleifenkonstrukte suchen und diese automatisch markieren.
Noch zuverlässiger sind automatisierte Tests, die die tatsächliche Query-Zahl während eines Testlaufs zählen und gegen einen festen Schwellenwert prüfen. Ein Integrationstest, der eine Liste mit einer variablen Anzahl von Elementen lädt und behauptet, dass die Query-Zahl konstant bleibt statt mit der Listengröße zu wachsen, deckt Regressionen zuverlässig auf, lange bevor sie in Produktion sichtbar werden. Solche Tests sind besonders wertvoll in CI-Pipelines, weil sie N+1 nicht nur einmalig beheben, sondern dauerhaft gegen künftige Refactorings absichern.
<?php
declare(strict_types=1);
// Integration test that asserts query count stays constant regardless of list size
final class OrderListServiceQueryCountTest extends TestCase
{
public function testQueryCountDoesNotGrowWithOrderCount(): void
{
$queryCounter = $this->objectManager->get(QueryCountCollector::class);
$queryCounter->reset();
$this->orderListService->getOrderSummaries($this->buildCriteria(limit: 10));
$queriesForTen = $queryCounter->getCount();
$queryCounter->reset();
$this->orderListService->getOrderSummaries($this->buildCriteria(limit: 200));
$queriesForTwoHundred = $queryCounter->getCount();
// The fixed implementation issues a constant number of queries (2),
// independent of how many orders are returned
self::assertSame(
$queriesForTen,
$queriesForTwoHundred,
'Query count must not scale with result set size, this indicates an N+1 regression'
);
self::assertLessThanOrEqual(2, $queriesForTwoHundred);
}
}
Die folgende Tabelle vergleicht Query-Zahl und Antwortzeit für drei typische Listengrößen, jeweils mit dem naiven N+1-Zugriff gegenüber dem eager-geladenen Fix aus Abschnitt 3. Die Werte basieren auf einem typischen Roundtrip von wenigen Millisekunden zwischen Applikations- und Datenbankserver und verdeutlichen, warum sich das Problem erst bei wachsender Listengröße dramatisch bemerkbar macht.
| Listengröße | Naive Queries (N+1) | Naive Antwortzeit | Eager-geladene Queries | Eager-geladene Antwortzeit |
|---|---|---|---|---|
| 10 Elemente | 11 | ~45 ms | 2 | ~9 ms |
| 100 Elemente | 101 | ~410 ms | 2 | ~14 ms |
| 1000 Elemente | 1001 | ~4100 ms | 2 | ~85 ms |
Der Unterschied ist bei kleinen Listen kaum spürbar, wächst aber linear mit der Anzahl der Elemente, während die eager-geladene Variante nahezu konstant bleibt und nur durch die etwas größere IN-Klausel und das größere Ergebnis-Set minimal zunimmt. Genau dieser lineare Anstieg ist der Grund, warum N+1 in Entwicklungsumgebungen mit wenigen Testdatensätzen häufig unentdeckt bleibt und erst in Produktion mit echten Datenmengen zum spürbaren Problem wird.
Mironsoft
Performance-Audits, Query-Optimierung und Magento-Backend-Engineering
N+1-Queries in eurem Magento-Shop aufspüren und beheben?
Wir analysieren Collections, Repositories und Plugins auf N+1-Muster, beheben sie mit Eager Loading und Batch-Queries und richten Query-Count-Tests ein, die Regressionen dauerhaft verhindern.
Query-Profiling
Systematische Analyse mit DB-Profiler, Xdebug und Blackfire zur Identifikation aller N+1-Stellen
Eager-Loading-Refactoring
Collections, Repositories und EAV-Zugriffe auf Batch-Loading und JOINs umstellen
CI-Absicherung
Automatisierte Query-Count-Tests gegen zukünftige Regressionen in der Pipeline
10. Zusammenfassung
Das N+1-Query-Problem entsteht, wenn eine Liste mit einer Query geladen wird und anschließend eine Schleife pro Element eine weitere Query auslöst, statt die benötigten Daten gebündelt nachzuladen. Es versteckt sich besonders leicht in ORM-Lazy-Loading und in Magento Collections, weil einzelne Codezeilen für sich betrachtet unauffällig aussehen und der Query-Aufruf hinter einem einfachen Methodenzugriff verborgen bleibt. Die zuverlässigste Erkennung gelingt nicht durch Code-Lesen, sondern durch tatsächliches Zählen ausgeführter Queries mit dem Magento DB-Profiler, Xdebug, Blackfire oder dem MySQL Query Log.
Die Lösung liegt in gezieltem Eager Loading: IDs vor der Schleife sammeln und mit einer IN-Klausel, einem JOIN oder Magentos addAttributeToSelect()/joinField() gebündelt nachladen, statt pauschal jede Relation vorsorglich mitzuladen. Caching mildert die Symptome, behebt aber nicht die Ursache und sollte erst nach dem strukturellen Fix ergänzend eingesetzt werden. Automatisierte Tests, die die Query-Zahl gegen die Listengröße prüfen, verhindern zuverlässig, dass N+1 durch künftige Refactorings unbemerkt zurückkehrt.
Das N+1-Query-Problem erkennen und lösen - Das Wichtigste auf einen Blick
Das Muster erkennen
Ein Query pro Schleifendurchlauf statt einer gebündelten Abfrage. Sichtbar durch Query-Logging, Xdebug-Traces oder das Magento DB-Profiler-Log.
Ursache im ORM
Lazy Loading lädt Relationen pro Objektinstanz, nicht pro Sammlung. Magento Collections reproduzieren dasselbe Muster über Resource-Model-Aufrufe im Loop.
Fix: Eager & Batch Loading
IDs vor der Schleife sammeln und mit einer IN-Klausel oder addAttributeToSelect()/joinField() gebündelt nachladen.
Prävention
Code Reviews mit gezielter Frage nach Repository-Aufrufen im Loop, plus automatisierte Tests, die die Query-Zahl gegen die Listengröße prüfen.