При работе с graphql очень просто породить проблему «SELECT N + 1», если в resolver-функции сделать подзапрос к связанной таблице. Первое что приходит в голову — сделать запрос сразу с учетом всех связанных данных, но это, согласитесь, нерационально, если связанные данные не запрашиваются клиентом.
Один из вариантов решения проблемы «SELECT N + 1» для graphql будет рассмотрен в этом сообщении.
Для примера возьмем две коллекции: «Авторы» (Author) и «Книги» (Book). Связь, как и следует полагать, «многие-ко-многим». У одного Автора может быть несколько Книг, и одна Книга может быть написана несколькими Авторами. Для хранения информации будем использовать базу данных mongodb и библиотеку mongoose.js
Связь между коллекциями «многие-ко-многим» реализуем при помощи вспомогательной коллекции «BookAuthor» и «виртуальных» полей.
// 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);
Теперь определим типы Author и Book в graphql. Есть небольшая проблема с тем, что эти типы взаимно ссылаются друг на друга. Поэтому для их взаимного доступа используется привязка ссылок к объекту модуля exports, а не привязка нового объекта к module.exports (который заменяет исходный объект), а также поле fields реалзовано в виде функции, что позволяет «отложить» чтение ссылки на объект при его создании до того момента, когда все циклические ссылки станут доступными:
// 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)
}
})
});
Теперь определим запрос Авторов, возможно, с перечнем их книг, и, возможно, с перечнем авторов (соавторов) этих книг.
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();
}
};
Для того чтобы определить, запрос каких полей пришел с клиента, используется библиотека graphql-list-fields. И если пришел запрос со вложенными объектами — то вызывается метод populate() библиотеки mongoose.
Теперь поэкпериментируем с запросами. Максимально возможный для нашей реализации запрос:
{
author {
_id
name
books {
_id
title
authors {
_id
name
}
}
}
}
будет выполнен 5-ю обращениями к базе данных:
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: {} })
Как видим, функция mongoose.js — populate() — не использует относительно новую возможность mongodb — $lookup, а создает дополнительные запросы. Но это не проблема «SELECT N + 1» т.к. новый запрос создается не для каждой строки, а для все коллекции. (Желание проверить как на самом деле работет функция mongoose.js populate() — одним запросом или несколькими — была одним из мотивов выбора не реляционной базы для этого примера).
Если же мы используем минималистический запрос:
{
author {
_id
name
}
}
то он сформирует только одно обращение к базе данных:
authors.find({}, { fields: {} })
Этого, собственно, я и добивался. В заключении скажу, что когда я начал искать решения для этой задачи, то нашел очень удобные и продвинутые библиотеки, решающие эту задачу. Одна из них, например, которая мне очень понравилась, на основании структуры реляционной базы данных формировала схему graphql со всеми необходимыми операциями. Однако, такой подход допустим, если graphql используется на стороне бэкэнда приложения. Если открыть доступ к таким сервисам с фронтенда приложения (что мне и нужно было), то это аналогично тому, что поместить в открытом доступе админку к серверу базы данных, т.к. все таблицы становится доступными «из коробки»
Для удобства читателей работающий пример расположил в репозитории.
Дополнение по комментарию пользователя joniks
Ползователь joniks в кормментарии сослался на библиотеку https://github.com/facebook/dataloader. Давайте посмотрим как эта библиотека позволить справиться с проблемой «SELECT N + 1»
С учетом этой библиотеки определение типа graphql Authors будет выглядеть так:
// 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))
}
})
});
В чем смысл использования этой библиотеки: одиночные запросы bookLoader.load(id) накапливаются и передаются на обработку с массивом идентификаторов const bookLoader = new DataLoader(async ids => {…
На выходе мы должны вернуть promise of array или array of promises которые расположены в том же порядке что и входной массив ids.
Теперь мы можем переписать наш запрос Авторов так:
// 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();
}
};
В результате мы можем проводить запрос связанных объектов произвольного уровня вложенности, не беспокоясь о проблеме SELECT N + 1 (правда ценой безусловного вызова populate() даже там где это было и не нужно):
{
authors {
_id
name
books {
_id
title
authors {
_id
name
books {
_id
title
authors {
_id
name
}
}
}
}
}
}
Но здесь нужно реально понимать, что если мы перейдем к работе с SQL серверами, то на каждый уровень вложенности объектов будет по одному агрегированному запросу. В то же время, иногда требуется, чтобы это был все же ровно один запрос. Но этого так напрямую при использовании библиотеки dataloader не добъешься. Пример с изменениями доступен в ветке репозитария data-loader.
apapacy@gmail.com
31 мая 2018 года
Комментарии (10)
VolCh
01.06.2018 09:16Вот, кстати, никогда не понимал безусловного стремления обходиться одним запросом к базе. Ладно когда связи 0..1:0..1, но как где-то N появляется, то в 99% случаев гораздо лучше сделать два или три запроса, чем городить многоэтажные выборки, особенно если база сама нужный граф сформировать не может.
maxzh83
01.06.2018 09:43Часто бывает, что потом количество записей возрастает и вместо двух или трех запросов, будет 20-30 или 200-300.
Moxa
01.06.2018 10:37У нас примерно 200 запросов на старте приложения, страница открывается за полторы секунды
VolCh
01.06.2018 11:14Я не о ситуации 1+N запросов, где N — число записей в результате первого запроса. Я о ситуации 1 запрос на таблицу/коллекцию, вместо объединения их на стороне базы средствами типа join
AstarothAst
01.06.2018 12:55Поддерживаю. сам перешел к подобному подходу, когда база стала не просто узким местом, но причиной реальных тормозов.
А потом вообще стал использовать in-memory хранилища, а в БД только персистинг. Отзывчивость возросла в разы. Потребление памяти, правда, тоже :D
apapacy Автор
01.06.2018 10:38Первый профит от одного запроса данные выбираются в одной транзакции то есть будут согласованы. Второй то что запросы если мы говорим о selext n+1растут в геометрической прогрессии и при этом очередная порция запросом идёт подряд. Например мы выбрали 1000 студентов запрос относительно лёгкий. Далее мы одним мвзом отправляет 1000 запрсовичтобы получить связанную инфу и база немного начинает подвисать
azhi
01.06.2018 19:04В эликсировской библиотеке absinthe есть фича «batch resolution», которая очень хорошо решает эту проблему.
Если вкратце: процесс определения значений запрашиваемых полей разбивается на этапы. Добавлен хелпер, который позволяет перенести вычисление запрашиваемого поля на следующий этап. Хелпер так же принимает функцию, которая будет проводить вычисление поля на следующем этапе, и вызывает ее с аггрегированной информацией предыдущего этапа. Т.е. по примеру из статьи: на первом этапе id всех авторов аггрегируются в массив, на втором этапе этот массив передается в функцию которая делает запрос в таблицу книг и возвращает для каждого автора список книг.
Но самому без поддержки со стороны graphql библиотеки такое организовать не получится.
joniks
Универсальное и простое решение это использовать библиотеку DataLoader.js от всеми любимой компании FaceBook.
apapacy Автор
Дполнил тест примером с использованием библиотеки dataloader