• Czas czytania ~7 min
  • 17.06.2022

Wymowna wydajność jest zazwyczaj głównym powodem powolnych projektów Laravela. Dużą część tego stanowi tak zwany „problem z zapytaniem N+1”. W tym artykule pokażę kilka różnych przykładów, na co należy uważać, w tym przypadki, gdy problem jest „ukryty” w nieoczekiwanych miejscach w kodzie.

Co to jest problem z zapytaniem N+1

W skrócie, dzieje się tak, gdy kod Laravela uruchamia zbyt wiele zapytań do bazy danych. Dzieje się tak, ponieważ Eloquent pozwala programistom pisać czytelną składnię z modelami, bez zagłębiania się w to, co „magia” dzieje się pod maską.

To nie tylko problem elokwentny, czy nawet Laravela: jest dobrze znany w branży deweloperskiej. Dlaczego nazywa się to „N+1”?Ponieważ w przypadku Eloquent wysyła zapytanie o JEDEN wiersz z bazy danych, a następnie wykonuje jeszcze jedno zapytanie dla KAŻDEGO powiązanego rekordu. A więc N zapytań plus sam rekord, łącznie N+1.

Aby rozwiązać ten problem, musimy z góry zapytać o powiązane rekordy, a Eloquent pozwala nam to łatwo zrobić za pomocą tak zwanego gorące ładowanie.Ale zanim przejdziemy do rozwiązań, omówmy problemy. Pokażę Ci 4 różne przypadki.


Przypadek 1. „Zwykłe” zapytanie N+1.

Ten plik można pobrać bezpośrednio z oficjalnej dokumentacji Laravela:

// app/Models/Book.php:
class Book extends Model
{
    public function author()
    {
        return $this->belongsTo(Author::class);
    }
}
 
// Then, in some Controller:
$books = Book::all();
 
foreach ($books as $book) {
    echo $book->author->name;
}

Co się tutaj dzieje? Część $book->author wykona jedno dodatkowe zapytanie do bazy danych dla każdej książki, aby uzyskać jej autora.

Stworzyłem mały projekt demonstracyjny, aby to zasymulować i zamieściłem 20 fałszywych książek z ich autorów. Spójrz na liczbę zapytań.

N+1 Przykład zapytania 1 — brak chętnych do wczytywania

Jak widać, dla 20 książek jest 21 zapytań, dokładnie N+1, gdzie N = 20.

I tak, masz rację: jeśli masz 100 książek na liście, będziesz mieć 101 zapytań do DB. Straszna wydajność, chociaż kod wydawał się „niewinny”, prawda.

Poprawka polega na załadowaniu relacji z góry, natychmiast w kontrolerze, z gorliwym ładowaniem, o którym wspomniałem wcześniej:

// Instead of:
$books = Book::all();
 
// You should do:
$books = Book::with('author')->get();

Wynik jest znacznie lepszy – tylko 2 zapytania:

N+1 Przykład zapytania 1 - Chętne ładowanie

Gdy używasz szybkiego ładowania, Eloquent pobiera wszystkie rekordy do tablicy i uruchamia JEDNO zapytanie do powiązanej tabeli bazy danych, przekazując identyfikatory z tej tablicy. A potem, za każdym razem, gdy wywołasz $book->author, ładuje on wynik ze zmiennej, która jest już w pamięci, nie ma potrzeby ponownego odpytywania bazy danych.

Teraz czekaj, zastanawiasz się, co to za narzędzie do wyświetlania zapytań?

Zawsze używaj paska debugowania. I umieszczaj fałszywe dane.

Ten dolny pasek to pakiet Laravel Debugbar. Aby z niego skorzystać, wystarczy go zainstalować:

composer require barryvdh/laravel-debugbar --dev

I to wszystko, pokaże dolny pasek na wszystkich stronach. Wystarczy włączyć debugowanie za pomocą .env zmienna APP_DEBUG=true, która jest wartością domyślną dla środowisk lokalnych.

Informacja o bezpieczeństwie: upewnij się, że po uruchomieniu projektu masz na tym serwerze APP_DEBUG=false, w przeciwnym razie zwykli użytkownicy Twojej witryny zobaczą pasek debugowania i zapytania do bazy danych, co stanowi ogromny problem z bezpieczeństwem.

