Bonjour, Habr! Je vous présente la traduction de l'article
«Architecture d'un moteur GraphQL vers SQL performant» .
Il s'agit de la traduction d'un article sur sa structure interne et sur les optimisations et les solutions architecturales apportées par Hasura - un serveur GraphQL léger hautes performances, qui agit comme une couche entre votre application Web et la base de données PostgreSQL.
Il vous permet de générer un schéma GraphQL basé sur une base de données existante ou d'en créer une nouvelle. Il prend en charge les abonnements GraphQL de la boßte basés sur les déclencheurs Postgres, le contrÎle d'accÚs dynamique, la génération automatique de jointures, résout le problÚme des demandes N + 1 (traitement par lots) et bien plus encore.
Vous pouvez utiliser des contraintes de clĂ©s Ă©trangĂšres dans PostgreSQL pour obtenir des donnĂ©es hiĂ©rarchiques dans une seule requĂȘte. Par exemple, vous pouvez exĂ©cuter cette requĂȘte afin d'obtenir les albums et leurs pistes correspondantes (si une clĂ© Ă©trangĂšre est créée dans la table "piste" qui pointe vers la table "album")
{ album (where: {year: {_eq: 2018}}) { title tracks { id title } } }
Comme vous l'avez peut-ĂȘtre devinĂ©, vous pouvez demander des donnĂ©es de n'importe quelle profondeur. Cette API, combinĂ©e au contrĂŽle d'accĂšs, permet aux applications Web d'interroger les donnĂ©es de PostgreSQL sans Ă©crire leur propre backend. Il est conçu pour rĂ©pondre aux requĂȘtes le plus rapidement possible, avoir une bande passante Ă©levĂ©e, tout en Ă©conomisant du temps processeur et de la consommation de mĂ©moire sur le serveur. Nous parlerons des solutions architecturales qui nous ont permis d'y parvenir.
Cycle de vie de la demande
Une demande envoyée à Hasura passe par les étapes suivantes:
- RĂ©ception de sessions : la demande tombe dans la passerelle, qui vĂ©rifie la clĂ© (le cas Ă©chĂ©ant) et ajoute divers en-tĂȘtes, par exemple l'identifiant et le rĂŽle d'utilisateur.
- Analyse des demandes : Hasura reçoit la demande, analyse les en-tĂȘtes pour obtenir des informations sur l'utilisateur, crĂ©e GraphQL AST en fonction du corps de la demande.
- Validation des demandes : une vérification est effectuée pour voir si la demande est sémantiquement correcte, puis les droits d'accÚs correspondant au rÎle de l'utilisateur sont appliqués.
- ExĂ©cution de la requĂȘte : la requĂȘte est convertie en SQL et envoyĂ©e Ă Postgres.
- GĂ©nĂ©ration de rĂ©ponse : le rĂ©sultat de la requĂȘte SQL est traitĂ© et envoyĂ© au client (la passerelle peut utiliser gzip si nĂ©cessaire ).
Buts
Les exigences sont approximativement les suivantes:
- La pile HTTP doit ajouter une surcharge minimale et ĂȘtre capable de gĂ©rer de nombreuses demandes simultanĂ©es pour un dĂ©bit Ă©levĂ©.
- GĂ©nĂ©ration SQL rapide Ă partir d'une requĂȘte GraphQL.
- La requĂȘte SQL gĂ©nĂ©rĂ©e doit ĂȘtre efficace pour Postgres.
- Le rĂ©sultat de la requĂȘte SQL doit ĂȘtre renvoyĂ© de Postgres.
Traitement des requĂȘtes GraphQL
Il existe plusieurs approches pour obtenir les donnĂ©es requises pour une requĂȘte GraphQL:
Résolveurs conventionnels
L'exĂ©cution de requĂȘtes GraphQL implique gĂ©nĂ©ralement l'appel d'un rĂ©solveur pour chaque champ.
Dans l'exemple de requĂȘte, nous obtenons les albums sortis en 2018, puis pour chacun d'eux, nous demandons les pistes qui lui correspondent - un problĂšme classique de requĂȘtes N + 1. Le nombre de requĂȘtes augmente de façon exponentielle avec l'augmentation de la profondeur des requĂȘtes.
Les demandes faites par Postgres seront:
SELECT id,title FROM album WHERE year = 2018;
Cette demande nous renverra tous les albums. Supposons que le nombre d'albums retournés par la demande soit égal à N. Ensuite, pour chaque album, nous exécuterions la demande suivante:
SELECT id,title FROM tracks WHERE album_id = <album-id>
Au total, vous obtenez N + 1 requĂȘtes pour obtenir toutes les donnĂ©es nĂ©cessaires.
Demandes groupées
Des outils tels que le
chargeur de donnĂ©es sont conçus pour rĂ©soudre le problĂšme des demandes N + 1 en utilisant le traitement par lots. Le nombre de requĂȘtes SQL pour les donnĂ©es incorporĂ©es ne dĂ©pend plus de la taille de l'Ă©chantillon initial, car Maintenant, cela affecte le nombre de nĆuds dans la requĂȘte GraphQL. Dans ce cas, 2 demandes Ă Postgres sont nĂ©cessaires pour obtenir les donnĂ©es requises:
Nous obtenons des albums:
SELECT id,title FROM album WHERE year = 2018
Nous obtenons les pistes pour les albums que nous avons reçus dans la demande précédente:
SELECT id, title FROM tracks WHERE album_id IN {the list of album ids}
Au total, 2 requĂȘtes sont reçues. Nous avons Ă©vitĂ© d'exĂ©cuter des requĂȘtes SQL sur les pistes de chaque album individuel; Ă la place, nous avons utilisĂ© l'opĂ©rateur WHERE pour obtenir toutes les pistes nĂ©cessaires en une seule requĂȘte Ă la fois.
Se joint
Dataloader est conçu pour fonctionner avec diffĂ©rentes sources de donnĂ©es et ne permet pas d'exploiter les capacitĂ©s d'une source particuliĂšre. Dans notre cas, Postgres est la seule source de donnĂ©es et, comme toutes les bases de donnĂ©es relationnelles, il offre la possibilitĂ© de collecter des donnĂ©es Ă partir de plusieurs tables avec une seule requĂȘte Ă l'aide de l'opĂ©rateur JOIN. Nous pouvons dĂ©terminer toutes les tables nĂ©cessaires Ă une requĂȘte GraphQL et gĂ©nĂ©rer une seule requĂȘte SQL Ă l'aide de JOIN pour obtenir toutes les donnĂ©es. Il s'avĂšre que les donnĂ©es nĂ©cessaires Ă toute requĂȘte GraphQL peuvent ĂȘtre obtenues Ă l'aide d'une seule requĂȘte SQL. Ces donnĂ©es sont converties avant d'ĂȘtre envoyĂ©es au client.
Une telle demande:
SELECT album.id as album_id, album.title as album_title, track.id as track_id, track.title as track_title FROM album LEFT OUTER JOIN track ON (album.id = track.album_id) WHERE album.year = 2018
Nous renverra ces données:
album_id, album_title, track_id, track_title 1, Album1, 1, track1 1, Album1, 2, track2 2, Album2, NULL, NULL
Ensuite, il sera converti en JSON et envoyé au client:
[ { "title" : "Album1", "tracks": [ {"id" : 1, "title": "track1"}, {"id" : 2, "title": "track2"} ] }, { "title" : "Album2", "tracks" : [] } ]
Optimisation de la génération de réponse
Nous avons constatĂ© que la plupart du temps dans le traitement des requĂȘtes est consacrĂ© Ă la fonction de conversion du rĂ©sultat d'une requĂȘte SQL en JSON.
AprÚs plusieurs tentatives d'optimisation de cette fonction de différentes maniÚres, nous avons décidé de la transférer vers Postgres. Postgres 9.4 (
sorti Ă l'Ă©poque de la premiĂšre version de Hasura ) a ajoutĂ© une fonction d'agrĂ©gation JSON qui nous a aidĂ©s Ă faire ce que nous voulions. AprĂšs cette optimisation, les requĂȘtes SQL ont commencĂ© Ă ressembler Ă ceci:
SELECT json_agg(r.*) FROM ( SELECT album.title as title, json_agg(track.*) as tracks FROM album LEFT OUTER JOIN track ON (album.id = track.album_id) WHERE album.year = 2018 GROUP BY album.id ) r
Le rĂ©sultat de cette requĂȘte aura une colonne et une ligne, et cette valeur sera envoyĂ©e au client sans autre conversion. Selon nos tests, cette approche est environ 3 Ă 6 fois plus rapide que la fonction de conversion Haskell.
Déclarations préparées
Les requĂȘtes SQL gĂ©nĂ©rĂ©es peuvent ĂȘtre assez volumineuses et complexes selon le niveau d'imbrication de la requĂȘte et les conditions d'utilisation. En rĂšgle gĂ©nĂ©rale, les applications Web ont un ensemble de requĂȘtes qui sont exĂ©cutĂ©es Ă plusieurs reprises avec diffĂ©rents paramĂštres. Par exemple, la requĂȘte prĂ©cĂ©dente doit ĂȘtre exĂ©cutĂ©e pour 2017, au lieu de 2018. Les instructions prĂ©parĂ©es conviennent mieux aux cas oĂč il existe une requĂȘte SQL complexe rĂ©pĂ©titive dans laquelle seuls les paramĂštres sont modifiĂ©s.
Disons que cette requĂȘte est exĂ©cutĂ©e pour la premiĂšre fois:
{ album (where: {year: {_eq: 2018}}) { title tracks { id title } } }
Nous crĂ©ons une instruction prĂ©parĂ©e pour la requĂȘte SQL au lieu de l'exĂ©cuter:
PREPARE prep_1 AS SELECT json_agg(r.*) FROM ( SELECT album.title as title, json_agg(track.*) as tracks FROM album LEFT OUTER JOIN track ON (album.id = track.album_id) WHERE album.year = $1 GROUP BY album.
AprÚs quoi nous l'exécutons immédiatement:
EXECUTE prep_1('2018');
Lorsque vous devez exĂ©cuter la requĂȘte GraphQL pour 2017, nous appelons simplement la mĂȘme instruction prĂ©parĂ©e avec un argument diffĂ©rent:
EXECUTE prep_1('2017');
Cela donne une augmentation de vitesse d'environ 10 Ă 20% en fonction de la complexitĂ© de la requĂȘte GraphQL.
Haskell
Haskell fonctionne bien pour plusieurs raisons:
En fin de compte
Toutes les optimisations mentionnées ci-dessus entraßnent des avantages de performance assez sérieux:

En fait, la faible consommation de mémoire et les retards insignifiants par rapport aux appels directs à PostgreSQL permettent dans la plupart des cas de remplacer les ORM dans votre backend par des appels API GraphQL.
RepĂšres:Banc d'essai:
- Ordinateur portable avec 8 Go de RAM et i7
- Postgres s'exĂ©cutant sur le mĂȘme ordinateur
- wrk , a été utilisé comme outil de comparaison et pour divers types de demandes, nous avons essayé de "maximiser" les rps
- Une instance de Hasura GraphQL Engine
- Taille du pool de connexions: 50
- Jeu de données : chinook
Demande 1: tracks_media_some query tracks_media_some { tracks (where: {composer: {_eq: "Kurt Cobain"}}){ id name album { id title } media_type { name } }}
- Demandes par seconde: 1375 req / s
- Retard: 17,5 ms
- CPU: ~ 30%
- RAM: ~ 30 Mo (Hasura) + 90 Mo (Postgres)
Demande 2: tracks_media_all query tracks_media_all { tracks { id name media_type { name } }}
- Demandes par seconde: 410 req / s
- Retard: 59 ms
- CPU: ~ 100%
- RAM: ~ 30 Mo (Hasura) + 130 Mo (Postgres)
Demande 3: album_tracks_genre_some query albums_tracks_genre_some { albums (where: {artist_id: {_eq: 127}}) { id title tracks { id name genre { name } } }}
- Demandes par seconde: 1029 req / s
- Retard: 24 ms
- CPU: ~ 30%
- RAM: ~ 30 Mo (Hasura) + 90 Mo (Postgres)
Demande 4: album_tracks_genre_all query albums_tracks_genre_all { albums { id title tracks { id name genre { name } } }
- Demandes par seconde: 328 req / s
- Retard: 73 ms
- CPU: 100%
- RAM: ~ 30 Mo (Hasura) + 130 Mo (Postgres)