Architektur
ArchitekturDas "n+1-Problem" unterdrücken

Das "n+1-Problem" unterdrücken

Lass uns herausfinden, wie Gato GraphQL das „n+1-Problem" bereits durch seine Architektur vollständig vermeidet.

Was ist das „n+1-Problem"?

Das „n+1-Problem" bedeutet im Wesentlichen, dass die Anzahl der gegen die Datenbank ausgeführten queries so groß sein kann wie die Anzahl der Knoten im Graph.

Was bedeutet das? Schauen wir es uns an einem Beispiel an: Angenommen, wir möchten eine Liste von Regisseuren abrufen und für jeden von ihnen seine Filme – mit der folgenden query:

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

Um effizient zu sein, würden wir erwarten, nur 2 queries gegen die Datenbank auszuführen: 1, um die Daten der Regisseure zu holen, und 1, um die Daten aller Filme aller Regisseure abzurufen.

Um diese query zu erfüllen, muss GraphQL jedoch „n+1" queries gegen die Datenbank ausführen: zunächst 1, um die Liste der N Regisseure (hier 10) abzurufen, und dann für jeden der N Regisseure 1 query, um seine Filmliste zu holen. In unserem Fall müssen wir 1+10=11 queries ausführen.

Dieses Problem entsteht, weil GraphQL-Resolver immer nur 1 Objekt auf einmal verarbeiten und nicht alle Objekte desselben Typs gleichzeitig. In unserem Fall wird der Resolver, der Objekte des Typs Query (dem Root-Typ) verarbeitet, einmal aufgerufen, um die Liste aller Director-Objekte zu holen – und anschließend wird der Resolver für den Typ Director für jedes einzelne Director-Objekt aufgerufen, um seine Filmliste abzurufen.

Mit anderen Worten: GraphQL-Resolver sehen den Baum, nicht den Wald.

Dieses Problem ist in Wirklichkeit schlimmer als es zunächst erscheint, denn die Anzahl der Knoten in einem Graph wächst exponentiell mit der Anzahl der Ebenen. Der Name „n+1" gilt also nur für einen Graph mit 2 Ebenen. Bei einem Graph mit 3 Ebenen sollte man es das „N2+n+1"-Problem nennen! Und so weiter...

Folgend unserem obigen Beispiel fügen wir der query auch noch die Liste der Schauspieler/Schauspielerinnen jedes Films hinzu:

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

Die gegen die Datenbank ausgeführten queries sind dann: zunächst 1, um die Liste der 10 Regisseure abzurufen, dann 1 query pro Regisseur für jeden der 10 Regisseure, um seine Filmliste zu holen, und schließlich 1 query pro Filmliste für jeden der 10 Filme jedes der 10 Regisseure. Das ergibt insgesamt 1+10+100=111 queries.

Wenn man dieses Verhalten einmal bemerkt hat, kann man das „n+1-Problem" leicht als das größte Performance-Hindernis von GraphQL betrachten: Wird es unkontrolliert gelassen, kann das Abfragen von Graphen mit einigen Ebenen so langsam werden, dass GraphQL praktisch unbrauchbar wird.

Allgemeine Lösung für das „n+1-Problem"

Die Standardlösung für das „n+1-Problem" wurde erstmals durch das Hilfsprogramm DataLoader bereitgestellt. Seine Strategie ist sehr einfach: Die Auflösung von Teilen der query wird auf eine spätere Phase verschoben, in der alle Objekte desselben Typs gemeinsam in einer einzigen query aufgelöst werden können. Diese Strategie, „Batching" genannt, löst das „n+1-Problem" effektiv.

Darüber hinaus speichert DataLoader abgerufene Objekte im Cache, sodass eine nachfolgende query, die ein bereits geladenes Objekt benötigt, die Ausführung überspringen und das Objekt stattdessen aus dem Cache holen kann. Diese Strategie, „Caching" genannt, ist hauptsächlich eine Optimierung on top of „Batching".

