graphql: optimización de consultas de bases de datos

Cuando se trabaja con bases de datos, existe un problema llamado "SELECCIONAR N + 1": cuando una aplicación, en lugar de una única consulta a la base de datos, que selecciona todos los datos necesarios de varias tablas relacionadas, colecciones, realiza una subconsulta adicional para cada fila del resultado de la primera consulta, para obtener datos relacionados. Por ejemplo, primero obtenemos una lista de estudiantes universitarios en los que su especialidad se identifica con un identificador, y luego para cada uno de los estudiantes hacemos una subconsulta adicional a una tabla o colección de especialidades para obtener el nombre de la especialidad mediante el identificador de la especialidad. Dado que cada una de las subconsultas puede requerir otra subconsulta y otra subconsulta, el número de consultas a la base de datos comienza a crecer exponencialmente.

Cuando se trabaja con graphql, es muy sencillo generar el problema "SELECCIONAR N + 1" si realiza una subconsulta en la tabla vinculada en la función de resolución. Lo primero que viene a la mente es hacer una solicitud inmediatamente teniendo en cuenta todos los datos relacionados, pero esto, debe aceptar, es irracional si el cliente no solicita los datos relacionados.

En esta publicación se considerará una de las soluciones al problema "SELECCIONAR N + 1" para graphql.

Por ejemplo, tome dos colecciones: "Autores" (Autor) y "Libros" (Libro). La relación es, como se podría suponer, de muchos a muchos. Un autor puede tener varios libros, y un libro puede ser escrito por varios autores. Para almacenar información usaremos la base de datos mongodb y la biblioteca mongoose.js

Nos damos cuenta de la relación entre colecciones de muchos a muchos utilizando la colección auxiliar BookAuthor y los campos virtuales.

// 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); 

Ahora defina los tipos Autor y Libro en graphql. Hay un pequeño problema con el hecho de que estos tipos se referencian mutuamente. Por lo tanto, para su acceso mutuo, se utiliza el enlace de enlaces al objeto del módulo de exportaciones, en lugar del enlace de un nuevo objeto a module.exports (que reemplaza al objeto original), y el campo de campos se implementa como una función, que le permite "posponer" la lectura del enlace al objeto al crearlo. hasta que todas las referencias circulares estén 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) } }) }); 

Ahora definimos la solicitud de los autores, posiblemente con una lista de sus libros y, posiblemente, con una lista de autores (coautores) de estos libros.

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

Para determinar qué solicitud de campos provino del cliente, se utiliza la biblioteca graphql-list-fields. Y si una solicitud vino con objetos anidados, se llama al método populate () de la biblioteca de mangostas.

Ahora experimentemos con las consultas. La máxima solicitud posible para nuestra implementación:

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

se realizará mediante 5 llamadas a la base de datos:

 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: {} }) en': [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: {} }) 

Como puede ver, la función mongoose.js - populate () - no usa la característica relativamente nueva mongodb - $ lookup, pero crea solicitudes adicionales. Pero este no es un problema de "SELECCIONAR N + 1" ya que No se crea una nueva consulta para cada fila, sino para toda la colección. (El deseo de comprobar cómo funciona realmente la función mongoose.js populate (), con una solicitud o varias, fue uno de los motivos para elegir una base de datos no relacional para este ejemplo).

Si usamos una consulta minimalista:

 { author { _id name } } 

entonces formará solo una llamada a la base de datos:

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

Esto, de hecho, lo busqué. En conclusión, diré que cuando comencé a buscar soluciones a este problema, encontré bibliotecas muy convenientes y avanzadas que resuelven este problema. Uno de ellos, por ejemplo, que realmente me gustó, basado en la estructura de la base de datos relacional, formó el esquema graphql con todas las operaciones necesarias. Sin embargo, este enfoque es aceptable si se usa graphql en el lado del backend de la aplicación. Si abre el acceso a dichos servicios desde la interfaz de la aplicación (que necesitaba), esto es similar a colocar un panel de administración en el servidor de base de datos en acceso abierto, como todas las mesas están disponibles fuera de la caja

Para comodidad de los lectores, el ejemplo de trabajo se encuentra en el repositorio .

Suplemento por comentario joniks

El usuario de joniks en el feed se ha referido a la biblioteca https://github.com/facebook/dataloader . Veamos cómo esta biblioteca le permite hacer frente al problema de "SELECCIONAR N + 1"

Dada esta biblioteca, una definición de tipo de autores Graphql se vería así:

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


¿Cuál es el punto de usar esta biblioteca? Las solicitudes individuales de bookLoader.load (id) se acumulan y se envían para su procesamiento con una matriz de identificadores const bookLoader = new DataLoader (async ids => {...
En la salida, debemos devolver la promesa de matriz o matriz de promesas que se encuentran en el mismo orden que la matriz de entrada de ID.

Ahora podemos reescribir nuestra solicitud para los Autores de la siguiente manera:

 // 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(); } }; 


Como resultado, podemos consultar objetos relacionados de un nivel arbitrario de anidamiento sin preocuparnos por el problema SELECT N + 1 (aunque a costa de una llamada incondicional para poblar () incluso donde no era necesario):

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


Pero aquí debe comprender realmente que si pasamos a trabajar con servidores SQL, en cada nivel de anidamiento de objetos habrá una consulta agregada. Al mismo tiempo, a veces se requiere que esto siga siendo exactamente una solicitud. Pero no puede lograr esto tan directamente usando la biblioteca del cargador de datos. Un ejemplo de cambios está disponible en la rama del repositorio del cargador de datos.

apapacy@gmail.com
31 de mayo de 2018

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


All Articles