aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Webentwicklung

Laravel N+1-Query-Problem und die Lösung

Das Laravel N+1-Query-Problem ist ein heimtückisches Performance-Problem, auf das früher oder später nahezu jedes Eloquent-Projekt stößt. Der Code sieht sauber aus, die Tests laufen durch, die Seite lädt auf der eigenen Maschine schnell — und dann sammeln sich ein paar tausend Zeilen in der Datenbank an und dieselbe Seite kriecht plötzlich. Die Ursache ist meist eine einzige Sache: ohne es zu merken Dutzende oder sogar Hunderte zusätzlicher Abfragen innerhalb einer Schleife abzufeuern. In diesem Artikel zeige ich Schritt für Schritt, warum das passiert, wie man die versteckten Abfragen mit dem Query-Log aufspürt und wie man es mit Eager Loading endgültig löst.

Was ist das N+1-Problem genau?

Der Name sagt eigentlich alles. Eine Elternabfrage (1) läuft und liefert N Datensätze zurück; danach läuft für jeden dieser N Datensätze eine weitere Abfrage (N), um die zugehörigen Daten zu holen. Das sind insgesamt 1 + N Abfragen. Wenn du 20 Beiträge auflistest und bei jedem den Autor anzeigst, geht 1 Abfrage für die Beiträge raus und 20 für die Autoren: 21 Abfragen.

Schauen wir uns das klassische Beispiel an. In einer Blog-Liste wollen wir bei jedem Beitrag den Namen des Autors anzeigen:

$posts = Post::all();

foreach ($posts as $post) {
    echo $post->author->name;
}

Die erste Zeile erzeugt eine einzige select * from posts Abfrage. Aber der Ausdruck $post->author innerhalb der Schleife löst für jeden Beitrag eine separate select * from users where id = ? Abfrage aus. Eloquent lädt die Beziehung per Lazy Loading — also in dem Moment, in dem sie zum ersten Mal angesprochen wird. So bedeuten 100 Beiträge 101 Abfragen, und das würdest du allein anhand der Ausgabe niemals bemerken.

Die versteckten Abfragen mit dem Query-Log aufspüren

Der erste Schritt zur Lösung des Problems ist, es sichtbar zu machen. Die DB-Facade von Laravel kann jede laufende Abfrage aufzeichnen. Du kannst das vorübergehend innerhalb einer Route oder eines Controllers aktivieren:

use Illuminate\Support\Facades\DB;

DB::enableQueryLog();

$posts = Post::all();
foreach ($posts as $post) {
    $post->author->name;
}

dd(DB::getQueryLog());

Siehst du 101 Einträge in der Ausgabe, ist der Übeltäter überführt. Dasselbe SQL, das sich immer wieder mit where id = ? wiederholt, ist die deutlichste Signatur von N+1.

Ein dauerhafterer Ansatz ist, jede Abfrage in einem Service Provider zu protokollieren:

DB::listen(function ($query) {
    logger()->info($query->sql, $query->bindings);
});

Setze das in die boot-Methode von AppServiceProvider und beobachte storage/logs/laravel.log, um genau zu sehen, wie viele Abfragen ein einzelner Request abfeuert. In der Entwicklung zeigen Tools wie Laravel Debugbar oder Telescope diese Zahl auch automatisch am unteren Rand jeder Seite an; statt wiederholte Abfragen mit dem Auge zu durchsuchen, bemerkst du einfach, wie der Zähler nach oben klettert.

Die Lösung: Eager Loading mit with()

Die Lösung ist überraschend einfach. Statt die Beziehung Zeile für Zeile in der Schleife zu laden, sagst du Eloquent von vornherein, „lade diese Beziehung gleich mit", während die Elternabfrage läuft. Das machst du mit with():

$posts = Post::with('author')->get();

foreach ($posts as $post) {
    echo $post->author->name;
}

Jetzt führt Eloquent nur zwei Abfragen aus: eine für die Beiträge und eine, die alle Autoren auf einmal mit select * from users where id in (1, 2, 3, ...) holt. Zwei Abfragen statt 101 für 100 Beiträge. So wird aus N+1 ein 1 + 1.

Du kannst auch mehrere Beziehungen und verschachtelte Beziehungen gleichzeitig laden:

$posts = Post::with(['author', 'comments.user', 'tags'])->get();

Hier lädt die Punkt-Syntax comments.user auch den Benutzer jedes Kommentars mit; so entsteht beim Durchlaufen der Kommentare kein neues N+1. Möchtest du die Abfrage entlasten, indem du nur bestimmte Spalten auswählst:

$posts = Post::with('author:id,name')->get();

Vorsicht: In der Spaltenauswahl musst du immer den Fremdschlüssel der Beziehung einschließen — hier id — sonst kann Eloquent die Datensätze nicht zuordnen und die Beziehung kommt als null zurück.

Weitere nützliche Techniken

  • loadMissing(): Hast du die Modelle bereits und bist dir nicht sicher, ob die Beziehung geladen ist, füllt dies die Lücken auf, ohne überflüssige Abfragen abzufeuern: $posts->loadMissing('author').
  • withCount(): Zeigst du nur die Anzahl der zugehörigen Datensätze an, hol die Anzahl in einer einzigen Abfrage, statt alle Zeilen zu laden: Post::withCount('comments')->get(). Das Ergebnis kommt als $post->comments_count an.
  • Automatischer Schutz: Laravel erlaubt dir, Lazy Loading komplett zu verbieten. Füge Model::preventLazyLoading(! app()->isProduction()) in den AppServiceProvider ein, und der Zugriff auf eine nicht eager geladene Beziehung wirft in der Entwicklung eine Exception. So fängst du N+1 schon beim Schreiben des Codes ab.

Eager Loading ist mächtig, aber entwickle nicht den Reflex, überall with() draufzuklatschen. Nutzt du eine Beziehung auf der Seite gar nicht wirklich, geht das Laden nach hinten los und bläht den Speicher auf. Die Regel ist simpel: jede Beziehung, die innerhalb einer Schleife angesprochen wird, sollte eager geladen werden; keine Beziehung, die nie angesprochen wird.

Häufige Fragen

Ist das N+1-Problem immer ein echtes Problem?

Bei kleinen Datenmengen fällt es nicht auf; 6 Abfragen für 5 Datensätze stören niemanden. Aber je größer die Daten werden, desto linearer steigt die Abfragezahl, und Hunderte Abfragen erdrücken sowohl die Datenbank als auch die Antwortzeit. Das Schlimmste ist, dass es lokal unsichtbar bleibt und in der Produktion explodiert — deshalb ist frühes Messen der richtige Ansatz.

Was ist der Unterschied zwischen with() und load()?

with() plant die Beziehung, während die Abfrage aufgebaut wird, bevor die Modelle geholt werden. load() fügt einer Sammlung, die du bereits hast, nachträglich eine Beziehung hinzu. Beide nutzen dieselbe gebündelte Abfragelogik; welche du wählst, hängt davon ab, wann du die Modelle erhalten hast.

Wie mache ich Eager Loading im Testbetrieb verpflichtend?

Aktiviere Model::preventLazyLoading() nur außerhalb der Produktion. So wirft beim Testen und Entwickeln jede Beziehung, die du eager zu laden vergisst, eine Exception, während es in der Produktion still und normal weiterläuft.

Ist deine Laravel-Anwendung langsamer als erwartet? Der Übeltäter sind oft versteckte N+1-Abfragen. Wir können dein Projekt gemeinsam profilen und die Abfragezahl senken — nimm Kontakt mit mir auf.

Bu kategorideki tüm yazılar →

Devamı için