Как может $ facet улучшить производительность поиска в $ - PullRequest
0 голосов
/ 13 мая 2018

Выпуск

Недавно я посетил техническую встречу и показал свой код гораздо более опытному разработчику. Он прокомментировал, что у меня возникнут проблемы с конвейером из-за $lookup и что я должен рассмотреть возможность использования $facet, чтобы это исправить.

Я не помню, с какой проблемой, по его словам, я столкнусь, и как $facet может помочь ее исправить. Я думаю, что это как-то связано с пределом в 16 МБ, но это можно решить с помощью $unwind после $lookup.

Мой код (Node.js)

У меня есть коллекция Post документов. Некоторые сообщения родительские сообщения , а другие сообщения комментарии . Сообщение, являющееся комментарием, идентифицируется тем фактом, что его свойство parent НЕ null.

Моя цель - вернуть массив самых последних родительских сообщений и прикрепить к каждому из них свойство int, равное количеству комментариев, которые оно имеет.

Вот моя Post схема мангуста

const postSchema = new mongoose.Schema({
    title: { type: String, required: true, trim: true },
    body: { type: String, required: true, trim: true },
    category: { type: String, required: true, trim: true, lowercase: true },
    timestamp: { type: Date, required: true, default: Date.now },
    parent: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', default: null },
});

Вот мой трубопровод

const pipeline = [
    { $match: { category: query.category } },
    { $sort: { timestamp: -1 } },
    { $skip: (query.page - 1) * query.count },
    { $limit: query.count },
    {
        $lookup: {
            from: 'posts',
            localField: '_id',
            foreignField: 'parent',
            as: 'comments',
        },
    },
    {
        $addFields: {
            comments: { $size: '$comments' },
            id: '$_id',
        },
    },
    { $project: { _id: 0, __v: 0 } },
];

1 Ответ

0 голосов
/ 14 мая 2018

Короче, не может.Но если кто-то сказал вам это, то он заслуживает объяснения, чтобы объяснить, почему такая концепция неверна.

Почему НЕ $ facet

Как прокомментировано, $facet не можетсделать что-нибудь для вас здесь и, вероятно, было неверное представление о том, что ваш запрос должен делать.Во всяком случае, стадия конвейера $facet вызовет больше проблем с пределом BSON из-за того факта, что единственным выходом стадии конвейера $facet является «Единый документ» , что означает, что, если вы фактически не используете его для намеченной цели «сводных результатов», вы почти наверняка нарушите этот предел в реальных условиях.

Самая большая причина, по которой онпросто не применяется, потому что ваш источник $lookup извлекает данные из другой коллекции.Этап $facet применяется только к «той же коллекции», поэтому вы не можете иметь выходные данные из одной коллекции в одном «фасете» и другой коллекции в другом фасете.Для той же коллекции, в которой выполняется .aggregate(), могут быть определены только «конвейеры».

$ lookup - это STILL, что вы хотите

Точка ограничения размера BSON, однако, совершеннодопустимо, поскольку основной сбой в текущем конвейере агрегации - использование оператора $size в возвращаемом массиве.«Массив» на самом деле является проблемой здесь, так как «unbound» имеет «потенциал» для извлечения документов из связанной коллекции, что фактически приводит к тому, что родительский документ, содержащий этот массив в выходных данных, превышает предел BSON.

Таким образом, есть два основных подхода к этому, которые вы можете использовать для того, чтобы просто получить «размер» без необходимости создания массива «целых» связанных документов.

MongoDB3.6 и выше

Здесь вы будете использовать $lookup с синтаксисом выражения «sub-pipe», введенным в этой версии, чтобы просто возвращать «уменьшенное количество» без фактического возврата каких-либо документов:

const pipeline = [
    { "$match": { "category": query.category } },
    { "$sort": { "timestamp": -1 } },
    { "$skip": (query.page - 1) * query.count },
    { "$limit": query.count },
    { "$lookup": {
      "from": "posts",
      "let": { "id": "$_id" },
      "pipeline": [
        { "$match": {
          "$expr": { "$eq": [ "$$id", "$parent" ] }
        }},
        { "$count": "count" }
      ],
      "as": "comments",
    }},
    { $addFields: {
        "comments": { 
          "$ifNull": [ { "$arrayElemAt": ["$comments.count", 0] }, 0 ]
        },
        "id": "$_id"
    }}
];