Oczywiście radzę używać Laravel Debugbar we wszystkich swoich projektach. Ale to narzędzie samo w sobie nie pokaże oczywistych problemów, dopóki nie będziesz mieć więcej danych na stronach. Tak więc używanie Debugbar to tylko jedna część porady.

Ponadto polecam również posiadanie klas seedera, które generowałyby fałszywe dane.Najlepiej dużo danych, abyś mógł zobaczyć, jak Twój projekt działa „w prawdziwym życiu”, jeśli wyobrazisz sobie, że będzie się pomyślnie rozwijał w przyszłych miesiącach lub latach.

Użyj klas Factory, a następnie wygeneruj ponad 10 000 rekordów dla książek/autorów i innych modeli:

class BookSeeder extends Seeder
{
    public function run()
    {
        Book::factory(10000)->create();
    }
}

Następnie przejrzyj witrynę i zobacz, co pokazuje pasek Debugbar.

Istnieją również inne alternatywy dla paska debugowania Laravela:


Przypadek 2. Dwa ważne symbole.

Powiedzmy, że między autorami a książkami istnieje taka sama zależność maWiele i musisz podać autorów z liczbą książek dla każdej z nich.

Kod kontrolera może być:

public function index()
{
    $authors = Author::with('books')->get();
 
    return view('authors.index', compact('authors'));
}

A następnie w pliku Blade wykonujesz pętlę foreach dla tabeli:

@foreach($authors as $author)
    <tr>
        <td>{{ $author->name }}</td>
        <td>{{ $author->books()->count() }}</td>
    </tr>
@endforeach

Wygląda legalnie, prawda? I to działa. Ale spójrz na dane paska debugowania poniżej.

N+1 Przykład zapytania 2 — słaba wydajność

Ale poczekaj, możesz powiedzieć, że używamy szybkiego ładowania, Author::with('books'), więc dlaczego pojawia się tak wiele zapytań?

Ponieważ w Blade, $author->books()->count() w rzeczywistości nie ładuje tej relacji z pamięci.

  • $author->books() means the METHOD of relation
  • $author->books means the DATA eager loaded into memory

Tak więc metoda relacji przeszukiwała bazę danych dla każdego autora. Ale jeśli załadujesz dane bez symboli (), z powodzeniem użyje gorliwie załadowanych danych:

N+1 Przykład zapytania 2 — dobra wydajność

Uważaj więc na to, czego dokładnie używasz — metodę relacji lub dane.

Zauważ, że w tym konkretnym przykładzie istnieje jeszcze lepsze rozwiązanie. Jeśli potrzebujesz tylko obliczonych zagregowanych danych relacji, bez pełnych modeli, powinieneś załadować tylko dane zagregowane, takie jak withCount:

// Controller:
$authors = Author::withCount('books')->get();
 
// Blade:
{{ $author->books_count }}

N+1 Przykład zapytania 2 — Najlepsza wydajność

W rezultacie do bazy danych będzie tylko JEDNO zapytanie, a nie dwa zapytania. A także pamięć nie zostanie „zanieczyszczona” danymi relacji, więc trochę pamięci RAM zostanie również zaoszczędzone.


Przypadek 3.Relacja „ukryta” w programie Accessor.

Weźmy podobny przykład: listę autorów z kolumną, czy autor jest aktywny: „Tak” lub „Nie”. Aktywność ta jest definiowana przez to, czy autor ma co najmniej jedną książkę i jest obliczana jako akcesor wewnątrz modelu Author.

Kod kontrolera może być:

public function index()
{
    $authors = Author::all();
 
    return view('authors.index', compact('authors'));
}

Plik kasetowy:

@foreach($authors as $author)
    <tr>
        <td>{{ $author->name }}</td>
        <td>{{ $author->is_active ? 'Yes' : 'No' }}</td>
    </tr>
@endforeach

To „jest_aktywne” jest zdefiniowane w modelu Eloquent:

use Illuminate\Database\Eloquent\Casts\Attribute;
 
class Author extends Model
{
    public function isActive(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->books->count() > 0,
        );
    }
}

Uwaga: jest to nowa składnia akcesorów Laravel, przyjęta w Laravel 9. Możesz także użyć starsza składnia definiowania metody getIsActiveAttribute(), będzie działać również w najnowszej wersji Laravela.

