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

  • Tutorial
При работе с базами данных существует проблема которую принято называть «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 года
Поделиться публикацией
Комментарии 10
    +3
    Универсальное и простое решение это использовать библиотеку DataLoader.js от всеми любимой компании FaceBook.
      0
      Дполнил тест примером с использованием библиотеки dataloader
      +2
      Вот, кстати, никогда не понимал безусловного стремления обходиться одним запросом к базе. Ладно когда связи 0..1:0..1, но как где-то N появляется, то в 99% случаев гораздо лучше сделать два или три запроса, чем городить многоэтажные выборки, особенно если база сама нужный граф сформировать не может.
        +1
        Часто бывает, что потом количество записей возрастает и вместо двух или трех запросов, будет 20-30 или 200-300.
          0

          У нас примерно 200 запросов на старте приложения, страница открывается за полторы секунды

            0
            Я не о ситуации 1+N запросов, где N — число записей в результате первого запроса. Я о ситуации 1 запрос на таблицу/коллекцию, вместо объединения их на стороне базы средствами типа join
              0
              Поддерживаю. сам перешел к подобному подходу, когда база стала не просто узким местом, но причиной реальных тормозов.
              А потом вообще стал использовать in-memory хранилища, а в БД только персистинг. Отзывчивость возросла в разы. Потребление памяти, правда, тоже :D
            –3
            Первый профит от одного запроса данные выбираются в одной транзакции то есть будут согласованы. Второй то что запросы если мы говорим о selext n+1растут в геометрической прогрессии и при этом очередная порция запросом идёт подряд. Например мы выбрали 1000 студентов запрос относительно лёгкий. Далее мы одним мвзом отправляет 1000 запрсовичтобы получить связанную инфу и база немного начинает подвисать
              +1
              Первый — согласен. Второй — чуть выше написал, я не про 1+N
            0
            В эликсировской библиотеке absinthe есть фича «batch resolution», которая очень хорошо решает эту проблему.
            Если вкратце: процесс определения значений запрашиваемых полей разбивается на этапы. Добавлен хелпер, который позволяет перенести вычисление запрашиваемого поля на следующий этап. Хелпер так же принимает функцию, которая будет проводить вычисление поля на следующем этапе, и вызывает ее с аггрегированной информацией предыдущего этапа. Т.е. по примеру из статьи: на первом этапе id всех авторов аггрегируются в массив, на втором этапе этот массив передается в функцию которая делает запрос в таблицу книг и возвращает для каждого автора список книг.
            Но самому без поддержки со стороны graphql библиотеки такое организовать не получится.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое