Thi*_*o P 5 join mongoose mongodb node.js aggregation-framework
我最近参加了一次技术聚会,并向经验丰富的开发人员展示了我的一些代码。他评论说,由于这些原因,我的管道会遇到问题$lookup,我应该考虑使用$facet此方法来解决此问题。
我不记得他说过我会遇到什么问题,也$facet无法帮助解决它。我认为这与16mb文件限制有关,但这可以通过使用$unwindafter 来解决$lookup。
我有一些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)
简而言之,它不能。但是,如果有人告诉过您,那么应该解释一下为什么这样的概念不正确。
如前所述,$facet在这里无法为您做任何事情,并且可能是您的查询意图做什么的误解。如果有的话,$facet由于明显的事实,$facet管道阶段的唯一输出是“单个文档”,因此管道阶段将导致BSON限制出现更多问题,这意味着除非您实际将其用于“摘要”的预期目的结果”,那么您几乎肯定会在现实世界中违反此限制。
它根本不适用的最大原因是,您的$lookup源正在从其他集合中提取数据。该$facet阶段仅适用于“相同集合”,因此您不能在一个“构面”中拥有一个集合的输出,而在另一个构面中拥有另一集合的输出。只能.aggregate()为正在执行的同一集合定义“管道” 。
但是,BSON大小限制的要点是完全有效的,因为当前聚合管道中的主要故障是使用$size返回数组上的运算符。“数组”实际上是这里的问题,因为“未绑定”具有从关联集合中提取文档的“潜在”潜力,这实际上导致在输出中包含此数组的父文档超过BSON限制。
因此,您可以使用两种基本方法来简单地获取“大小”,而无需实际创建“整个”相关文档的数组。
在这里,您将使用$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大小限制的基本问题。