Probleme mit der „Batching/Deferred"-Lösung

Rein technisch gesehen gibt es keinerlei Problem mit der „Batching"- oder „Deferred"-Strategie: Sie funktioniert einfach.

(Von nun an bezeichnen wir die Strategie nur noch als „Deferred".)

Das Problem ist jedoch, dass diese Strategie nachträglich hinzugefügt wird: Die Entwicklerin oder der Entwickler implementiert zuerst den Server und entscheidet sich dann, nachdem sie/er bemerkt hat, wie langsam die Auflösung der queries ist, den Deferred-Mechanismus einzuführen. Daher kann die Implementierung der Resolver einige Umwege beinhalten, was den Entwicklungsprozess erschwert. Da die Entwicklerin oder der Entwickler außerdem verstehen muss, wie der „Deferred"-Mechanismus funktioniert, wird seine Implementierung komplexer als nötig.

Dieses Problem liegt nicht in der Strategie selbst, sondern darin, dass der GraphQL-Server diese Funktionalität als Add-on anbietet – obwohl ohne sie das Abfragen so langsam sein kann, dass GraphQL praktisch nutzlos wird.

Die Lösung für dieses Problem ist daher naheliegend: Die „Deferred"-Strategie sollte kein Add-on sein, sondern direkt in den GraphQL-Server integriert werden. Anstatt 2 Ausführungsstrategien zu haben, „normal" und „deferred", sollte es nur 1 geben: „deferred". Und der GraphQL-Server muss den „Deferred"-Mechanismus ausführen, auch wenn die Entwicklerin oder der Entwickler den Resolver auf „normale" Weise implementiert (mit anderen Worten: Der GraphQL-Server übernimmt die zusätzliche Komplexität, nicht die Entwicklerin oder der Entwickler).

Und genau das tut Gato GraphQL.

Die „Deferred"-Strategie als einzige Strategie im GraphQL-Server

Das Problem bei den meisten GraphQL-Servern besteht darin, dass die Verantwortung für die Auflösung der Objekttypen (object, union und interface) als Objekte bei den Resolvern selbst liegt, wenn der übergeordnete Knoten verarbeitet wird (z. B.: films => directors), anstatt diese Aufgabe an die Datenladeengine zu delegieren.

Gato GraphQL überträgt diese Verantwortung vom Resolver auf die Datenladeengine des Servers, und zwar so:

  1. Die Resolver geben IDs zurück, keine Objekte, wenn sie eine Beziehung zwischen übergeordnetem und untergeordnetem Knoten auflösen
  2. Für eine Liste von IDs eines bestimmten Typs holt eine DataLoader-Entität die entsprechenden Objekte dieses Typs
  3. Die Datenladeengine des Servers ist das Bindeglied zwischen diesen 2 Teilen: Sie holt zunächst die Objekt-IDs von den Resolvern und ruft – kurz bevor die verschachtelte query für die Beziehung ausgeführt wird (zu dem Zeitpunkt, zu dem alle IDs für den spezifischen Typ gesammelt wurden) – die Objekte für diese IDs über den DataLoader ab (der alle IDs effizient in einer einzigen query zusammenfassen kann).

Dieser Ansatz lässt sich so zusammenfassen: „Arbeite mit IDs, nicht mit Objekten".

Verwenden wir dasselbe Beispiel von vorhin, um diesen neuen Ansatz zu veranschaulichen. Die folgende query ruft eine Liste von Regisseuren und ihre Filme ab:

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

Achte auf die 2 Felder, die für jeden Regisseur abgerufen werden sollen – name und films – und darauf, wie sie sich aktuell unterscheiden:

Das Feld name ist vom skalaren Typ. Es ist sofort auflösbar, da wir davon ausgehen können, dass das Objekt vom Typ Director eine Eigenschaft vom Typ string namens name enthält, die den Namen des Regisseurs beinhaltet. Sobald wir das Director-Objekt haben, ist also keine zusätzliche query nötig, um diese Eigenschaft aufzulösen.

Das Feld films hingegen ist eine Liste vom Objekttyp. Es ist normalerweise nicht sofort auflösbar, da es auf eine Liste von Objekten vom Typ Film verweist, die noch über 1 oder mehr zusätzliche queries aus der Datenbank abgerufen werden müssen. Die Entwicklerin oder der Entwickler müsste daher den „Deferred"-Mechanismus dafür implementieren.

Betrachten wir nun das andere Verhalten: Das Feld films soll als Liste von IDs aufgelöst werden (statt als Liste von Objekten). Da wir davon ausgehen können, dass das Director-Objekt eine Eigenschaft namens filmIDs enthält, die die IDs aller seiner Filme enthält – vom Typ array of string (unter der Annahme, dass die ID als String dargestellt wird) –, kann dieses Feld ebenfalls sofort aufgelöst werden, ohne den „Deferred"-Mechanismus implementieren zu müssen.

Schließlich muss der Resolver neben der ID noch eine zusätzliche Information liefern: den Typ des erwarteten Objekts (in unserem Beispiel könnte das [(Film, 2), (Film, 5), (Film, 9)] sein). Diese Information ist jedoch intern, wird an die Engine weitergegeben und muss nicht in der Antwort auf die query ausgegeben werden.

Den angepassten Ansatz im Code implementieren

Schauen wir uns an, wie Gato GraphQL diesen Ansatz in PHP-Code umsetzt. Der folgende Code zeigt die verschiedenen Resolver (zur Übersichtlichkeit wurde der gesamte Code unten bearbeitet).

FieldResolvers

FieldResolvers empfangen ein Objekt eines bestimmten Typs und lösen dessen Felder auf. Bei Beziehungen müssen sie auch den Typ des Objekts angeben, in den sie auflösen. Dies ist ihr Vertrag:

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

Die Implementierung sieht so aus:

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;
  }
}

