Het "n+1 probleem" onderdrukken
Laten we ontdekken hoe Gato GraphQL het «n+1 probleem» volledig vermijdt door middel van architectureel ontwerp.
Wat is het «n+1 probleem»
Het «n+1 probleem» betekent kortweg dat het aantal queries dat tegen de database wordt uitgevoerd even groot kan zijn als het aantal knooppunten in de graph.
Wat betekent dat? Laten we het bekijken aan de hand van een voorbeeld: stel dat we een lijst van regisseurs willen ophalen, en voor elk van hen zijn/haar films, via de volgende query:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
}
}
}
}Om efficiënt te werken, zouden we verwachten dat er slechts 2 queries worden uitgevoerd om de gegevens uit de database op te halen: 1 om de gegevens van de regisseurs op te halen, en 1 om de gegevens van alle films van alle regisseurs op te halen.
Echter, om aan deze query te voldoen, moet GraphQL «n+1» queries uitvoeren tegen de database: 1 eerst om de lijst van N regisseurs op te halen (in dit geval 10) en dan, voor elk van de N regisseurs, 1 query om zijn/haar lijst van films op te halen. In ons geval moeten we 1+10=11 queries uitvoeren.
Dit probleem ontstaat doordat GraphQL resolvers slechts 1 object tegelijk verwerken, en niet alle objecten van hetzelfde type tegelijk. In ons geval wordt de resolver die objecten van het type Query verwerkt (het roottype) de eerste keer één keer aangeroepen om de lijst van alle Director-objecten op te halen, en daarna wordt de resolver die het type Director verwerkt één keer aangeroepen voor elk Director-object, om zijn/haar lijst van films op te halen.
Met andere woorden: GraphQL resolvers zien de boom, niet het bos.
Dit probleem is eigenlijk erger dan het aanvankelijk lijkt, omdat het aantal knooppunten in een graph exponentieel groeit met het aantal niveaus van de graph. De naam «n+1» is dus alleen geldig voor een graph van 2 niveaus diep. Voor een graph van 3 niveaus diep zou het het «N2+n+1» probleem moeten heten! En zo verder...
Laten we, voortbouwend op bovenstaand voorbeeld, ook de lijst van acteurs/actrices van elke film toevoegen aan de query, als volgt:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
actors(first: 10) {
name
}
}
}
}
}Dan zijn de queries die tegen de database worden uitgevoerd: 1 eerst om de lijst van 10 regisseurs op te halen, dan 1 query om de lijst van films van elke regisseur op te halen voor elk van de 10 regisseurs, en ten slotte 1 query om de lijst van acteurs/actrices op te halen voor elk van de 10 films van elk van de 10 regisseurs. Dit geeft een totaal van 1+10+100=111 queries.
Na het observeren van dit gedrag kan het «n+1 probleem» gemakkelijk worden beschouwd als de grootste prestatiehindernis van GraphQL: als het niet wordt aangepakt, kan het bevragen van graphs van een paar niveaus diep zo traag worden dat GraphQL praktisch nutteloos wordt.
Algemene oplossing voor het «n+1 probleem»
De standaardoplossing voor het «n+1 probleem» werd voor het eerst geboden door het hulpmiddel DataLoader. De strategie is heel eenvoudig: het oplossen van segmenten van de query uitstellen naar een latere fase, waarin alle objecten van hetzelfde type samen kunnen worden opgelost in één query. Deze strategie, «batching» genaamd, lost het «n+1» probleem effectief op.
Daarnaast slaat DataLoader objecten op in een cache nadat ze zijn opgehaald, zodat als een volgende query een al geladen object moet laden, de uitvoering kan worden overgeslagen en het object uit de cache kan worden opgehaald. Deze strategie, «caching» genaamd, is voornamelijk een optimalisatie bovenop «batching».
Problemen met de «batching/uitgestelde» oplossing
Technisch gezien is er geen enkel probleem met de «batching»- of «uitgestelde» strategie: het werkt gewoon.
(Voortaan verwijzen we naar de strategie uitsluitend als «uitgesteld».)
Het probleem is echter dat deze strategie een nagedachte is: de ontwikkelaar kan eerst de server implementeren en dan, wanneer hij merkt hoe traag de queries worden opgelost, besluiten het uitstelingsmechanisme in te voeren. Het implementeren van de resolvers kan daardoor enkele extra stappen vereisen, wat wrijving toevoegt aan het ontwikkelingsproces. Bovendien, omdat de ontwikkelaar moet begrijpen hoe het «uitgestelde» mechanisme werkt, wordt de implementatie complexer dan nodig zou zijn.
Het probleem ligt niet in de strategie zelf, maar in het feit dat de GraphQL-server deze functionaliteit aanbiedt als een aanvulling, ook al kan het bevragen zonder deze functionaliteit zo traag zijn dat GraphQL praktisch nutteloos wordt.
De oplossing voor dit probleem is dan ook eenvoudig: de «uitgestelde» strategie mag geen aanvulling zijn, maar moet ingebouwd zijn in de GraphQL-server zelf. In plaats van 2 query-uitvoeringsstrategieën te hebben, «normaal» en «uitgesteld», zou er maar 1 moeten zijn, «uitgesteld». En de GraphQL-server moet het «uitgestelde» mechanisme uitvoeren, ook als de ontwikkelaar de resolver op de «normale» manier implementeert (met andere woorden, de GraphQL-server draagt zorg voor de extra complexiteit, niet de ontwikkelaar).
En dat is precies wat Gato GraphQL doet.
«Uitgesteld» de enige strategie maken die door de GraphQL-server wordt uitgevoerd
Het probleem met de meeste GraphQL-servers is dat de verantwoordelijkheid voor het oplossen van de objecttypen (object, union en interface) als objecten bij de resolvers zelf ligt tijdens het verwerken van het bovenliggende knooppunt (bijv.: films => directors), in plaats van deze taak te delegeren aan de data-loading engine.
Gato GraphQL verschuift deze verantwoordelijkheid weg van de resolver en naar de data-loading engine van de server, als volgt:
- De resolvers geven IDs terug, geen objecten, bij het oplossen van een relatie tussen bovenliggende en onderliggende knooppunten
- Gegeven een lijst van IDs van een bepaald type, haalt een
DataLoader-entiteit de bijbehorende objecten van dat type op - De data-loading engine van de server is de verbinding tussen deze 2 delen: het haalt eerst de object-IDs op van de resolvers en, net voordat de geneste query voor de relatie wordt uitgevoerd (op dat moment zijn alle IDs voor het specifieke type verzameld), haalt het de objecten voor die IDs op via de
DataLoader(die alle IDs efficiënt in één query kan verwerken).
Deze aanpak kan worden samengevat als: «Werk met IDs, niet met objecten».
Laten we hetzelfde voorbeeld van eerder gebruiken om deze nieuwe aanpak te visualiseren. De onderstaande query haalt een lijst van regisseurs en hun films op:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
}
}
}
}Let op de 2 velden die voor elke regisseur moeten worden opgehaald, name en films, en hoe ze momenteel van elkaar verschillen:
Veld name is van het scalaire type. Het is direct oplosbaar, omdat we mogen verwachten dat het object van het type Director een eigenschap van het type string genaamd name bevat, die de naam van de regisseur bevat. Zodra we het Director-object hebben, hoeft er dus geen extra query te worden uitgevoerd om deze eigenschap op te lossen.
Veld films is echter een lijst van het objecttype. Het is normaal gesproken niet direct oplosbaar, omdat het verwijst naar een lijst van objecten van het type Film, die nog uit de database moeten worden opgehaald via 1 of meer extra queries. De ontwikkelaar zou hiervoor dan het «uitgestelde» mechanisme moeten implementeren.
Laten we nu de andere benadering overwegen en veld films oplossen als een lijst van IDs (in plaats van een lijst van objecten). Omdat we mogen verwachten dat het Director-object een eigenschap genaamd filmIDs bevat met de IDs van al zijn films, van het type array of string (ervan uitgaande dat het ID als string wordt weergegeven), kan dit veld ook direct worden opgelost, zonder het «uitgestelde» mechanisme te hoeven implementeren.
Ten slotte moet de resolver, naast het ID, een extra stukje informatie doorgeven: het type van het verwachte object (in ons voorbeeld zou dat [(Film, 2), (Film, 5), (Film, 9)] kunnen zijn). Deze informatie is echter intern, wordt doorgegeven aan de engine, en hoeft niet te worden opgenomen in het antwoord op de query.
De aangepaste aanpak implementeren in code
Laten we zien hoe Gato GraphQL deze aanpak implementeert in PHP-code. De onderstaande code demonstreert de verschillende resolvers (voor de duidelijkheid is alle onderstaande code bewerkt).
FieldResolvers
FieldResolvers ontvangen een object van een specifiek type en lossen de velden ervan op. Voor relaties moeten ze ook het type van het object aangeven waarnaar ze oplossen. Dit is hun contract:
interface FieldResolverInterface
{
public function resolveValue($object, string $field, array $args = []);
public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string;
}De implementatie ziet er als volgt uit:
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;
}
}Let op hoe, door de logica voor het afhandelen van promises/uitgestelde objecten te verwijderen, de code die veld author oplost erg eenvoudig en beknopt is geworden.
TypeResolvers
TypeResolvers zijn objecten die een specifiek type verwerken: ze kennen de naam van het type en welke TypeDataLoader objecten van dat type laadt, onder andere.
De data-loading engine ontvangt bij het oplossen van velden IDs van een bepaalde TypeResolver-klasse. Wanneer de objecten voor die IDs worden opgehaald, vraagt de data-loading engine aan de TypeResolver welk TypeDataLoader-object moet worden gebruikt om die objecten te laden.
Hun contract is als volgt gedefinieerd:
interface TypeResolverInterface
{
public function getTypeName(): string;
public function getTypeDataLoaderClass(): string;
}In ons voorbeeld definieert klasse UserTypeResolver dat type User zijn gegevens moet laten laden via klasse UserTypeDataLoader:
class UserTypeResolver implements TypeResolverInterface
{
public function getTypeName(): string
{
return 'User';
}
public function getTypeDataLoaderClass(): string
{
return UserTypeDataLoader::class;
}
}TypeDataLoaders
TypeDataLoaders ontvangen een lijst van IDs van een specifiek type en geven de bijbehorende objecten van dat type terug. Dit is hun contract:
interface TypeDataLoaderInterface
{
public function getObjects(array $ids): array;
}Het ophalen van gebruikers gaat als volgt:
class UserTypeDataLoader implements TypeDataLoaderInterface
{
public function getObjects(array $ids): array
{
$userAPI = UserAPIFacade::getInstance();
return $userAPI->getUsers($ids);
}
}Een (echt) grote query uitvoeren
Laten we testen of deze strategie werkt. Ga naar de GraphiQL-client in Gato GraphQL en voer de onderstaande query uit, die een graph van 10 niveaus diep omvat (posts => author => posts => tags => posts => comments => author => posts => comments => author) en die niet in een redelijke tijd kon worden opgelost als het «n+1 probleem» zou optreden.
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
}
}
}
}
}
}
}
}
}
}
}Als je door de resultaten scrolt, zie je hoe groot het antwoord is, hoeveel entiteiten het omvat en hoeveel niveaus er zijn opgehaald, en toch werd het snel uitgevoerd, zonder enige moeite.