0Pricing
PHP Academy · Lekcja

Wydajność: N+1 i DataLoader

Grupuj i buforuj rozwiązywanie pól, aby zachować szybkość

Wydajność: N+1 i DataLoader to bezpłatna lekcja PHP Academy na CoddyKit. To lekcja 4 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej PHP Academy, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs PHP Academy zawiera 4 lekcji w sumie.

Cichy zabójca: N+1

Największą pułapką wydajnościową GraphQL jest problem zapytania N+1. Jest niezauważalny, ponieważ każdy resolver z osobna wygląda niewinnie — ale zagnieżdżenie listy i pola pobieranego dla każdego elementu powoduje wykonanie jednego zapytania dla listy oraz po jednym zapytaniu dla każdego elementu. Dla 100 elementów daje to 101 odwołań do bazy. W tej lekcji pokazano, jak ograniczyć ich liczbę do kilku zapytań wsadowych za pomocą DataLoader.

Konkretny przykład N+1

Rozważmy { posts { author { name } } }. Resolver posts wykonuje jedno zapytanie. Następnie dla każdego posta resolver author wykonuje własne zapytanie. Naiwny kod poniżej wyraźnie pokazuje ten koszt.

<?php
// 1 query for posts...
$posts = [['id'=>1,'author_id'=>7],['id'=>2,'author_id'=>7],['id'=>3,'author_id'=>9]];

$queries = 1;
foreach ($posts as $post) {
    // ...then 1 query PER post to fetch its author
    $queries++;
    // SELECT * FROM users WHERE id = $post['author_id']
}
echo "Total DB queries: {$queries}\n"; // Total DB queries: 4

Kluczowa obserwacja: grupowanie według poziomu

GraphQL rozwiązuje pola poziomami. Wszystkie resolvery author dla listy postów są uruchamiane w tym samym kroku wykonania. Jeśli można odroczyć każde wyszukiwanie autora, zebrać żądane identyfikatory, a następnie wykonać pojedyncze WHERE id IN (...), liczba zapytań zmniejsza się z N do jednego. Takie odroczenie zapewnia właśnie GraphQL\Deferred.

<?php
$ids = [7, 7, 9];
$unique = array_values(array_unique($ids));
// One query instead of three:
echo 'SELECT * FROM users WHERE id IN (' . implode(',', $unique) . ")\n";
// SELECT * FROM users WHERE id IN (7,9)

Minimalny bufor/loader

Oto istota DataLoader: bufor gromadzący identyfikatory, ładujący je raz za pomocą funkcji wsadowej i udostępniający wyniki z pamięci podręcznej. Ten sam klucz zażądany dwukrotnie zostanie załadowany tylko raz — to automatyczna deduplikacja.

<?php
class UserLoader {
    private array $queue = [];
    private array $cache = [];
    public function __construct(private \Closure $batchFn) {}

    public function add(int $id): void { $this->queue[$id] = true; }

    public function loadOnce(): void {
        $missing = array_diff(array_keys($this->queue), array_keys($this->cache));
        if ($missing) {
            foreach (($this->batchFn)(array_values($missing)) as $id => $row) {
                $this->cache[$id] = $row;
            }
        }
        $this->queue = [];
    }

    public function get(int $id): mixed { return $this->cache[$id] ?? null; }
}

Podłączanie do resolvera

Resolver author umieszcza identyfikator w kolejce i zwraca obiekt Deferred. graphql-php uruchamia wszystkie odroczone operacje po zakończeniu bieżącego poziomu, więc w chwili wykonania domknięcia w kolejce znajdują się już identyfikatory wszystkich autorów z całej listy. Jedno wywołanie loadOnce() uruchamia pojedyncze zapytanie wsadowe.

<?php
use GraphQL\Deferred;

$authorField = [
    'type' => $userType,
    'resolve' => function ($post, $args, $context) {
        /** @var UserLoader $loader */
        $loader = $context['userLoader'];
        $loader->add($post['author_id']);
        return new Deferred(function () use ($loader, $post) {
            $loader->loadOnce();              // batches across all posts
            return $loader->get($post['author_id']);
        });
    },
];

Korzystanie z biblioteki overblog/dataloader

Rzadko trzeba implementować to samodzielnie. overblog/dataloader-php to dojrzały port biblioteki DataLoader firmy Facebook. Należy przekazać mu funkcję wsadową, która otrzymuje tablicę kluczy i musi zwrócić promise z wartościami w tej samej kolejności. Biblioteka obsługuje buforowanie, deduplikację i rozwiązywanie promise.

composer require overblog/dataloader-php

Tworzenie DataLoader

Kontrakt funkcji wsadowej jest ścisły: dla danych [k1, k2, k3] musi ona rozwiązać się do [v1, v2, v3], zachowując pozycje elementów. Należy zindeksować wiersze z bazy danych według klucza i odwzorować z powrotem kolejność danych wejściowych, aby brakujące klucze stały się wartościami null.

<?php
use Overblog\DataLoader\DataLoader;
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
use Overblog\PromiseAdapter\Adapter\WebonyxGraphQLSyncPromiseAdapter;

$adapter = new WebonyxGraphQLSyncPromiseAdapter(new SyncPromiseAdapter());

