Architektura
ArchitekturaEliminowanie "problemu n+1"

Eliminowanie "problemu n+1"

Dowiedzmy się, jak Gato GraphQL całkowicie unika «problemu n+1» już na poziomie projektu architektonicznego.

Czym jest «problem n+1»

«Problem n+1» oznacza w skrócie, że liczba queries wykonywanych na bazie danych może być równie duża jak liczba węzłów w grafie.

Co to oznacza? Sprawdźmy to na przykładzie: załóżmy, że chcemy pobrać listę reżyserów i dla każdego z nich listę jego filmów za pomocą następującej query:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
      }
    }
  }
}

Aby działać wydajnie, oczekiwalibyśmy wykonania tylko 2 queries w celu pobrania danych z bazy danych: 1 do pobrania danych reżyserów i 1 do pobrania danych wszystkich filmów wszystkich reżyserów.

Jednak aby spełnić tę query, GraphQL będzie musiał wykonać «n+1» queries na bazie danych: 1 najpierw, aby pobrać listę N reżyserów (w tym przypadku 10), a następnie dla każdego z N reżyserów 1 query w celu pobrania jego listy filmów. W naszym przypadku musimy wykonać 1+10=11 queries.

Problem ten wynika z tego, że resolvers GraphQL obsługują tylko 1 obiekt naraz, a nie wszystkie obiekty tego samego typu jednocześnie. W naszym przypadku resolver obsługujący obiekty typu Query (który jest typem głównym) zostanie wywołany raz za pierwszym razem, aby pobrać listę wszystkich obiektów Director, a następnie resolver obsługujący typ Director zostanie wywołany raz dla każdego obiektu Director, aby pobrać jego listę filmów.

Innymi słowy: resolvers GraphQL widzą drzewo, a nie las.

Problem ten jest w rzeczywistości poważniejszy, niż początkowo wygląda, ponieważ liczba węzłów w grafie rośnie wykładniczo wraz z liczbą poziomów grafu. Zatem nazwa «n+1» jest właściwa tylko dla grafu o głębokości 2 poziomów. Dla grafu o głębokości 3 poziomów powinno się go nazywać problemem «N2+n+1»! I tak dalej...

Na przykład, kontynuując powyższy przykład, dodajmy do query listę aktorów/aktorek każdego filmu, w taki sposób:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
        actors(first: 10) {
          name
        }
      }
    }
  }
}

Wówczas queries wykonywane na bazie danych to: 1 najpierw w celu pobrania listy 10 reżyserów, następnie 1 query w celu pobrania listy filmów każdego reżysera dla każdego z 10 reżyserów, i na koniec 1 query w celu pobrania listy aktorów/aktorek dla każdego z 10 filmów każdego z 10 reżyserów. Daje to łącznie 1+10+100=111 queries.

Po zaobserwowaniu tego zachowania, «problem n+1» można łatwo uznać za największą przeszkodę wydajnościową GraphQL: jeśli pozostawiony bez kontroli, odpytywanie grafów o kilku poziomach głębokości może stać się tak wolne, że GraphQL stanie się praktycznie bezużyteczny.

Ogólne rozwiązanie «problemu n+1»

Standardowe rozwiązanie «problemu n+1» zostało po raz pierwszy dostarczone przez narzędzie DataLoader. Jego strategia jest bardzo prosta: odkładanie rozwiązywania segmentów query na późniejszy etap, w którym wszystkie obiekty tego samego typu mogą być rozwiązane razem, w jednej query. Ta strategia, zwana «batchingiem», skutecznie rozwiązuje problem «n+1».

Dodatkowo DataLoader buforuje obiekty po ich pobraniu, dzięki czemu jeśli kolejna query potrzebuje załadować już załadowany obiekt, może pominąć wykonanie i pobrać obiekt z bufora. Ta strategia, zwana «cachingiem», jest głównie optymalizacją na bazie «batchingu».

Problemy z rozwiązaniem «batching/odroczone»

Technicznie rzecz biorąc, nie ma żadnego problemu ze strategią «batchingu» ani «odroczoną»: po prostu działa.

(Od tej chwili będziemy się odwoływać do tej strategii wyłącznie jako «odroczonej».)

