graphql — оптимизация запросов к базе данных +6


При работе с базами данных существует проблема которую принято называть «SELECT N + 1» — это когда приложение вместо одного запроса к базе данных, который выбирает все необходимые данные из нескольких связанных таблиц, коллекций, — делает дополнительный подзапрос для каждой строки результата первого запроса, чтобы получить связанные данные. Например, сначала мы получаем список студентов университета, в котором его специальность обозначена идентификатором, а потом для каждого из студентов делаем дополнительный подзапрос в таблицу или коллекцию специальностей, чтобы по идентификатору специальности получить наименование специальности. Поскольку каждый из подзапросов может потребовать еще один подзапрос, и еще один подзапрос — колчество запросов к базе данных начинает расти в геометрической прогрессии.

При работе с 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 года




К сожалению, не доступен сервер mySQL