Trois ans en compagnie de MongoDB (Part 1 - Souffrance)

MongoDB et moi, nous avons fait un bon bout de chemin ensemble. Le projet sur lequel j'ai travaillé les 3 dernières années était construit autour de MongoDB. Durant ces 3 années, j'ai vu MongoDB évoluer de la version 2.6 à 3.6. Le projet n'avait pas encore migré vers la version 4.0 au moment de mon départ.

Globalement j'étais heureux avec MongoDB, mais comme dans toutes les relations il y avait des hauts et des bas. Dans cet article je me confesse sur les problèmes de notre vie commune. Certains de ses comportements m'agaçaient au quotidien et d'autres avaient provoqué de graves crises dans nos relations.

Comme c'est toujours le cas, MongoDB n'était pas la seule responsable de ces troubles. Ses comportements qui m'ont fait pleurer amuseront peut-être les autres, c'est subjectif. Les crises les plus graves étaient plus souvent provoquées par ma mauvaise compréhension de son fonctionnement, je n'avais pas bien lu la documentation.

Si cet article vous paraît assez négatif, ce n'est pas parce que je n'aime pas du tout MongoDB. MongoDB a des qualités pour lesquelles elle mérite d'être appréciée. Mon objectif est de vous prévenir de ses particularités, afin que vous soyez heureux ensemble si un jour vous croisez son chemin.

Le projet avait intégré MongoDB avant mon arrivée dans l'équipe. Je ne me prononce pas sur la pertinence de ce choix. Je n'ai pas fait d'étude comparative des solutions disponibles en 2014. Je peux juste dire que MongoDB répondait plutôt bien aux besoins du projet.

Mongo shell qui surprend

La syntaxe des requêtes mongo paraît un peu barbare au début quand on vient du monde SQL mais on s'y habitue vite, on y prend goût même. Il est tout à fait naturel d'interroger les données au format BSON (JSON binaire) par des requêtes composées d'objets JSON. L'outil mongo shell qui permet d'interroger la base en ligne de commande est très pratique au quotidien. Néanmoins, il a quelques comportements bizarres qui m'ont fait souffrir. Voici celui qui m'a le plus marqué.

MongoDB supporte plusieurs types numériques : Double, Integer, Long et Decimal128 (à partir de la version 3.4). Lorsqu'on fait une requête en mongo shell, tout nombre est interprété comme Double :

> db.users.insert({age: 18})

Pourquoi Double ? Ce nombre ressemble pourtant beaucoup plus à un entier. Afin de pouvoir stocker un Integer ou un Long, il faut utiliser la syntaxe suivante :

> db.users.insert({age: NumberInt(25)})
> db.users.insert({age: NumberLong(42)})

Et les surprises ne s'arrêtent pas là. Voici comment mongo shell affiche les 3 documents que l'on vient de créer :

> db.users.find({}, {_id: 0})

{ "age" : 18 }
{ "age" : 25 }
{ "age" : NumberLong(42) }

Il est impossible de voir que 18 est un Double et que 25 est un Integer ! Ce n'est qu'à partir de la version 3.4 qu'il était devenu possible d'afficher les types des attributs, mais pour cela il faut faire une requête aggregate assez barbare, accrochez-vous :

> db.users.aggregate({
    $project: {_id: 0, age: "$age", age_type: {$type: "$age"}}
})

{ "age" : 18, "age_type" : "double" }
{ "age" : 25, "age_type" : "int" }
{ "age" : NumberLong(42), "age_type" : "long" }

Si on met à jour un attribut de type Integer en oubliant (croyez-moi, ça arrive) d'encapsuler la nouvelle valeur dans NumberInt()

> db.users.update({age: 25}, {$set: {age: 26}})

l'attribut de type Integer devient Double. On se retrouve donc avec des données corrompues alors que la requête paraît correcte.

Bon update et mauvais update

Lorsqu'on fait une requête update

> db.users.update({age: {$gte: 18}}, {$set: {status: "adult"}})

seul le premier document trouvé est mis à jour. Pour mettre à jour tous les documents correspondants à la condition, il faut soit ajouter l'option {multi: true} dans la requête :

> db.users.update({age: {$gte: 18}}, {$set: {status: "adult"}}, {multi: true})

soit utiliser la méthode updateMany :

> db.users.updateMany({age: {$gte: 18}}, {$set: {status: "adult"}})