Więc mamy załadowaną listę autorów i ponownie spójrz, co pokazuje Debugbar:

N+1 Przykład zapytania 3 — słaba wydajność

Tak, możemy go rozwiązać, chętnie ładując książki do kontrolera. Ale w tym przypadku moja ogólna rada to unikanie używania relacji w akcesorach.Ponieważ akcesor jest zwykle używany podczas wyświetlania danych, aw przyszłości ktoś inny może go użyć w innym pliku Blade, a Ty nie będziesz mieć kontroli nad wyglądem tego kontrolera.

Innymi słowy, Accessor ma być wielokrotnego użytku metodą formatowania danych, więc nie masz kontroli nad tym, kiedy i jak zostaną ponownie użyte.


20 książek, 21 zapytań do bazy danych. Znowu dokładnie N+1.

Więc pakiet ma złą pracę pod względem wydajności? Cóż, nie, ponieważ oficjalna dokumentacja mówi, jak pobrać pliki multimedialne dla jednego konkretnego obiektu modelu, dla jednej książki, ale nie dla listy. Ta część listy, którą musisz sam rozgryźć.

Jeśli zagłębimy się nieco głębiej, w cecha InteractsWithMedia pakietu, znajdziemy tę zależność, która jest automatycznie uwzględniana we wszystkich modelach:

Tak więc, jeśli chcemy, aby wszystkie pliki multimedialne były chętnie ładowane książkami, musimy dodać with() do naszego kontrolera:

To jest wynik wizualny, tylko 2 zapytania.

use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
 
class Book extends Model implements HasMedia
{
    use HasFactory, InteractsWithMedia;
 
    // ...
}

N+1 Przykład zapytania 4 — Dobra wydajność

public function index()
{
    $books = Book::all();
 
    return view('books.index', compact('books'));
}

Ponownie, to jest przykład, aby nie pokazywać tego pakietu jako złego, ale z radą, że musisz sprawdzać zapytania bazy danych przez cały czas, niezależnie od tego, czy pochodzą z twojego kodu, czy z zewnętrznego pakietu.

@foreach($books as $book)
    <tr>
        <td>
            {{ $book->title }}
        </td>
        <td>
            <img src="{{ $book->getFirstMediaUrl() }}" />
        </td>
    </tr>
@endforeach

Wbudowane rozwiązanie przeciwko zapytaniom N+1

Teraz, po omówieniu wszystkich 4 przykładów, dam ci ostatnią wskazówkę: od Laravel 8.43, framework ma wbudowany detektor zapytań N+1!

Oprócz paska debugowania Laravela do sprawdzenia, możesz dodać kod, aby zapobiec temu problemowi.

Musisz dodać dwie linie kodu do app/Providers/AppServiceProvider.php:

Teraz, jeśli uruchomisz jakąkolwiek stronę zawierającą problem z zapytaniem N+1, zobaczysz stronę błędu, mniej więcej tak:

Zapytanie N+1 — zapobieganie leniwemu ładowaniu

public function media(): MorphMany
{
    return $this->morphMany(config('media-library.media_model'), 'model');
}

To pokaże dokładny „niebezpieczny” kod, który możesz chcieć naprawić i zoptymalizować.

// Instead of:
$books = Book::all();
 
// You should do:
$books = Book::with('media')->get();

Pamiętaj, że ten kod powinien być wykonywany tylko na komputerze lokalnym lub serwerach testowych/pomostowych, użytkownicy na żywo na serwerach produkcyjnych nie powinni widzieć tego komunikatu, ponieważ byłby to problem z bezpieczeństwem.Dlatego musisz dodać warunek, taki jak ! app()->isProduction(), co oznacza, że ​​wartość APP_ENV w pliku .env nie jest „produkcyjna”.

Co ciekawe, ta prewencja nie zadziałała w przypadku ostatniego przykładu Biblioteki multimediów. Nie jestem pewien, czy to dlatego, że pochodzi z zewnętrznego pakietu, czy z powodu relacji polimorficznych.


use Illuminate\Database\Eloquent\Model;
 
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Model::preventLazyLoading(! app()->isProduction());
    }
}

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

O

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

O autorze CrazyBoy49z
WORK EXPERIENCE
Kontakt
Ukraine, Lutsk
+380979856297