Проще говоря, новый "под-конвейер" возвращает в целевой "массив" (который всегда является массивом) только выходные данные конвейерного выражения.Здесь мы не только $match на значениях локального и внешнего ключа (что фактически делает другая форма $lookup теперь внутри), но мы продолжаем конвейер, используя$count stage, который опять-таки фактически является синонимом для:

{ "$group": { "_id": null, "count": { "$sum": 1 } } },
{ "$project": { "_id": 0, "count": 1 } }

Дело в том, что в ответе массива вы когда-либо получаете только «один» документ, который мы можем затемлегко преобразовать в единственное значение с помощью $arrayElemAt и с использованием $ifNull в случае отсутствия совпадений в иностранной коллекции для получения счетчика 0

Более ранние версии

Для более ранних версий, чем MongoDB 3.6, общая идея - $unwind непосредственно после $lookup.Это на самом деле имеет специальное действие, которое описано в $ lookup + $ unwind Coalescence в более широком разделе руководства по Агрегационная оптимизация конвейера .Лично я считаю это скорее «помехой», чем «оптимизацией», так как вы действительно должны быть способны «выразить то, что вы имеете в виду» вместо того, чтобы что-то делать для вас «за спиной».Но основы таковы:

const pipeline = [
    { "$match": { "category": query.category } },
    { "$sort": { "timestamp": -1 } },
    { "$skip": (query.page - 1) * query.count },
    { "$limit": query.count },
    { "$lookup": {
      "from": "posts",
      "localField": "_id",
      "foreignField": "parent",
      "as": "comments"
    }},
    { "$unwind": "$comments" },
    { "$group": {
      "_id": "$_id",
      "otherField": { "$first": "$otherField" },
      "comments": { "$sum": 1 }
    }}
];

Важная часть здесь - это то, что на самом деле происходит с этапами $lookup и $unwind, как можетбыть просмотренным с помощью explain() для просмотра проанализированного конвейера как фактически выраженного сервером:

        {
            "$lookup" : {
                "from" : "posts",
                "as" : "comments",
                "localField" : "_id",
                "foreignField" : "parent",
                "unwinding" : {
                        "preserveNullAndEmptyArrays" : false
                }
            }
        }

То, что unwinding по существу "свернуто" в $lookup, а само $unwind "исчезает". Это происходит потому, что комбинация переводится таким «особым образом», что фактически приводит к «размотанным» результатам $lookup вместо нацеливания на массив. По сути, это делается для того, чтобы «массив» никогда не создавался, тогда предел BSON никогда не может быть нарушен.

Остальное, конечно, довольно просто: вы просто используете $group, чтобы «сгруппировать» исходный документ. Вы можете использовать $first в качестве аккумулятора для хранения любых полей документа, который вы хотите в ответе, и просто $sum для подсчета возвращенных внешних данных.

Поскольку это мангуста, я уже обрисовал процесс «автоматизации» построения всех полей для включения в $first как часть моего ответа на Запросы после заполнения в Mongoose который показывает, как проверить «схему», чтобы получить эту информацию.

Еще одна «морщина» - это $unwind, отменяющая «LEFT JOIN», присущую $lookup, поскольку там, где нет совпадений с родительским содержимым, тогда это " родительский документ "исключен из результатов. Я не совсем уверен в этом на момент написания (и должен посмотреть это позже), но опция preserveNullAndEmptyArrays имела ограничение в том смысле, что она не может применяться в этой форме "Объединения", однако это не так по крайней мере, MongoDB 3.6:

const pipeline = [
    { "$match": { "category": query.category } },
    { "$sort": { "timestamp": -1 } },
    { "$skip": (query.page - 1) * query.count },
    { "$limit": query.count },
    { "$lookup": {
      "from": "posts",
      "localField": "_id",
      "foreignField": "parent",
      "as": "comments"
    }},
    { "$unwind": { "path": "$comments", "preserveNullAndEmptyArrays": true } },
    { "$group": {
      "_id": "$_id",
      "otherField": { "$first": "$otherField" },
      "comments": {
        "$sum": {
          "$cond": {
            "if": { "$eq": [ "$comments", null ]  },
            "then": 0,
            "else": 1
          }
        }
      }
    }}
];

