Archives pour la catégorie MongoDB

L’agrégation sur un serveur MongoDB mono-instance : NON

J’ai eu récemment de gros problèmes de performances sur un serveur, lié à MongoDB. Je vais donc vous présenter le problème et sa solution :)

TL;DR : Tout le code est ici pour faire le test vous-même.

Le contexte général : on a une collection assez large de documents comportant un code de regroupement et une valeur numérique. On cherche à faire une agrégation très classique consistant à prendre l’item dont la valeur numérique est la plus haute, regroupé par le code de regroupement.

Par exemple : on a une collection d’articles (auteur, date, texte) et on veut sortir la liste des derniers articles postés par auteur. Pour l’exemple j’ai préparé une collection de 50’000 documents à valeurs aléatoires (auteur parmi une liste pré-définie, date dans les 3 derniers mois).

Si on connaît bien MongoDB, on va utiliser le framework d’agrégation. Mais dans mon cas avec un MongoDB 2.2, impossible.

La map/reduce

Du coup, on se rabat logiquement sur un map/reduce :

// Article grouped by author
function map () {
  emit(this.author, this);
}
 
// Keep max date
function reduce (key, articles) {
  return articles.sort(function (a1, a2) { return a2.date - a1.date })[0]
}
 
…
.then(mapReduce(map, reduce))

C’est lent. Sur ma machine avec un mongodb en 2.4 ça tourne à ~900 ms. Sur le serveur dont je parlais au début, en 2.2, ça tape dans les 5 secondes (ouch).

Le map/reduce avec béquille

J’ai donc essayé de simplifier le map/reduce, me disant que « sort » devait être trop coûteux. Je sors donc juste la date max par auteur, puis je fais un find :

// Date grouped by author
function map () {
  emit(this.author, this.date);
}
 
// Keep max date
function reduce (key, dates) {
  return Math.max.apply(null, dates)
}
 
…
.then(mapReduce(map, reduce))
// Build query: big $or
.then(function (results) {
  return {$or: results.map(function (result) {
    return {author: result._id, date: new Date(result.value)}
  })})
.then(find)

On y gagne, mais ça reste lent. Sur ma machine concrètement on passe de ~900 ms à ~700 ms, c’est quand-même un beau gain de plus de 20%.

On abandonne le map/reduce

Du coup j’ai essayé la méthode débile. J’ai sorti la liste des clés possibles (la liste des auteurs ici), puis pour chacun fait une requête avec un tri sur la date. Dans notre exemple ça donnerait quelque-chose comme ça :

…
.then(find({}, {author: 1}) // [{author: "Bob"}, {author: "John"}]
.then(pluck('author'))      // ["Bob", "John"]
.then(function (authors) {
  // N queries in concurrency
  return Q.all(authors.map(function (author) {
    // max date's article for this author
    return find({author: author}).sort({date: -1}).limit(1).nextObject()
  }))
})

Le résultat est sans appel : le premier « find » prend en moyenne ~270 ms, les N « find » suivant au total ~60 ms pour un total de ~330 ms soit un gain supérieur à 60%. Et comme c’est la première requête qui prend l’essentiel du temps, si on a la possibilité d’avoir la liste des auteurs de manière moins coûteuse (par exemple une valeur en configuration, ou simplement les listes d’une collection tierce, ce qui dans notre exemple aurait été plus logique) on peut vite diviser par 10 le temps de réponse initial.

Ce fut mon cas, je suis passé de 5 secondes à 0.5 secondes sur mon serveur, en passant de 1 à N+1 requête (une quinzaine dans ce cas).

Conclusion

Comparaison des méthodes

Pour l’anecdote, le problème ne s’était pas posé sur notre premier serveur de recette monté un peu à l’arrache et surtout… branché sur un server mongodb dans le cloud (Clever Cloud en l’occurrence). Et dans ce cas le map/reduce fonctionnait très bien.

Tout ça pour dire : N’oubliez donc pas que MongoDB est mono-threadé, en plus d’avoir un runtime plutôt lent, et que donc s’il n’est pas capable de distribuer les calculs… il vaut mieux ne pas lui en demander :) N’oubliez pas de jeter un œil au TL;DR : code des tests.