正如前面在评论中所说,发生错误是因为在执行$lookup 时,默认情况下会根据外部集合的结果在父文档中生成一个目标“数组”,为该数组选择的文档的总大小会导致父级超过16MB BSON Limit.
此计数器将使用紧跟在$lookup 管道阶段之后的$unwind 进行处理。这实际上改变了$lookup 的行为,因此结果不是在父级中生成一个数组,而是每个匹配的文档的每个父级的“副本”。
与$unwind 的常规用法非常相似,只是unwinding 操作实际上添加到$lookup 管道操作本身,而不是作为“单独的”管道阶段处理。理想情况下,您还可以在$unwind 之后使用$match 条件,这也将创建一个matching 参数以添加到$lookup。您实际上可以在管道的explain 输出中看到这一点。
核心文档中的Aggregation Pipeline Optimization 部分实际上(简要地)介绍了该主题:
$lookup + $unwind 合并
3.2 版中的新功能。
当 $unwind 紧跟在另一个 $lookup 之后,并且 $unwind 在 $lookup 的 as 字段上运行时,优化器可以将 $unwind 合并到 $lookup 阶段。这样可以避免创建大型中间文档。
最好通过创建超过 16MB BSON 限制的“相关”文档来使服务器承受压力的清单进行演示。尽可能简短地打破和解决 BSON 限制:
const MongoClient = require('mongodb').MongoClient;
const uri = 'mongodb://localhost/test';
function data(data) {
console.log(JSON.stringify(data, undefined, 2))
}
(async function() {
let db;
try {
db = await MongoClient.connect(uri);
console.log('Cleaning....');
// Clean data
await Promise.all(
["source","edge"].map(c => db.collection(c).remove() )
);
console.log('Inserting...')
await db.collection('edge').insertMany(
Array(1000).fill(1).map((e,i) => ({ _id: i+1, gid: 1 }))
);
await db.collection('source').insert({ _id: 1 })
console.log('Fattening up....');
await db.collection('edge').updateMany(
{},
{ $set: { data: "x".repeat(100000) } }
);
// The full pipeline. Failing test uses only the $lookup stage
let pipeline = [
{ $lookup: {
from: 'edge',
localField: '_id',
foreignField: 'gid',
as: 'results'
}},
{ $unwind: '$results' },
{ $match: { 'results._id': { $gte: 1, $lte: 5 } } },
{ $project: { 'results.data': 0 } },
{ $group: { _id: '$_id', results: { $push: '$results' } } }
];
// List and iterate each test case
let tests = [
'Failing.. Size exceeded...',
'Working.. Applied $unwind...',
'Explain output...'
];
for (let [idx, test] of Object.entries(tests)) {
console.log(test);
try {
let currpipe = (( +idx === 0 ) ? pipeline.slice(0,1) : pipeline),
options = (( +idx === tests.length-1 ) ? { explain: true } : {});
await new Promise((end,error) => {
let cursor = db.collection('source').aggregate(currpipe,options);
for ( let [key, value] of Object.entries({ error, end, data }) )
cursor.on(key,value);
});
} catch(e) {
console.error(e);
}
}
} catch(e) {
console.error(e);
} finally {
db.close();
}
})();
插入一些初始数据后,列表将尝试运行仅包含 $lookup 的聚合,这将失败并出现以下错误:
{ MongoError: 边缘匹配管道中文档的总大小 { $match: { $and : [ { gid: { $eq: 1 } }, {} ] } } 超过最大文档大小
这基本上告诉您检索时超出了 BSON 限制。
相比之下,下一次尝试添加了$unwind 和$match 管道阶段
解释输出:
{
"$lookup": {
"from": "edge",
"as": "results",
"localField": "_id",
"foreignField": "gid",
"unwinding": { // $unwind now is unwinding
"preserveNullAndEmptyArrays": false
},
"matching": { // $match now is matching
"$and": [ // and actually executed against
{ // the foreign collection
"_id": {
"$gte": 1
}
},
{
"_id": {
"$lte": 5
}
}
]
}
}
},
// $unwind and $match stages removed
{
"$project": {
"results": {
"data": false
}
}
},
{
"$group": {
"_id": "$_id",
"results": {
"$push": "$results"
}
}
}
这个结果当然是成功的,因为结果不再被放入父文档中,所以不能超过 BSON 限制。
这实际上只是由于添加了$unwind 而发生的,但是添加了$match 以表明这是也添加到$lookup 阶段并且整体效果是以有效的方式“限制”返回的结果,因为这一切都在 $lookup 操作中完成,除了匹配的结果之外没有其他结果实际返回。
通过以这种方式构建,您可以查询超出 BSON 限制的“引用数据”,然后如果您希望 $group 将结果返回为数组格式,一旦它们已被“隐藏查询”有效过滤这实际上是由$lookup 执行的。
MongoDB 3.6 及更高版本 - “LEFT JOIN”的附加功能
正如上述所有内容所指出的,BSON 限制是一个您不能违反的“硬”限制,这通常是为什么$unwind 作为临时步骤是必要的。然而,由于$unwind 无法保留内容,“LEFT JOIN”成为“INNER JOIN”的限制。甚至preserveNulAndEmptyArrays 也会否定“合并”并仍然保留完整的数组,从而导致相同的 BSON 限制问题。
MongoDB 3.6 向$lookup 添加了新语法,允许使用“子管道”表达式代替“本地”和“外来”键。因此,只要生成的数组不违反限制,就可以在返回数组“完整”的管道中放置条件,而不是使用演示的“合并”选项,并且可能没有匹配项作为指示的“左连接”。
新的表达式将是:
{ "$lookup": {
"from": "edge",
"let": { "gid": "$gid" },
"pipeline": [
{ "$match": {
"_id": { "$gte": 1, "$lte": 5 },
"$expr": { "$eq": [ "$$gid", "$to" ] }
}}
],
"as": "from"
}}
事实上,这基本上就是 MongoDB 正在做的“在幕后”,使用以前的语法,因为 3.6 使用 $expr“内部”来构造语句。当然,不同之处在于$lookup 的实际执行方式中没有"unwinding" 选项。
如果"pipeline" 表达式实际上没有生成任何文档,那么主文档中的目标数组实际上将是空的,就像“LEFT JOIN”实际上所做的那样并且将是@ 的正常行为987654346@ 没有任何其他选项。
但是,输出数组不得导致创建它的文档超过 BSON 限制。因此,您必须确保条件下的任何“匹配”内容保持在此限制以下,否则相同的错误将持续存在,当然,除非您实际使用 $unwind 来实现“INNER JOIN”。