graphql - optimisation des requêtes de base de données

Lorsque vous travaillez avec des bases de données, il existe un problème appelé "SELECT N + 1" - lorsqu'une application, au lieu d'une seule requête vers la base de données, qui sélectionne toutes les données nécessaires dans plusieurs tables, collections associées, crée une sous-requête supplémentaire pour chaque ligne du résultat de la première requête, pour obtenir des données connexes. Par exemple, nous obtenons d'abord une liste d'étudiants universitaires dans lesquels sa spécialité est identifiée par un identifiant, puis pour chacun des étudiants nous faisons une sous-requête supplémentaire à un tableau ou à une collection de spécialités afin d'obtenir le nom de la spécialité par l'identifiant de la spécialité. Étant donné que chacune des sous-requêtes peut nécessiter une autre sous-requête et une autre sous-requête - le nombre de requêtes vers la base de données commence à croître de façon exponentielle.

Lorsque vous travaillez avec graphql, il est très simple de générer le problème «SELECT N + 1» si vous effectuez une sous-requête sur la table liée dans la fonction résolveur. La première chose qui vient à l'esprit est de faire une demande en tenant compte immédiatement de toutes les données liées, mais cela, vous devez en convenir, est irrationnel si les données liées ne sont pas demandées par le client.

Une des solutions au problème «SELECT N + 1» pour graphql sera examinée dans cet article.

Par exemple, prenez deux collections: «Auteurs» (Auteur) et «Livres» (Livre). La relation est, comme on pourrait le supposer, de plusieurs à plusieurs. Un auteur peut avoir plusieurs livres et un livre peut être écrit par plusieurs auteurs. Pour stocker des informations, nous utiliserons la base de données mongodb et la bibliothèque mongoose.js

Nous réalisons la relation entre les collections plusieurs à plusieurs à l'aide de la collection auxiliaire BookAuthor et des champs virtuels.

// Author.js const mongoose = require('mongoose'); const Schema = mongoose.Schema; const schema = new Schema({ name: String }); schema.virtual('books', { ref: 'BookAuthor', localField: '_id', foreignField: 'author' }); module.exports = schema; 

 // Book.js const mongoose = require('mongoose'); const Schema = mongoose.Schema; const schema = new Schema({ title: String }); schema.virtual('authors', { ref: 'BookAuthor', localField: '_id', foreignField: 'book' }); module.exports = schema; 

 // BookAuthor.js const mongoose = require('mongoose'); const Schema = mongoose.Schema; const schema = new Schema({ author: { type: mongoose.Schema.Types.ObjectId, ref: 'Author' }, book: { type: mongoose.Schema.Types.ObjectId, ref: 'Book' } }); module.exports = schema; 

 // mongoSchema.js const mongoose = require('mongoose'); const Author = require('./Author'); const Book = require('./Book'); const BookAuthor = require('./BookAuthor'); mongoose.connect('mongodb://localhost:27017/books') mongoose.set('debug', true); exports.Author = mongoose.model('Author', Author); exports.Book = mongoose.model('Book', Book); exports.BookAuthor = mongoose.model('BookAuthor', BookAuthor); 

Définissez maintenant les types Author et Book dans graphql. Il y a un léger problème avec le fait que ces types sont référencés mutuellement. Par conséquent, pour leur accès mutuel, la liaison des liens vers l'objet du module exports est utilisée, plutôt que la liaison d'un nouvel objet à module.exports (qui remplace l'objet d'origine), et le champ champs est implémenté en tant que fonction, ce qui vous permet de «reporter» la lecture du lien vers l'objet lors de sa création. jusqu'à ce que toutes les références circulaires deviennent disponibles:

 // graphqlType.js exports.Author = require('./Author'); exports.Book = require('./Book'); 

 // Author.js const graphql = require('graphql') const graphqlType = require('./index') module.exports = new graphql.GraphQLObjectType({ name: 'author', description: '', fields: () => ({ _id: {type: graphql.GraphQLString}, name: { type: graphql.GraphQLString, }, books: { type: new graphql.GraphQLList(graphqlType.Book), resolve: obj => obj.books && obj.books.map(book => book.book) } }) }); 

 // Book.js const graphql = require('graphql') const graphqlType = require('./index') module.exports = new graphql.GraphQLObjectType({ name: 'book', description: '', fields: () => ({ _id: {type: graphql.GraphQLString}, title: { type: graphql.GraphQLString, }, authors: { type: new graphql.GraphQLList(graphqlType.Author), resolve: obj => obj.authors && obj.authors.map(author => author.author) } }) }); 