Problem polega jednak na tym, że ta strategia jest rozwiązaniem wtórnym: programista może najpierw zaimplementować serwer, a następnie, zauważając jak wolno rozwiązywane są queries, zdecydować się na wprowadzenie mechanizmu odraczania. W związku z tym implementacja resolvers może wymagać kilku dodatkowych kroków, co wprowadza tarcie do procesu programowania. Ponadto, ponieważ programista musi rozumieć, jak działa mechanizm «odroczony», jego implementacja staje się bardziej złożona niż mogłaby być.

Problem nie leży w samej strategii, lecz w tym, że serwer GraphQL oferuje tę funkcjonalność jako dodatek, mimo że bez niej odpytywanie może być tak wolne, że GraphQL staje się praktycznie bezużyteczny.

Rozwiązanie tego problemu jest zatem proste: strategia «odroczona» nie powinna być dodatkiem, lecz być wbudowana w sam serwer GraphQL. Zamiast mieć 2 strategie wykonywania queries, «normalną» i «odroczoną», powinna istnieć tylko 1, «odroczona». Serwer GraphQL musi wykonywać mechanizm «odroczony» nawet jeśli programista implementuje resolver w sposób «normalny» (innymi słowy, serwer GraphQL zajmuje się dodatkową złożonością, a nie programista).

I dokładnie to robi Gato GraphQL.

Uczynienie strategii «odroczonej» jedyną wykonywaną przez serwer GraphQL

Problem z większością serwerów GraphQL polega na tym, że odpowiedzialność za rozwiązywanie typów obiektów (object, union i interface) jako obiektów spoczywa na samych resolvers podczas przetwarzania węzła nadrzędnego (np.: films => directors), zamiast delegowania tego zadania do silnika ładowania danych.

Gato GraphQL przenosi tę odpowiedzialność z resolverów do silnika ładowania danych serwera w następujący sposób:

  1. Resolvers zwracają ID, a nie obiekty, podczas rozwiązywania relacji między węzłami nadrzędnymi i podrzędnymi
  2. Dla danej listy ID określonego typu, jednostka DataLoader pobiera odpowiednie obiekty tego typu
  3. Silnik ładowania danych serwera jest spoiwem między tymi 2 częściami: najpierw pobiera ID obiektów od resolvers i, tuż przed wykonaniem zagnieżdżonej query dla relacji (do tego momentu zgromadzi już wszystkie ID do rozwiązania dla określonego typu), pobiera obiekty dla tych ID przez DataLoader (który może efektywnie uwzględnić wszystkie ID w jednej query).

Podejście to można streścić jako: «Operuj na ID, nie na obiektach».

Użyjmy tego samego przykładu co wcześniej, aby zobrazować to nowe podejście. Poniższa query pobiera listę reżyserów i ich filmy:

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
      }
    }
  }
}

Zwróć uwagę na 2 pola do pobrania dla każdego reżysera, name i films, i jak różnią się od siebie:

Pole name jest typu skalarnego. Jest natychmiast rozwiązywalne, ponieważ możemy oczekiwać, że obiekt typu Director zawiera właściwość typu string o nazwie name, zawierającą imię i nazwisko reżysera. W związku z tym, gdy mamy już obiekt Director, nie ma potrzeby wykonywania dodatkowej query w celu rozwiązania tej właściwości.

Pole films, natomiast, jest listą typu obiektowego. Zazwyczaj nie jest natychmiast rozwiązywalne, ponieważ odwołuje się do listy obiektów typu Film, które muszą być jeszcze pobrane z bazy danych za pomocą 1 lub więcej dodatkowych queries. W związku z tym programista musiałby zaimplementować dla niego mechanizm «odroczony».

Rozważmy teraz inne podejście i sprawmy, aby pole films było rozwiązywane jako lista ID (zamiast listy obiektów). Ponieważ możemy oczekiwać, że obiekt Director zawiera właściwość o nazwie filmIDs zawierającą ID wszystkich jego filmów, typu array of string (zakładając, że ID jest reprezentowane jako string), to pole to może być również rozwiązane natychmiast, bez konieczności implementowania mechanizmu «odroczonego».

