graphql - otimizando consultas ao banco de dados

Ao trabalhar com bancos de dados, existe um problema chamado "SELECT N + 1" - quando um aplicativo, em vez de uma única consulta ao banco de dados, que seleciona todos os dados necessários de várias tabelas relacionadas, coleções, faz uma subconsulta adicional para cada linha do resultado da primeira consulta, para obter dados relacionados. Por exemplo, primeiro obtemos uma lista de estudantes universitários nos quais sua especialidade é identificada por um identificador e, em seguida, para cada um dos estudantes, fazemos uma subconsulta adicional a uma tabela ou coleção de especialidades para obter o nome da especialidade pelo identificador da especialidade. Como cada uma das subconsultas pode exigir outra subconsulta e outra subconsulta - o número de consultas ao banco de dados começa a crescer exponencialmente.

Ao trabalhar com o graphql, é muito simples gerar o problema "SELECT N + 1" se você fizer uma subconsulta na tabela vinculada na função resolvedor. A primeira coisa que vem à mente é fazer uma solicitação imediatamente, levando em consideração todos os dados relacionados, mas você deve concordar que isso é irracional se os dados relacionados não forem solicitados pelo cliente.

Uma das soluções para o problema “SELECT N + 1” do graphql será considerada neste post.

Por exemplo, faça duas coleções: “Autores” (Autor) e “Livros” (Livro). O relacionamento é, como se poderia supor, muitos-para-muitos. Um autor pode ter vários livros e um livro pode ser escrito por vários autores. Para armazenar informações, usaremos o banco de dados mongodb e a biblioteca mongoose.js

Percebemos o relacionamento entre coleções muitos-para-muitos usando a coleção auxiliar BookAuthor e os campos virtuais.

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

Agora defina os tipos Autor e Livro no graphql. Há um pequeno problema com o fato de que esses tipos são referenciados mutuamente. Portanto, para o acesso mútuo, é usada a ligação de links ao objeto do módulo de exportações, em vez da ligação de um novo objeto ao module.exports (que substitui o objeto original), e o campo fields é implementado como uma função, que permite "adiar" a leitura do link para o objeto ao criá-lo. até que todas as referências circulares estejam disponíveis:

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

Agora, definimos a solicitação dos autores, possivelmente com uma lista de seus livros e, possivelmente, com uma lista de autores (co-autores) desses livros.

 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 quais solicitações de campos vieram do cliente, a biblioteca graphql-list-fields é usada. E se uma solicitação veio com objetos aninhados, o método populate () da biblioteca mangusto é chamado.

Agora vamos experimentar as consultas. A solicitação máxima possível para nossa implementação:

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

será realizado por 5 chamadas ao banco de dados:

 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: {} }) em': [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 você pode ver, a função mongoose.js - populate () - não usa o recurso mongodb relativamente novo - $ lookup, mas cria solicitações adicionais. Mas este não é um problema "SELECT N + 1", pois Uma nova consulta não é criada para cada linha, mas para toda a coleção. (O desejo de verificar como a função mongoose.js populate () realmente funciona - com uma solicitação ou várias - foi um dos motivos para a escolha de um banco de dados não relacional para este exemplo).

Se usarmos uma consulta minimalista:

 { author { _id name } } 

então ele formará apenas uma chamada para o banco de dados:

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

Na verdade, eu procurei isso. Concluindo, direi que, quando comecei a procurar soluções para esse problema, encontrei bibliotecas muito convenientes e avançadas que resolvem esse problema. Um deles, por exemplo, que eu realmente gostei, com base na estrutura do banco de dados relacional, formou o esquema graphql com todas as operações necessárias. No entanto, essa abordagem é aceitável se graphql for usado no lado de back-end do aplicativo. Se você abrir o acesso a esses serviços a partir do front-end do aplicativo (que eu precisava), será semelhante a colocar um painel de administração no servidor de banco de dados em acesso aberto, como todas as tabelas ficam disponíveis imediatamente

Para conveniência dos leitores, o exemplo de trabalho está localizado no repositório .

Suplemento por joniks comment

O usuário do joniks no feed consultou a biblioteca https://github.com/facebook/dataloader . Vamos ver como esta biblioteca permite que você lide com o problema de "SELECT N + 1"

Dada essa biblioteca, uma definição de tipo de graphql Authors ficaria assim:

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


Qual é o sentido de usar esta biblioteca: solicitações únicas bookLoader.load (id) são acumuladas e enviadas para processamento com uma matriz de identificadores const bookLoader = new DataLoader (assíncrono ids => {...
Na saída, devemos retornar a promessa da matriz ou matriz de promessas que estão localizadas na mesma ordem que a matriz de entrada dos IDs.

Agora podemos reescrever nossa solicitação para os autores da seguinte maneira:

 // 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 um nível arbitrário de aninhamento sem nos preocupar com o problema SELECT N + 1 (embora à custa de uma chamada incondicional para preencher () mesmo quando não fosse necessário):

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


Mas aqui você precisa realmente entender que, se continuarmos trabalhando com servidores SQL, em cada nível de aninhamento de objetos haverá uma consulta agregada. Ao mesmo tempo, às vezes é necessário que essa ainda seja exatamente uma solicitação. Mas você não pode fazer isso tão diretamente usando a biblioteca do dataloader. Um exemplo de mudanças está disponível na ramificação do repositório do carregador de dados.

apapacy@gmail.com
31 de maio de 2018

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


All Articles