$userLoader = new DataLoader(function (array $ids) use ($adapter, $db) {
    $rows = $db->usersByIds($ids);          // SELECT ... WHERE id IN (...)
    $byId = [];
    foreach ($rows as $r) { $byId[$r['id']] = $r; }
    // MUST return values in the SAME ORDER as $ids
    $ordered = array_map(fn($id) => $byId[$id] ?? null, $ids);
    return $adapter->createFulfilled($ordered);
}, $adapter);

Rozwiązywanie za pomocą loadera

W resolverze wystarczy wywołać $loader->load($id), które zwraca promise. graphql-php, za pośrednictwem adaptera, zbiera te promise i automatycznie uruchamia funkcję wsadową na końcu kroku. Nie jest potrzebne ręczne buforowanie.

<?php
$authorField = [
    'type' => $userType,
    'resolve' => fn($post, $args, $context) =>
        $context['userLoader']->load($post['author_id']),
];

Czas życia w ramach żądania ma kluczowe znaczenie

DataLoaders buforują dane według klucza, dlatego muszą być tworzone od nowa dla każdego żądania. Loader współdzielony między żądaniami udostępniałby nieaktualne dane i powodował wyciek pamięci. Loadery należy tworzyć podczas konstruowania żądania $context i usuwać po jego zakończeniu.

<?php
// Per request: brand new loaders, attached to context
function buildContext($db, $currentUser): array {
    return [
        'db' => $db,
        'user' => $currentUser,
        'userLoader' => makeUserLoader($db),  // fresh, not a singleton
        'postLoader' => makePostLoader($db),
    ];
}

Inne zabezpieczenia wydajnościowe

DataLoader eliminuje problem odczytów N+1, ale złośliwe lub nieprzemyślane zapytanie nadal może powodować problemy. Warto dodać:

  • Ograniczanie głębokości zapytania — reguła QueryDepth odrzuca patologicznie zagnieżdżone zapytania.
  • Złożoność zapytania — QueryComplexity przypisuje budżet kosztu do każdego pola.
  • Utrwalone zapytania — należy zezwalać wyłącznie na operacje ze znanej listy dozwolonych.
  • Paginacja — nie należy nigdy rozwiązywać list o nieograniczonym rozmiarze; trzeba używać połączeń opartych na kursorach.
<?php
use GraphQL\Validator\Rules\QueryDepth;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\DocumentValidator;

DocumentValidator::addRule(new QueryDepth(10));
DocumentValidator::addRule(new QueryComplexity(200));

Pomiar korzyści

Zawsze należy mierzyć poprawę. W teście należy opakować warstwę bazy danych tak, aby zliczała zapytania, a następnie sprawdzić, czy wersja wsadowa wykonuje ograniczoną liczbę zapytań niezależnie od rozmiaru listy. Chroni to przed regresjami, w których ktoś dodaje naiwny zagnieżdżony resolver i po cichu ponownie wprowadza problem N+1.

<?php
class CountingDb {
    public int $queries = 0;
    public function usersByIds(array $ids): array {
        $this->queries++;            // one batched call
        return array_map(fn($id) => ['id' => $id], $ids);
    }
}

$db = new CountingDb();
$db->usersByIds([7, 9, 11, 13]);     // 4 authors
echo "Queries for 4 authors: {$db->queries}\n"; // Queries for 4 authors: 1

Szybkie sprawdzenie

Dlaczego DataLoaders muszą być tworzone dla każdego żądania?

Podsumowanie

Udało się wyeliminować najgorszą pułapkę wydajnościową GraphQL:

  • Zagnieżdżone pola list powodują problem N+1: jedno zapytanie dla każdego elementu.
  • graphql-php rozwiązuje pola poziomami, więc odroczenie pozwala zebrać wszystkie klucze w jednym zapytaniu IN (...).
  • GraphQL\Deferred to podstawowy mechanizm, a overblog/dataloader-php zapewnia wsadowanie, pamięć podręczną dla każdego klucza i deduplikację.
  • Funkcja wsadowa musi zwracać wartości w tej samej kolejności co klucze wejściowe.
  • Loadery działają w ramach pojedynczego żądania; dodatkowymi zabezpieczeniami są ograniczenia głębokości i złożoności oraz paginacja.

Często zadawane pytania

Czy lekcja „Wydajność: N+1 i DataLoader” jest bezpłatna?

Tak — pełny tekst „Wydajność: N+1 i DataLoader” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu PHP Academy, przejdź na CoddyKit PRO. Kurs PHP Academy zawiera 4 lekcji w sumie.

Co nauczysz się w „Wydajność: N+1 i DataLoader”?

Grupuj i buforuj rozwiązywanie pól, aby zachować szybkość Ćwiczysz PHP Academy z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.

Czy potrzebuję doświadczenia, aby zacząć PHP Academy?

Nie wymagamy żadnego doświadczenia. PHP Academy w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 4 z 4.

Ile czasu zajmuje lekcja „Wydajność: N+1 i DataLoader”?

Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.

Czy mogę pisać i uruchamiać kod w tej lekcji PHP Academy?

Tak. Każda lekcja PHP Academy zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.

Wszystkie lekcje w tym kursie

  1. GraphQL a REST
  2. Tworzenie schematu za pomocą graphql-php
  3. Resolvery, mutacje i subskrypcje
  4. Wydajność: N+1 i DataLoader
← Powrót do PHP Academy