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.

5 réflexions au sujet de « L’agrégation sur un serveur MongoDB mono-instance : NON »

    1. naholyr Auteur de l’article

      En *MySQL* parce que leur GROUP BY est pas standard 😉
      Chez les autres (Postgres, Oracle, etc…) c’est un poil plus chiant à écrire, mais évidemment le principe est vrai, je chipote.

      Et en plus ça aurait probablement répondu en 50 ms directement. Note que dans l’exemple je n’ai aucun index (mon cas réel en avait, mais était un poil plus complexe évidemment, en plus d’être sur un serveur en mousse).

      La morale ? On n’utilise pas du NoSQL pour faire du relationnel (you don’t say :D), et on structure ses données avec en tête la façon dont on les lira. La vraie bonne manière de corriger le problème ici à mon avis aurait plutôt été de créer une autre collection « lastArticleByAuthor » mises à jour à chaque création d’article. Et là on aurait eu un simple find, et des perfs équivalentes (avec la scalabilité en plus).

      C’est pour ça d’ailleurs que je préfère généralement les bases clé/valeur : elles sont tellement éloignées de nos valeurs habituelles qu’on n’est pas tentés de reproduire un schéma de modélisation qui serait foireux. On est obligé de modéliser « façon NoSQL », et ça marche (vraiment) bien mieux.

      Répondre
  1. naholyr Auteur de l’article

    On m’a signalé via Twitter (https://twitter.com/nmarsup/status/448775184041119744) l’existence d’une option dans la commande mapReduce : `jsMode`.
    Elle permet de désactiver les conversions JS←→BSON, et de gagner en performances, mais n’est utilisable que si on a moins de 500K clés distinctes émises par `map()`. C’est notre cas ici (moins de 500K auteurs), donc on peut l’activer et j’ai noté effectivement une sacrée amélioration : on divise quasiment par deux le temps du map/reduce.
    C’est toujours un peu plus lent que la solution bête finale, mais le rapport gain/lignes de code est imbattable, et en plus on pourra continuer de bénéficier d’améliorations en cas de passage à un cluster de serveurs.

    À ne pas négliger :)

    Répondre
  2. Greg

    Sympa comme article. Je commence à bosser sur des tests de performances sur notre application, et je sens qu’on va avoir de la merde avec mongo.

    Par curiosité, pourquoi avoir choisit mongo dans ton cas ?

    Répondre
    1. naholyr Auteur de l’article

      Pour être complètement honnête c’est quasiment par flemme : il « va bien » avec Node dans une stack JS, tout le monde parle le même langage c’est plutôt sympa. Et Mongoose est très bien fait.

      Son côté scalable nous donne l’impression d’être paré à tout dans l’avenir, mais c’est idiot car ce genre de besoin n’apparaît qu’en cas de gros succès, et en cas de gros succès (et en théorie les fonds qui vont avec) on pourra toujours trouver de quoi faire tenir une BDD relationnelle le temps de trouver mieux.

      Cela dit à part ses performances médiocres je n’ai aucun problème à déplorer dessus 😉 Il « fait le job », il faut juste savoir ce qu’on est en droit d’attendre de lui : de simples find ça va, le reste si on est dans une archi mono-serveur on laisse tomber ^^

      Répondre

Laisser un commentaire