$ facet如何改善$ lookup性能

Thi*_*o P 5 join mongoose mongodb node.js aggregation-framework

问题

我最近参加了一次技术聚会,并向经验丰富的开发人员展示了我的一些代码。他评论说,由于这些原因,我的管道会遇到问题$lookup,我应该考虑使用$facet此方法来解决此问题。

我不记得他说过我会遇到什么问题,也$facet无法帮助解决它。我认为这与16mb文件限制有关,但这可以通过使用$unwindafter 来解决$lookup

我的代码(Node.js)

我有一些Post文件。一些帖子是父帖子,其他帖子是评论。发表评论的帖子通过其parent属性不是NOT 来标识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 },
});
Run Code Online (Sandbox Code Playgroud)

这是我的管道

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 } },
];
Run Code Online (Sandbox Code Playgroud)

Nei*_*unn 5

简而言之,它不能。但是,如果有人告诉过您,那么应该解释一下为什么这样的概念不正确。

为什么不$ facet

如前所述,$facet在这里无法为您做任何事情,并且可能是您的查询意图做什么的误解。如果有的话,$facet由于明显的事实,$facet管道阶段的唯一输出是“单个文档”,因此管道阶段将导致BSON限制出现更多问题,这意味着除非您实际将其用于“摘要”的预期目的结果”,那么您几乎肯定会在现实世界中违反此限制。

它根本不适用的最大原因是,您的$lookup源正在从其他集合中提取数据。该$facet阶段仅适用于“相同集合”,因此您不能在一个“构面”中拥有一个集合的输出,而在另一个构面中拥有另一集合的输出。只能.aggregate()为正在执行的同一集合定义“管道” 。

$ lookup仍然是您想要的

但是,BSON大小限制的要点是完全有效的,因为当前聚合管道中的主要故障是使用$size返回数组上的运算符。“数组”实际上是这里的问题,因为“未绑定”具有从关联集合中提取文档的“潜在”潜力,这实际上导致在输出中包含此数组的父文档超过BSON限制。

因此,您可以使用两种基本方法来简单地获取“大小”,而无需实际创建“整个”相关文档的数组。

MongoDB 3.6及更高版本

在这里,您将使用$lookup此版本中引入的“子管道”表达式语法来简单地返回“减少的计数”,而无需实际返回任何文档:

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"
    }}
];
Run Code Online (Sandbox Code Playgroud)

非常简单地将新的“子管道”返回放在管道表达式的输出中的目标“数组”(始终为数组)中。在这里,我们不仅要$match使用本地和外键值(实际上这是另$lookup一种形式现在在内部执行的值),而且我们使用$count阶段继续管道,这实际上也是以下各项的同义词:

{ "$group": { "_id": null, "count": { "$sum": 1 } } },
{ "$project": { "_id": 0, "count": 1 } }
Run Code Online (Sandbox Code Playgroud)

要点是,您在数组响应中最多只会收到一个“文档”,然后我们可以轻松地将其转换为奇异值,$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 }
    }}
];
Run Code Online (Sandbox Code Playgroud)

这里的重要部分是$lookup$unwind阶段实际上发生了什么,可以使用它explain()来查看服务器实际表达的已解析管道:

        {
            "$lookup" : {
                "from" : "posts",
                "as" : "comments",
                "localField" : "_id",
                "foreignField" : "parent",
                "unwinding" : {
                        "preserveNullAndEmptyArrays" : false
                }
            }
        }
Run Code Online (Sandbox Code Playgroud)

unwinding本质上讲,这会“卷入”,$lookup而其$unwind本身“会消失”。这是因为以这种“特殊方式”翻译了组合,这实际上导致了“展开”结果$lookup而不是针对数组。基本上这样做是为了,如果从未真正创建“数组”,那么就永远不会违反BSON限制。

其余部分当然很简单,因为您只是$group为了将其“组合”回原始文档而使用。您可以将其$first用作累加器,以便将您想要的文档的任何字段保留在响应中,并仅对$sum返回的外部数据进行计数。

由于这是猫鼬,因此我已经概述了“自动化”构建所有字段以包括在内的过程,$first作为在猫鼬填充后查询的答案的一部分,其中显示了如何检查“模式”以获得该信息。

另一个“皱纹”是$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
          }
        }
      }
    }}
];
Run Code Online (Sandbox Code Playgroud)

由于我实际上无法确认它是否可以在MongoDB 3.6以外的任何其他版本上正常工作,因此这毫无意义,因为对于较新的版本,$lookup无论如何都应采用其他形式。我知道,MongoDB 3.2至少存在一个最初的问题,即preserveNullAndEmptyArrays取消了“ Coalescence”,因此$lookup仍将返回的输出作为“数组”,并且只有此阶段之后才将数组“展开”。这违反了避免BSON限制的目的。


用代码做

综上所述,最终您只是在寻找要添加到“相关”注释的结果中的“计数”。只要您不拉入包含“数百个项目”的页面,那么您的$limit情况就应该使它保持合理的结果,以简单地触发count()查询以获取每个键上匹配的文档数,而不会造成“过多”的开销,以致于不合理:

// 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 }) );
Run Code Online (Sandbox Code Playgroud)

这里的“折衷”是,尽管对所有这些count()查询执行“并行”执行意味着对服务器的附加请求,但每个查询本身的开销实际上确实很低。与使用$count上面显示的聚合管道阶段之类的方法相比,获得查询结果的“游标计数”要有效得多。

这在执行时给数据库连接带来了负担,但它没有相同的“处理负担”,当然,您只查看“计数”,并且不会通过网络返回文件,甚至不会从文件中“获取”文件。在处理游标结果时进行收集。

最后,这基本上是对猫鼬populate()过程的“优化” ,在这里我们实际上并不要求“文档”,而只是为每个查询获取一个计数。从技术上讲populate(),此处$in将对先前结果中的所有文档使用“一个”查询。但这在这里行不通,因为您需要每个“父级”的总数,这实际上是单个查询和响应中的汇总。因此,为什么在这里发出“多个请求”。

摘要

因此,为了避免BSON限制问题,您真正要寻找的是那些$lookup通过获取“精简数据”来避免从管道阶段返回用于“加入” 的相关文档“数组”的任何技术。”或“光标计数”技术。

关于BSON大小限制和处理的更多“深度”:

总计$ lookup匹配管道中文档的总大小超过了该站点上的最大文档大小。请注意,此处演示的引起错误的相同技术也可以应用于此$facet阶段,因为16MB的限制对于“文档”中的任何内容都是常量。而且,MongoDB中的“一切”几乎都是 BSON文档,因此在限制范围内工作非常重要。

注意:纯粹从“性能”的角度来看,当前查询中固有的潜在BSON大小限制违规之外的最大问题实际上是$skipand $limit处理。如果您实际实现的功能更多是“加载更多结果...”类型的功能,则类似在mongodb实现分页,您将使用“范围”通过排除先前的结果来开始下一个“页面”选择,比$skip和更注重性能$limit

仅在您真的没有其他选择的情况下使用$skip和进行分页$limit。在“编号分页”上,您可以跳到任何编号的页面。即使这样,最好还是将结果“缓存”到预定义的集合中。

但这确实是一个“其他问题”,而不是这里有关BSON大小限制的基本问题。