Так как я не могу на самом деле подтвердить, что он работает должным образом во всем, кроме MongoDB 3.6, то это бессмысленно, так как в более новой версии вы все равно должны использовать другую форму $lookup. Я знаю, что была, по крайней мере, первоначальная проблема с MongoDB 3.2 в том, что preserveNullAndEmptyArrays отменял «Объединение» и, следовательно, $lookup все еще возвращал вывод в виде «массива», и только после этой стадией был массив "размотан". Который побеждает цель сделать это, чтобы избежать предела BSON.


Сделай это в коде

Все это говорит о том, что в конечном итоге вы просто ищете "подсчеты", которые будут добавлены к вашим результатам для "связанных" комментариев. Пока вы не тянете страницы с «сотнями элементов», тогда ваше условие $limit должно поддерживать это при разумном результате, чтобы просто запускать count() запросов для получения соответствующий документ учитывается для каждого ключа без «слишком много» * ​​1159 * накладных расходов, что делает его необоснованным:

// Get documents
let posts = await Post.find({ "category": query.category })
    .sort({ "timestamp": -1 })
    .skip((query.page - 1) * query.count)
    .limit(query.count)
    .lean().exec();

// Map counts to each document
posts = (await Promise.all(
  posts.map(post => Comment.count({ "parent": post._id }) )
)).map((comments,i) => ({ ...posts[i], comments }) );

Здесь «компромисс» заключается в том, что хотя «параллельное» выполнение всех этих запросов count() означает дополнительные запросы к серверу, издержки каждого запроса сами по себе действительно низкие. Получение «счетчика курсоров» результата запроса намного эффективнее, чем использование чего-то вроде $count этапа конвейера агрегации, показанного выше.

Это накладывает нагрузку на соединения с базой данных при выполнении, но у него нет той же «нагрузки по обработке», и, конечно, вы когда-либо просматриваете только «подсчеты», и никакие документы не возвращаются по проводам или даже «извлекаются». "из коллекции при обработке курсора результатов.

Так что последнее - это, по сути, «оптимизация» процесса mongoose populate(), когда мы на самом деле не запрашиваем «документы», а просто получаем счетчик для каждого запроса. Технически populate() будет использовать здесь «один» запрос с $in для всех документов в предыдущем результате. Но это не сработает, потому что вам нужно общее количество на «родителя», которое по сути является агрегацией в одном запросе и ответе. Следовательно, почему здесь выдается несколько запросов.

Резюме

Итак, чтобы избежать проблем BSON , вы действительно ищете какой-либо из тех методов, который позволяет избежать возврата «массива» связанных документов из вашего $lookup этап конвейера, который используется для "соединения", либо путем получения "сокращенных данных", либо с помощью метода "счетчика курсоров".

Существует немного больше "глубины" в пределе размера BSON и обработки в:

Совокупный $ lookup Общий размер документов в соответствующем конвейере превышает максимальный размер документа на этом сайте.Обратите внимание, что те же методы, которые продемонстрированы там, чтобы вызвать ошибку, могут также применяться к стадии $facet, так как ограничение в 16 МБ является константой для всего, что является «документом».И почти "все" в MongoDB является документом BSON, поэтому работа в указанных пределах чрезвычайно важна.

ПРИМЕЧАНИЕ : чисто из "производительности"«В перспективе самая большая проблема за пределами потенциального нарушения BSON Size Limit, присущего вашему текущему запросу, на самом деле - обработка $skip и $limit.Если то, что вы на самом деле реализуете, - это больше функциональность типа «Загрузить больше результатов ...», то что-то вроде Реализация нумерации страниц в mongodb , где вы бы использовали «диапазон» для запуска следующей «страницы»выбор, исключая предыдущие результаты, намного более ориентирован на производительность, чем $skip и $limit.

Пейджинг с $skip и$limit следует использовать только там, где у вас нет другого выбора.Находясь на «пронумерованных страницах», где вы можете перейти на любую пронумерованную страницу.И даже тогда все же гораздо лучше вместо этого «кэшировать» результаты в заранее определенные наборы.

Но это действительно «совсем другой вопрос», чем существенный вопрос здесь о пределе размера BSON.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...