Nous définissons maintenant la demande des auteurs, éventuellement avec une liste de leurs livres, et, éventuellement, avec une liste d'auteurs (co-auteurs) de ces livres.

 const graphql = require('graphql'); const getFieldNames = require('graphql-list-fields'); const graphqlType = require('../graphqlType'); const mongoSchema = require('../mongoSchema'); module.exports = { type: new graphql.GraphQLList(graphqlType.Author), args: { _id: { type: graphql.GraphQLString } }, resolve: (_, {_id}, context, info) => { const fields = getFieldNames(info); const where = _id ? {_id} : {}; const authors = mongoSchema.Author.find(where) if (fields.indexOf('books.authors.name') > -1 ) { authors.populate({ path: 'books', populate: { path: 'book', populate: {path: 'authors', populate: {path: 'author'}} } }) } else if (fields.indexOf('books.title') > -1 ) { authors.populate({path: 'books', populate: {path: 'book'}}) } return authors.exec(); } }; 

Afin de déterminer quels champs les demandes proviennent du client, la bibliothèque graphql-list-fields est utilisée. Et si une requête est venue avec des objets imbriqués, la méthode populate () de la bibliothèque mongoose est appelée.

Essayons maintenant avec les requêtes. La demande maximale possible pour notre mise en œuvre:

 { author { _id name books { _id title authors { _id name } } } } 

sera effectué par 5 appels à la base de données:

 authors.find({}, { fields: {} }) bookauthors.find({ author: { '$in': [ ObjectId("5b0fcab305b15d38f672357d"), ObjectId("5b0fcabd05b15d38f672357e"), ObjectId("5b0fcac405b15d38f672357f"), ObjectId("5b0fcad705b15d38f6723580"), ObjectId("5b0fcae305b15d38f6723581"), ObjectId("5b0fedb94ad5435896079cf1"), ObjectId("5b0fedbd4ad5435896079cf2") ] } }, { fields: {} }) books.find({ _id: { '$in': [ ObjectId("5b0fcb7105b15d38f6723582") ] } }, { fields: {} }) bookauthors.find({ book: { '$in': [ ObjectId("5b0fcb7105b15d38f6723582") ] } }, { fields: {} }) authors.find({ _id: { '$in': [ ObjectId("5b0fcab305b15d38f672357d"), ObjectId("5b0fcad705b15d38f6723580") ] } }, { fields: {} }) dans': [ObjectId ( "5b0fcab305b15d38f672357d"), ObjectId ( "5b0fcabd05b15d38f672357e"), ObjectId ( "5b0fcac405b15d38f672357f"), ObjectId ( "5b0fcad705b15d38f6723580"), ObjectId ( "5b0fcae305b15d38f6723581"), ObjectId authors.find({}, { fields: {} }) bookauthors.find({ author: { '$in': [ ObjectId("5b0fcab305b15d38f672357d"), ObjectId("5b0fcabd05b15d38f672357e"), ObjectId("5b0fcac405b15d38f672357f"), ObjectId("5b0fcad705b15d38f6723580"), ObjectId("5b0fcae305b15d38f6723581"), ObjectId("5b0fedb94ad5435896079cf1"), ObjectId("5b0fedbd4ad5435896079cf2") ] } }, { fields: {} }) books.find({ _id: { '$in': [ ObjectId("5b0fcb7105b15d38f6723582") ] } }, { fields: {} }) bookauthors.find({ book: { '$in': [ ObjectId("5b0fcb7105b15d38f6723582") ] } }, { fields: {} }) authors.find({ _id: { '$in': [ ObjectId("5b0fcab305b15d38f672357d"), ObjectId("5b0fcad705b15d38f6723580") ] } }, { fields: {} }) 

Comme vous pouvez le voir, la fonction mongoose.js - populate () - n'utilise pas la fonctionnalité relativement nouvelle de mongodb - $ lookup, mais crée des requêtes supplémentaires. Mais ce n'est pas un problème "SELECT N + 1" car Une nouvelle requête n'est pas créée pour chaque ligne, mais pour toute la collection. (Le désir de vérifier le fonctionnement réel de la fonction mongoose.js populate () - avec une ou plusieurs requêtes - a été l'un des motifs du choix d'une base de données non relationnelle pour cet exemple).

Si nous utilisons une requête minimaliste:

 { author { _id name } } 

il formera alors un seul appel à la base de données:

  authors.find({}, { fields: {} }) 

Ceci, en fait, j'ai cherché. En conclusion, je dirai que lorsque j'ai commencé à chercher des solutions à ce problème, j'ai trouvé des bibliothèques très pratiques et avancées qui résolvent ce problème. L'un d'eux, par exemple, que j'ai beaucoup aimé, sur la base de la structure de la base de données relationnelle, a formé le schéma graphql avec toutes les opérations nécessaires. Cependant, cette approche est acceptable si graphql est utilisé du côté backend de l'application. Si vous ouvrez l'accès à ces services depuis le front-end de l'application (dont j'avais besoin), cela revient à placer un panneau d'administration sur le serveur de base de données en accès ouvert, comme toutes les tables deviennent disponibles dès la sortie de la boîte

Pour la commodité des lecteurs, l'exemple de travail se trouve dans le référentiel .

Supplément par joniks comment

L' utilisateur joniks dans le flux s'est référé à la bibliothèque https://github.com/facebook/dataloader . Voyons comment cette bibliothèque vous permet de faire face au problème de "SELECT N + 1"

Étant donné cette bibliothèque, une définition de type graphql Authors ressemblerait à ceci:

 // Autors.js const graphql = require('graphql') const DataLoader = require('dataloader') const graphqlType = require('./index') const mongoSchema = require('../mongoSchema'); const bookLoader = new DataLoader(async ids => { const data = await mongoSchema.Book.find({ _id: { $in: ids }}).populate('authors').exec(); const books = data.reduce((obj, item) => (obj[item._id] = item) && obj, {}) const response = ids.map(id => books[id]); return response; }); module.exports = new graphql.GraphQLObjectType({ name: 'authors', description: '', fields: () => ({ _id: {type: graphql.GraphQLString}, name: { type: graphql.GraphQLString, }, books: { type: new graphql.GraphQLList(graphqlType.Books), resolve: obj => obj.books && obj.books.map(book => bookLoader.load(book.book)) } }) }); 


Quel est l'intérêt d'utiliser cette bibliothèque: des requêtes bookLoader.load (id) uniques sont accumulées et envoyées pour traitement avec un tableau d'identifiants const bookLoader = new DataLoader (async ids => {...
En sortie, nous devons renvoyer la promesse de tableau ou tableau de promesses qui sont situées dans le même ordre que le tableau d'entrée ids.

Nous pouvons maintenant réécrire notre demande pour les auteurs comme suit:

 // authors.js const graphql = require('graphql'); const getFieldNames = require('graphql-list-fields'); const graphqlType = require('../graphqlType'); const mongoSchema = require('../mongoSchema'); module.exports = { type: new graphql.GraphQLList(graphqlType.Authors), args: { _id: { type: graphql.GraphQLString } }, resolve: (_, {_id}, context, info) => { const fields = getFieldNames(info); const where = _id ? {_id} : {}; const authors = mongoSchema.Author.find(where).populate('books') return authors.exec(); } }; 


En conséquence, nous pouvons interroger des objets liés d'un niveau arbitraire d'imbrication sans se soucier du problème SELECT N + 1 (bien qu'au prix d'un appel inconditionnel à populate () même là où il n'était pas nécessaire):

 { authors { _id name books { _id title authors { _id name books { _id title authors { _id name } } } } } } 


Mais ici, vous devez vraiment comprendre que si nous passons à travailler avec des serveurs SQL, à chaque niveau d'imbrication d'objets, il y aura une requête agrégée. Dans le même temps, il est parfois nécessaire que ce soit toujours exactement une demande. Mais vous ne pouvez pas y parvenir si directement en utilisant la bibliothèque de chargeur de données. Un exemple de modifications est disponible dans la branche du référentiel du chargeur de données.

apapacy@gmail.com
31 mai 2018

Source: https://habr.com/ru/post/fr412847/


All Articles