J'ai un assez simple $lookup
requête d'agrégation comme suit:
{'$lookup':
{'from': 'Edge',
'localField': 'gid',
'foreignField': 'to',
'as': 'from'}}
Lorsque j'exécute cela sur une correspondance avec suffisamment de documents, j'obtiens l'erreur suivante:
Command failed with error 4568: 'Total size of documents in Edge
matching { $match: { $and: [ { from: { $eq: "geneDatabase:hugo" }
}, {} ] } } exceeds maximum document size' on server
Toutes les tentatives pour limiter le nombre de documents échouent. allowDiskUse: true
ne fait rien. L'envoi d'un cursor
in ne fait rien. Ajout d'un $limit
dans l'agrégation échoue également.
Comment est-ce possible?
Ensuite, je vois à nouveau l'erreur. Où est-ce que $match
et $and
et $eq
viens de? Le pipeline d'agrégation se déroule-t-il dans les coulisses du $lookup
appel à une autre agrégation, une qui s'exécute d'elle-même avec laquelle je n'ai aucune capacité à fournir des limites ou à utiliser des curseurs avec ??
Qu'est-ce qui se passe ici?
Comme indiqué précédemment dans le commentaire, l'erreur se produit car lors de l'exécution de la $lookup
qui, par défaut, produit un "tableau" cible dans le document parent à partir des résultats de la collection étrangère, la taille totale des documents sélectionnés pour ce tableau entraîne le parent à dépasser 16 Mo BSON Limit.
Le compteur pour cela est de traiter avec un $unwind
qui suit immédiatement le $lookup
étape du pipeline. Cela modifie en fait le comportement de $lookup
de telle sorte qu'au lieu de produire un tableau dans le parent, les résultats sont plutôt une "copie" de chaque parent pour chaque document correspondant.
Tout comme l’utilisation régulière de $unwind
, à l'exception qu'au lieu de traiter comme une étape de pipeline "séparée", l'action unwinding
est en fait ajoutée à $lookup
opération de pipeline elle-même. Idéalement, vous suivez également les $unwind
avec un $match
, qui crée également un argument matching
à ajouter également à la $lookup
. Vous pouvez réellement voir cela dans la sortie explain
du pipeline.
Le sujet est en fait couvert (brièvement) dans une section de Aggregation Pipeline Optimization dans la documentation principale:
$ lookup + $ unwind Coalescence
Nouveau dans la version 3.2.
Lorsqu'un déroulement $ suit immédiatement une autre recherche $ et que le déroulement $ opère sur le champ as de la recherche $, l'optimiseur peut fusionner le déroulement $ dans l'étape de recherche $. Cela évite de créer de gros documents intermédiaires.
Il est préférable de le démontrer avec une liste qui met le serveur sous tension en créant des documents "connexes" qui dépasseraient la limite BSON de 16 Mo. Fait aussi brièvement que possible pour briser et contourner la limite 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();
}
})();
Après avoir inséré des données initiales, la liste tentera d'exécuter un agrégat simplement composé de $lookup
qui échouera avec l'erreur suivante:
{MongoError: la taille totale des documents dans le pipeline de correspondance Edge {$ match: {$ et: [{gid: {$ eq: 1}}, {}]}} dépasse la taille maximale du document
Ce qui vous indique essentiellement que la limite BSON a été dépassée lors de la récupération.
En revanche, la prochaine tentative ajoute le $unwind
et $match
étapes du pipeline
La sortie Explain :
{
"$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"
}
}
}
Et ce résultat réussit bien sûr, car comme les résultats ne sont plus placés dans le document parent, la limite BSON ne peut pas être dépassée.
Cela se produit vraiment simplement suite à l'ajout de $unwind
uniquement, mais le $match
est ajouté par exemple pour montrer que c'est également ajouté dans le $lookup
étape et que l'effet global est de "limiter" les résultats retournés de manière efficace, car tout est fait dans cette $lookup
opération et aucun autre résultat que ceux correspondant n'est réellement retourné.
En construisant de cette manière, vous pouvez rechercher des "données référencées" qui dépasseraient la limite BSON, puis si vous le souhaitez $group
les résultats dans un format de tableau, une fois qu'ils ont été efficacement filtrés par la "requête cachée" qui est réellement effectuée par $lookup
.
Comme tout le contenu ci-dessus le note, la limite BSON est une limite "dure" que vous ne pouvez pas violer et c'est généralement la raison pour laquelle $unwind
est nécessaire comme étape intermédiaire. Il y a cependant la limitation que le "LEFT JOIN" devient un "INNER JOIN" en vertu de $unwind
où il ne peut pas conserver le contenu. De plus, même preserveNulAndEmptyArrays
annulerait la "coalescence" et laisserait toujours le tableau intact, provoquant le même problème de limite BSON.
MongoDB 3.6 ajoute une nouvelle syntaxe à $lookup
qui permet d'utiliser une expression "sous-pipeline" à la place des clés "locales" et "étrangères". Ainsi, au lieu d'utiliser l'option "coalescence" comme démontré, tant que le tableau produit ne dépasse pas également la limite, il est possible de mettre des conditions dans ce pipeline qui retourne le tableau "intact", et éventuellement sans correspondance comme cela serait indicatif d'un "LEFT JOIN".
La nouvelle expression serait alors:
{ "$lookup": {
"from": "Edge",
"let": { "gid": "$gid" },
"pipeline": [
{ "$match": {
"_id": { "$gte": 1, "$lte": 5 },
"$expr": { "$eq": [ "$$gid", "$to" ] }
}}
],
"as": "from"
}}
En fait, ce serait fondamentalement ce que fait MongoDB "sous les couvertures" avec la syntaxe précédente puisque 3.6 utilise $expr
"en interne" afin de construire l'instruction. La différence est bien sûr qu'il n'y a pas de "unwinding"
option présente dans la façon dont $lookup
est effectivement exécuté.
Si aucun document n'est réellement produit à la suite du "pipeline"
, le tableau cible dans le document maître sera en fait vide, tout comme le fait réellement un "LEFT JOIN" et serait le comportement normal de $lookup
sans aucune autre option.
Cependant, le tableau de sortie à NE DOIT PAS faire en sorte que le document où il est créé dépasse la limite BSON . Donc, c'est vraiment à vous de vous assurer que tout contenu "correspondant" par les conditions reste sous cette limite ou la même erreur persistera, à moins bien sûr que vous n'utilisiez réellement $unwind
pour effectuer la "INNER JOIN".