Ce comportement me perturbait au début mais je m'y suis habitué avec le temps. En revanche, il y a un piège dans lequel je tombais régulièrement. Si on ne met aucun opérateur dans l'objet modificateur de la méthode updateMany (par exemple, on oublie l'opérateur $set)

> db.users.updateMany({age: {$gte: 18}}, {status: "adult"})

la requête est rejetée comme non valide. En revanche, si on fait la même erreur dans la méthode update

> db.users.update({age: {$gte: 18}}, {status: "adult"})

la requête s'exécute et remplace le premier document trouvé par l'objet modificateur, on perd donc tous les autres attributs.

> db.users.find({}, {_id: 0})

{ "status" : "adult" }
{ "age" : 25, "status" : "adult" }
{ "age" : NumberLong(42), "status" : "adult" }

Voilà encore une astuce pour corrompre vos données.

Pourquoi faire simple ?

Voici une requête SQL très simple :

SELECT * FROM monthly_budget WHERE spent > budget 

Même une personne qui ne connaît rien en informatique sera capable de comprendre ce qu'elle fait, tant la syntaxe est explicite. Comment exprimer la même requête pour MongoDB ? Le problème est que la condition compare deux champs de même document et MongoDB n'avait pas prévu ce cas d'usage "tellement rare".

Avant la version 3.6, le seul moyen faire cette requête de façon performante était de passer par aggregate, éloignez les plus sensibles de l'écran :

> db.monthly_budget.aggregate([{
    $redact: {
        $cond: [{ 
            $gt: ["$spent", "$budget"]}, 
            "$$KEEP",
            "$$PRUNE"
        ]
    }
}])

La version 3.6 a introduit l'opérateur $expr qui permet d'utiliser les opérateurs de aggregate dans la requête find. Notre requête devient donc un peu plus simple :

> db.monthly_budget.find({$expr: {$gt: ["$spent", "$budget"]}})

Il existe une autre façon de procéder mais qu'il ne faut surtout pas utiliser à cause des performances désastreuses :

> db.mounthly_budget.find({
    $where: function() {return this.spent > this.budget}
})

Malgré le fait que les trois requêtes ci-dessus procèdent par le COLLSCAN (aucune n'utilise les index), les deux premières sont de trois ordres de grandeur plus rapides que la requête avec $where. Le fait que le filtre de cette requête exécute la fonction JavaScript sur chaque document la rend extrêmement lente. Il y a une mise en garde à ce sujet dans la documentation MongoDB.

Index TTL sur _id ? Oui, mais non

Nous n'avions pas défini la stratégie de nettoyage des données périmées au début de projet en la reportant à plus tard. Le problème c'est que ce "plus tard" est arrivé au bout de quelques mois de production et nous nous sommes retrouvés coincés.

La collection grandissait, les requêtes ralentissaient, les index prenaient de plus en plus de place dans la mémoire RAM des machines. Nous ne pouvions pas juste supprimer les données périmées car la requête (ou les requêtes) de suppression aurait bloqué les écritures sur la collection en provoquant un downtime conséquent (jusqu'à plusieurs heures) de notre service synchrone. La meilleure solution (que nous avions au final réussi à mettre en place avec un workflow de migration complexe) était d'ajouter un index TTL et de le laisser gérer la suppression. Malheureusement, l'index TTL ne peut être ajouté que sur un champ de type Date et nos dates de création étaient stockées en timestamp Long (ne me demandez pas pourquoi). La mise à jour massive de la collection pour transformer le timestamp en Date n'était pas acceptable pour les mêmes raisons que la requête de suppression.

Ce qui aurait pu nous aider, c'est que MongoDB permette d'ajouter l'index TTL sur _id dont la valeur encapsule le timestamp de création de document. Malheureusement, cette fonctionnalité proposée en 2012 (SERVER-6701, SERVER-9305) n'est toujours pas implémentée.

Skip qui ne saute pas

Un jour nous devions mettre en place un traitement par batch sur une collection contenant plusieurs dizaines de millions de documents. Nous avions réalisé ce traitement à l'aide de requêtes paginées avec skip et limit. Cela fonctionnait parfaitement sur un petit jeu de données, mais lors des tests sur le volume de données cible, le traitement n'arrivait jamais au bout, il ralentissait et finissait par mettre à genoux la base systématiquement. Nous avons mis du temps avant de trouver cela dans la documentation de MongoDB :

"The cursor.skip() method requires the server to scan from the beginning of the input results set before beginning to return results. As the offset increases, cursor.skip() will become slower."

Cela signifie que skip(N) ne saute pas les N premiers documents mais il les parcourt sur le disque, et c'est le cas même si la requête trie les documents avec un index.

Pour contourner ce problème, dans notre cas, il a suffi de grouper les documents dans les batchs par des filtres sur un champ incrémental et indexé (cela peut être la date de création, par exemple) au lieu de les paginer par skip et limit.

Index qui bloque tout

Voici encore un extrait de la documentation MongoDB que nous avions trouvé de façon empirique :

"Foreground index builds block all other operations on the database."

Sachant que le mode foreground est celui par défaut et que la création d'un index sur une collection volumineuse peut prendre des heures, je vous laisse imaginer l'état de panique lors de la mise en production d'une version qui ajoute un nouvel index. Je n'ai trouvé aucune indication dans la documentation de MongoDB sur les avantages que présente le mode foreground et pourquoi il est celui par défaut. L'option background n'est donc pas une option, il est indispensable d'utiliser ce mode pour éviter le downtime.

Conclusion

Dans cet article j'ai omis volontairement tous les points forts de MongoDB que j'apprécie. Cela fera le sujet de mon deuxième article.

Je vous ai raconté tout ce qui m'a le plus marqué. Il y avait d'autres problèmes dont je ne me rappelle plus. Je me souviens juste que la question "Pourquoi ?" revenait assez souvent dans nos relations.

Ma recette pour être heureux avec MongoDB était de ne pas essayer de comprendre ses choix, mais juste d'apprendre à vivre avec et de l'apprécier pour ses qualités.