Beachte, wie der Code zur Auflösung des Feldes author sehr einfach und prägnant geworden ist, indem die Logik für Promises/deferred objects entfernt wurde.

TypeResolvers

TypeResolvers sind Objekte, die einen bestimmten Typ behandeln: Sie kennen den Namen des Typs und welcher TypeDataLoader Objekte dieses Typs lädt, unter anderem.

Die Datenladeengine erhält beim Auflösen von Feldern IDs von einer bestimmten TypeResolver-Klasse. Wenn sie dann die Objekte für diese IDs abruft, fragt die Datenladeengine den TypeResolver, welches TypeDataLoader-Objekt zum Laden dieser Objekte verwendet werden soll.

Ihr Vertrag ist folgendermaßen definiert:

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

In unserem Beispiel definiert die Klasse UserTypeResolver, dass der Typ User seine Daten über die Klasse UserTypeDataLoader laden soll:

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

TypeDataLoaders

TypeDataLoaders empfangen eine Liste von IDs eines bestimmten Typs und geben die entsprechenden Objekte dieses Typs zurück. Dies ist ihr Vertrag:

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

Das Abrufen von Benutzern funktioniert so:

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

Eine (wirklich) große query ausführen

Testen wir, ob diese Strategie funktioniert. Gehe zum GraphiQL-Client in Gato GraphQL und führe die folgende query aus, die einen 10 Ebenen tiefen Graph umfasst (posts => author => posts => tags => posts => comments => author => posts => comments => author) und die nicht in annehmbarer Zeit aufgelöst werden könnte, wenn das „n+1-Problem" aufträte.

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
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Wenn du durch die Ergebnisse scrollst, siehst du, wie groß die Antwort ist, wie viele Entitäten sie umfasst und wie viele Ebenen abgerufen wurden – und dennoch wurde sie prompt ausgeführt, ohne jede Schwierigkeit.