Zum Inhalt springen

N+1-Abfragen in Laravel: finden, bevor die Produktion es tut

Der häufigste Performance-Fehler in Laravel und der am leichtesten wieder eingeführte. Wo er sich versteckt, wie er einen Test scheitern lässt statt eine Seite, und wann Eager Loading falsch ist.

5 Min. Lesezeit

Das N+1 ist der erste Performance-Fehler, von dem in Laravel jeder erfährt, und der, den wir in der Produktion immer noch am häufigsten finden. Nicht weil Teams nicht wüssten, was er ist – sondern weil zu wissen was er ist ihn nicht davon abhält wiederzukommen.

Was er tatsächlich ist

Eine Abfrage, um eine Sammlung zu holen, und dann eine weitere pro Element darin:

$orders = Order::latest()->take(50)->get();   // 1 Abfrage
 
foreach ($orders as $order) {
    echo $order->customer->name;              // 50 Abfragen
}

Einundfünfzig Abfragen, wo zwei gereicht hätten. Auf einer lokalen Datenbank mit vierzig Zeilen kostet das nichts. In der Produktion ist es der Endpunkt, über den sich alle beschweren.

Die Behebung, die jeder kennt:

$orders = Order::with('customer')->latest()->take(50)->get();  // 2 Abfragen

Hörte der Artikel hier auf, wäre er derselbe Artikel wie alle anderen. Der interessante Teil ist, warum das in Codebasen immer wieder passiert, in denen jede Entwicklerin das oben Stehende längst weiß.

Wo er sich tatsächlich versteckt

In einem Accessor. Das ist der, der die Leute täuscht, denn die Aufrufstelle sieht nach Stringformatierung aus und nicht nach einem Datenbankzugriff:

public function getDisplayNameAttribute(): string
{
    return $this->customer->name . ' (' . $this->customer->country->code . ')';
}

Nichts an der Aufrufstelle sagt „Abfrage". Schlimmer noch: Accessoren laufen häufig beim Serialisieren, sodass ein API-Endpunkt, der aussieht als lade er eine Relation, zwei pro Zeile absetzt.

In einem Blade-Partial, das anderswo wiederverwendet wird. Das Partial wurde für eine Detailseite geschrieben, wo eine Relation zu laden eine Abfrage ist. Jemand bindet es in einer Schleife auf einer Übersichtsseite ein. Der Diff, der den Rückfall verursacht hat, enthält keine einzige Abfrage.

Hinter einer Bedingung. Die Relation wird in dem Controller vorab geladen, der profiliert wurde. Ein zweiter Controller gibt dieselbe Resource ohne das with() zurück, weil er von jemandem geschrieben wurde, der gar nicht wusste, dass die Resource eine Relation berührt.

In einer Policy. Autorisierung läuft pro Element. Eine Policy, die $user->team->settings liest, ist eine Abfrage pro Element der Sammlung, die Sie autorisieren.

In einem Job, pro Datensatz. Queue-Worker machen N+1 unsichtbar – niemand wartet auf die Seite, das einzige Symptom ist also eine Queue, die langsamer abfließt als sie sollte, und eine Datenbank mit einer Last, die niemand zuordnen kann.

Lassen Sie ihn laut scheitern, in einer Zeile

Das ist die wertvollste Änderung in diesem Artikel:

// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

Lazy Loading wirft jetzt in Entwicklung und Test eine LazyLoadingViolationException. Ein N+1 hört auf, etwas zu sein, das man auf einer Grafik bemerkt, und wird zu etwas, das in der CI scheitert, in dem Pull Request, der es eingeführt hat, mit einem Stacktrace auf die Zeile.

Die meisten Teams aktivieren es überall außer in der Produktion, denn eine übersehene Verletzung ist als langsame Seite besser als als 500. Das ist ein vernünftiger Standard, und es lohnt sich, ihn noch einmal anzusehen, sobald die Codebasis sauber ist – eine Exception in der Produktion ist der Weg, den Codepfad zu finden, den Ihre Tests nicht abdecken.

Dann sorgen Sie dafür, dass es behoben bleibt

Vorbeugung fängt den Fehler, solange die Entwicklerin ihn noch in der Hand hat. Rückfallschutz fängt ihn später. Sie wollen beides, und das Zweite sind drei Zeilen:

it('listet Bestellungen ohne N+1', function () {
    Order::factory()->count(20)->create();
 
    DB::enableQueryLog();
    $this->get('/orders')->assertOk();
 
    expect(DB::getQueryLog())->toHaveCount(4);
});

Ein Test, der eine Zahl zusichert statt einer Spanne, auf den Endpunkten, die Traffic tragen. Wenn jemand ein Eager Load entfernt, scheitert der Build mit einer Zahl, die von vier auf vierundzwanzig gegangen ist, und die Ursache steht in dem Diff, den diese Person gerade ansieht.

Der Einwand dagegen ist, dass die Zahl brüchig sei. Das ist die Funktion: Sie wollen erfahren, wenn sich die Abfragezahl ändert, und die Zusicherung anzupassen ist der Moment, in dem Sie entscheiden, ob die Änderung beabsichtigt war.

Wann Eager Loading die falsche Antwort ist

Eager Loading ersetzt N Abfragen durch eine. Manchmal ist die richtige Zahl null.

Sie brauchen nur eine Anzahl. $post->comments->count() lädt jeden Kommentar, um sie zu zählen. withCount('comments') fragt die Datenbank nach einer Zahl:

$posts = Post::withCount('comments')->get();
// $post->comments_count, keine Zeilen hydriert

Dasselbe gilt für Summen, Maxima und Existenz. withSum, withMax und whereHas halten die Arbeit im SQL.

Sie brauchen nur die neueste. Die gesamte Bestellhistorie eines Kunden zu laden, um seine letzte Bestellung zu zeigen, ist eine Relation mit einer Einschränkung und kein vollständiges Eager Load.

Sie paginieren etwas Riesiges. with() auf einer Relation mit tausenden Zeilen pro Elternteil verwandelt ein Problem in ein Speicherproblem. Zerteilen Sie es, oder bauen Sie die Abfrage so um, dass die Datenbank filtert.

Die Daten lassen sich denormalisieren. Eine beim Schreiben gepflegte Zählerspalte ist nicht unelegant – sie ist die richtige Antwort, wenn ein Wert tausendmal pro Schreibvorgang gelesen und danach sortiert wird.

Eine kurze Checkliste

  • preventLazyLoading außerhalb der Produktion an.
  • Zusicherungen auf die Abfragezahl der meistbesuchten Endpunkte.
  • Prüfen Sie Ihre Accessoren: Jeder, der eine Relation berührt, ist ein N+1-Generator in Verkleidung.
  • Sehen Sie in Policies und API-Resources nach, nicht nur in Controllern.
  • Fragen Sie vor jedem with(), ob Sie die Zeilen überhaupt brauchen oder nur eine Zahl.

Nichts davon ist schwierig. Es ist der Unterschied zwischen einer Anwendung, die schnell ist, weil jemand sie letztes Quartal profiliert hat, und einer, die schnell ist, weil sie nicht still und leise aufhören kann es zu sein.

Wenn die Abfragen schon in der Produktion sind und niemand weiß, welche davon Sie Geld kosten: das ist unsere Arbeit – und sie beginnt mit Messung statt mit einer Liste bewährter Praktiken.

Verwandte Fragen

Behebt Eager Loading immer ein N+1?
Es ersetzt viele Abfragen durch zwei, was meist richtig ist. Es ist die falsche Behebung, wenn Sie nur ein Aggregat brauchen - zehntausend verbundene Zeilen zu laden, um sie zu zählen, ist eine Abfrage statt zehntausend und immer noch weit mehr Arbeit, als die Datenbank nach einer Zahl zu fragen.
Kann man preventLazyLoading gefahrlos einschalten?
In Entwicklungs- und Testumgebungen ja, und es ist die wertvollste einzelne Zeile in diesem Artikel. In der Produktion wirft es auf einem Codepfad, den Sie übersehen haben, deshalb aktivieren es die meisten Teams überall außer dort - was den Fehler in der CI abfängt und die Produktion degradiert statt kaputt lässt.
Warum taucht das N+1 in der Entwicklung nicht auf?
Weil hundert zusätzliche Abfragen gegen eine lokale Datenbank mit vierzig Zeilen ein paar Millisekunden kosten. Der Fehler hängt nicht an der Latenz, er hängt an den Daten, und die Entwicklung ist der eine Ort, an dem die Daten klein sind.
Kann ein Paket die für uns finden?
Erkennungspakete helfen und laufen zur Laufzeit, sehen also nur die Codepfade, die Sie auch ausführen. Sie sind ein gutes Sicherheitsnetz und ein schlechter Ersatz für eine Zusicherung auf die Abfragezahl der Endpunkte, die zählen.

← Zurück zu allen Artikeln

Anrufen+1 848 272 7583WhatsApp+90 850 308 5436E-Mailinfo@codefacture.comKontaktseite