Na koniec, poza ID, resolver musi przekazać dodatkową informację: typ oczekiwanego obiektu (w naszym przykładzie mogłoby to być [(Film, 2), (Film, 5), (Film, 9)]). Informacja ta jest jednak wewnętrzna, przekazywana do silnika, i nie musi być uwzględniana w odpowiedzi na query.

Implementacja dostosowanego podejścia w kodzie

Zobaczmy, jak Gato GraphQL implementuje to podejście w kodzie PHP. Poniższy kod demonstruje różne resolvers (dla przejrzystości cały poniższy kod został zredagowany).

FieldResolvers

FieldResolvers otrzymują obiekt określonego typu i rozwiązują jego pola. W przypadku relacji muszą również wskazać typ obiektu, do którego rozwiązują. To jest ich kontrakt:

interface FieldResolverInterface
{
  public function resolveValue($object, string $field, array $args = []);
  public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string;
}

Ich implementacja wygląda następująco:

class PostFieldResolver implements FieldResolverInterface
{
  public function resolveValue($object, string $field, array $args = [])
  {
    $post = $object;
    switch ($field) {
      case 'title':
        return $post->title;
      case 'author':
        return $post->authorID; // This is an ID, not an object!
    }
 
    return null;
  }
 
  public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string
  {
    switch ($field) {
      case 'author':
        return UserTypeResolver::class;
    }
 
    return null;
  }
}

Zwróć uwagę, jak po usunięciu logiki obsługującej promises/obiekty odroczone, kod rozwiązujący pole author stał się bardzo prosty i zwięzły.

TypeResolvers

TypeResolvers to obiekty zajmujące się określonym typem: znają nazwę typu i który TypeDataLoader ładuje obiekty tego typu, między innymi.

Silnik ładowania danych, podczas rozwiązywania pól, będzie otrzymywał ID od określonej klasy TypeResolver. Następnie, podczas pobierania obiektów dla tych ID, silnik ładowania danych zapyta TypeResolver, którego obiektu TypeDataLoader użyć do załadowania tych obiektów.

Ich kontrakt jest zdefiniowany w następujący sposób:

interface TypeResolverInterface
{
  public function getTypeName(): string;
  public function getTypeDataLoaderClass(): string;
}

W naszym przykładzie klasa UserTypeResolver definiuje, że typ User musi mieć swoje dane ładowane przez klasę UserTypeDataLoader:

class UserTypeResolver implements TypeResolverInterface
{
  public function getTypeName(): string
  {
    return 'User';
  }
 
  public function getTypeDataLoaderClass(): string
  {
    return UserTypeDataLoader::class;
  }
}

TypeDataLoaders

TypeDataLoaders otrzymują listę ID określonego typu i zwracają odpowiednie obiekty tego typu. To jest ich kontrakt:

interface TypeDataLoaderInterface
{
  public function getObjects(array $ids): array;
}

Pobieranie użytkowników odbywa się w następujący sposób:

class UserTypeDataLoader implements TypeDataLoaderInterface
{
  public function getObjects(array $ids): array
  {
    $userAPI = UserAPIFacade::getInstance();
    return $userAPI->getUsers($ids);
  }
}

Wykonywanie (naprawdę) dużej query

Sprawdźmy, czy ta strategia działa. Przejdź do klienta GraphiQL w Gato GraphQL i wykonaj poniższą query, która obejmuje graf o głębokości 10 poziomów (posts => author => posts => tags => posts => comments => author => posts => comments => author) i która nie mogłaby zostać rozwiązana w rozsądnym czasie, gdyby «problem n+1» miał miejsce.

query {
  posts(pagination:{ limit:10 }) {
    excerpt
    title
    url
    author {
      name
      url
      posts(pagination:{ limit:10 }) {
        title
        tags(pagination:{ limit:10 }) {
          slug
          url
          posts(pagination:{ limit:10 }) {
            title
            comments(pagination:{ limit:10 }) {
              content
              date
              author {
                name
                posts(pagination:{ limit:10 }) {
                  title
                  url
                  comments(pagination:{ limit:10 }) {
                    content
                    date
                    author {
                      name
                      username
                      url
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Przewijając wyniki, zobaczymy, jak duża jest odpowiedź, ile podmiotów obejmuje i ile poziomów zostało pobranych, a mimo to została wykonana natychmiast, bez żadnych trudności.