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-phpTworzenie 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
QueryDepthodrzuca patologicznie zagnieżdżone zapytania. - Złożoność zapytania —
QueryComplexityprzypisuje 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\Deferredto podstawowy mechanizm, aoverblog/dataloader-phpzapewnia 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
- GraphQL a REST
- Tworzenie schematu za pomocą graphql-php
- Resolvery, mutacje i subskrypcje
- Wydajność: N+1 i DataLoader