Trois ans en compagnie de MongoDB (Part 2 - Joie)

Dans mon précédent article je me suis plaint des comportements de MongoDB qui m'ont fait souffrir. Dans cette deuxième partie je vous présente les points forts de MongoDB vus sous l'angle de mon expérience personnelle.

Dans cet article je n'essaie pas de justifier le choix de MongoDB pour le projet sur lequel j'ai travaillé. Pour le faire il aurait fallu confronter les besoins du projet à l'offre de MongoDB ainsi qu'aux autres solutions disponibles en 2014.

Même si j'évoque quelques aspects opérationnels je ne suis pas le mieux placé pour en parler. Il faut voir cet article comme un REX d'un développeur qui a travaillé dans une équipe DevOps, qui est donc conscient des contraintes opérationnelles sans être un vrai expert Ops.

DSL (presque) intuitif

MongoDB n'a pas donné un nom particulier à son DSL comme le font tous les autres (CQL, N1QL, etc.). Dans cet article je vais l'appeler MongoL (hommage à mes ancêtres mongols). À première vue MongoL est un peu barbare, mais lorsqu'on fait sa connaissance de plus près on le trouve plutôt sympathique.

Il est tout à fait naturel (presque intuitif) d'écrire les requêtes sous forme d'objets JSON pour interroger les données au format BSON (JSON binaire). Avec MongoL il est très facile de référencer les attributs des sous-objets à plusieurs niveaux d'imbrication. Dans la plupart des cas les requêtes MongoL sont moins verbeuses que les requêtes SQL équivalentes.

La seule chose qui n'est pas très intuitive (mais tout à fait logique si on y réfléchit) c'est le fait que l'emplacement des opérateurs dans les objets filtre et modificateur est inversé. Dans le modificateur les opérateurs sont à la racine et dans le filtre ce sont les attributs des documents recherchés qui y sont :

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

Au début j'avais tendance à me tromper souvent en écrivant cette requête comme cela :

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

MongoL est très pratique pour construire les requêtes dynamiquement. Il est beaucoup plus simple de composer des objets (filtre, modificateur, projection et options) pour MongoL que de concaténer des chaînes de caractères dans le bon ordre pour SQL.

Aujourd'hui, je suis très à l'aise avec MongoL. Ne vous fiez pas aux apparences, faites sa connaissance, vous allez l'apprécier.

Mongo shell pratique

Mongo shell est un client en ligne de commande qui permet d'interroger et d'administrer MongoDB. Malgré ses quelques bizarreries dont j'ai parlé dans mon précédent article, mongo shell est un outil très pratique.

Mongo shell fonctionne comme un interpréteur de JavaScript interactif. Cette approche donne un certain nombre d'avantages. Cela permet, par exemple, de générer facilement un jeu de données de test ou de le lire dans un fichier à l'aide de JavaScript et de l'injecter dans la base. On peut également faire du post-traitement des données extraites de la base (enrichissement, mise en forme) toujours à l'aide de JavaScript. On peut exécuter le code JavaScript en direct étape par étape ou utiliser son IDE préféré pour écrire le code dans un fichier et l'exécuter avec load() dans mongo shell.

J'utilisais mongo shell principalement pour :

  • Tester rapidement mes requêtes et vérifier leurs plans d'exécution avant de les implémenter côté application,
  • Explorer les données,
  • Faire de l'analyse de données one-shot avec aggregate,
  • Administrer le replica set,
  • "Éteindre le feu" en production (la commande db.killOp() est votre amie !).

Mongo shell est probablement l'un des facteurs principaux de la popularité de MongoDB. Il permet une prise en main rapide du MongoL et par conséquent facilite l'adoption de MongoDB par les développeurs. Je pense qu'un bon nombre de projets ont intégré MongoDB juste parce que le ticket d'entrée n'était pas cher, grâce notamment à mongo shell.

Opérations bulk efficaces

MongoDB permet de grouper les opérations d'écriture (insert/update) dans des batchs (bulks dans les termes de MongoDB) en évitant de ce fait les allers-retours réseaux.

Il n'est pas nécessaire de respecter une taille maximale des batchs, les drivers les découpent eux-mêmes. Il est possible d'exécuter les bulks en mode ordonné et non ordonné. Le mode non ordonné permet d'accélérer le traitement en parallélisant les opérations côté serveur.

L'utilisation de bulk accélère de façon radicale les opérations d'insertion. En combinant le pattern producteur-consommateur et les opérations bulk j'ai pu créer des programmes d'ingestion en Java, performants et totalement personnalisables. Cette approche me permettait d'ingérer toute source de données quel que soit le format. Je pouvais également régler finement la vitesse d'ingestion afin de ne pas dégrader les performances de la base de production.

Bien évidemment, mes ETL faits maison n'étaient pas scalables, mais ce n'était pas nécessaire. Le bottleneck restait du côté de MongoDB (écriture sur disque et réplication). Avec une seule machine je pouvais envoyer plus de données que ce que MongoDB (sans sharding) était capable d'encaisser sans dégrader le service en production.

En ce qui concerne les opérations de mise à jour, il n'y a pas de miracles. Les bulks ne font que réduire le trafic réseau. L'opération update est par nature plus chère que l'opération insert. Premièrement, pour mettre à jour un document il faut d'abord le trouver (pensez aux index). Deuxièmement, vu la structure de données il peut être nécessaire de déplacer le document entier sur une autre zone mémoire de disque dur suite à sa modification, ce qui est une opération très coûteuse. Sur notre projet dans les cas où le delta était important, il était préférable (en terme de vitesse et de ressources consommées) de ré-ingérer le jeu de données complet dans une collection temporaire et la renommer en collection cible. Ce qui est d'ailleurs une bonne pratique régulièrement préconisée (le ratio delta/complet pris en considération) avec les bases de données NoSQL.

Aggregate puissant

Le framework aggregate mériterait un article à part entière, tellement il est riche en fonctionnalités. La documentation est bien faite, il y a beaucoup d'exemples. On trouve également énormément de solutions aux différentes problématiques avec aggregate sur Stack Overflow, la communauté est assez active.

C'est juste génial de pouvoir faire des requêtes d'analyse sous forme de pipeline au lieu des requêtes imbriquées. On peut empiler des stages dans une requête sans qu'elle ne devienne pour autant complètement illisible.

Pour donner un exemple, j'ai écrit une requête qui calcule les statistiques sur l'ensemble des utilisateurs équivalentes (avec moins de tranches d'âge) à celles publiées par l'INSEE sur la population de la France en 2018 :

> db.users.aggregate([
   {
       $addFields: {
           age_range: {
               $switch: {
                   branches: [
                       {case: {$lt: ["$age", 18]}, then: "Moins de 18 ans"},
                       {case: {$and: [{$gte: ["$age", 18]}, {$lte: ["$age", 35]}]}, then: "18-35 ans"},
                       {case: {$and: [{$gte: ["$age", 35]}, {$lte: ["$age", 60]}]}, then: "35-60 ans"},
                       {case: {$gte: ["$age", 60]}, then: "60 ans ou plus"}
                   ]
               }
           }
       }
   },
   {
       $group: {
           _id: "$age_range",
           m_count: {$sum: {$cond: {if: {$eq: ["$sex", "M"]}, then: 1, else: 0}}},
           f_count: {$sum: {$cond: {if: {$eq: ["$sex", "F"]}, then: 1, else: 0}}},
           total_count: {$sum: 1}
       }
   },
   {
       $group: {
           _id: null,
           m_count: {$sum: "$m_count"},
           f_count: {$sum: "$f_count"},
           total_count: {$sum: "$total_count"},
           age_range_stats: {$addToSet: "$$ROOT"}
       }
   },
   {
       $unwind: "$age_range_stats"
   },
   {
       $project: {
           age_range: "$age_range_stats._id",
           m_proportion: {$multiply: [{$divide: ["$age_range_stats.m_count", "$m_count"]}, 100]},
           f_proportion: {$multiply: [{$divide: ["$age_range_stats.f_count", "$f_count"]}, 100]},
           total_proportion: {$multiply: [{$divide: ["$age_range_stats.total_count", "$total_count"]}, 100]}
       }
   }
])

Cette requête est composée de cinq étapes (stages). Le premier stage prend en entrée les objets de la collection, chaque stage suivant prend en entrée les résultats en sortie du stage précédent. Les deux premiers stages donnent les résultats équivalents à ceux du premier tableaux de l'INSEE (effectifs), en ajoutant trois stages supplémentaires on obtient le deuxième tableau de l'INSEE (proportions) :

  1. Ajoute aux objets de la collection un nouvel attribut qui correspond à la tranche d'âge,
  2. Groupe les utilisateurs par tranche d'âge en comptant les hommes, les femmes et le nombre total des utilisateurs dans chaque groupe,
  3. Groupe toutes les tranches d'âge pour calculer les nombres totaux d'hommes, de femmes et de l'ensemble, les résultats par tranche d'âge sont mis dans une liste,
  4. Déroule la liste des résultats par tranche d'âge,
  5. Calcule la proportion de chaque tranche d'âge dans son groupe.

Le framework met également à votre disposition la méthode mapReduce permettant d'implémenter des requêtes d'analyse avec ce fameux paradigme. En revanche, pour des raisons de performance il faut privilégier l'utilisation de aggregate autant que possible. En terme de temps d'exécution aggregate est toujours beaucoup plus performant que mapReduce car il utilise les mécanismes natifs et optimisés là où mapReduce exécute directement les fonctions JavaScript (moteur SpiderMonkey depuis la version 3.2) définies par l'utilisateur. Dans la plupart des cas, ce qu'on peut faire avec mapReduce est également faisable avec aggregate de façon plus élégante et plus performante.

J'avoue que dans le version 2.6 il me manquait certains opérateurs. En revanche, la version 3.6 couvrait déjà totalement les besoins du projet. Pour se rendre compte de la progression de ce framework au fil des versions, il suffit de regarder ce tableau :

v2.6 v3.0 v3.2 v3.4 v3.6 v4.0
Nombre de stage opérateurs 10 10 13 22 23 23
Nombre de pipeline opérateurs 53 53 70 90 94 106

Le résultat d'une requête aggregate peut être écrit dans une collection à l'aide de l'opérateur $out (attention ! il écrase la collection cible si elle existe déjà). Cela permet de faire des transformations massives de données sans qu'elles aient à quitter la base, il faut juste avoir suffisamment d'espace disque. Nous utilisions cette approche pour migrer les données lorsqu'il était nécessaire de changer le format.

Voici un exemple concret qui pourrait vous servir un jour. Imaginez la situation où vous voulez créer un index TTL sur une collection volumineuse dont les documents ne contiennent pas de date de création (attribut de type Date). L'attribut n'existe pas ou la date est stockée dans un autre format (Long, String). Vous allez bien évidemment modifier l'application afin qu'elle ajoute l'attribut de type Date dans les documents qu'elle stocke. Problème : comment ajouter ce champ aux documents existants ? La mise à jour de chaque document consomme beaucoup de ressources (CPU, accès disque, trafic réseaux) et prend beaucoup de temps même en utilisant les bulks. De plus, chaque opération update locke la collection le temps de son exécution. Avec aggregate cette migration peut être faite de façon performante au sein de la base (les données sont lues, transformées et stockées sur le même serveur). Il est, en revanche, nécessaire d'approvisionner suffisamment d'espace disque. De plus, la date de création peut être extraite de l'objet _id :

> db.logs.aggregate(
    [
        {$addFields: {date: {$toDate: "$_id"}}}, 
        {$out: "logs"}
    ], 
    {allowDiskUse: true}
)

Attention ! Si la collection reçoit des écritures en continu (migration à chaud) une partie des données sera perdue, car aggregate stocke les documents transformés dans une collection temporaire durant l'exécution et écrase la collection cible (dans la requête ci-dessus c'est la même que la collection source) à la fin.

Afin de pouvoir faire la migration à chaud sans downtime et sans perte de données nous avons utilisé le workflow suivant :

  • Transformer les documents par aggregate et les stocker dans une nouvelle collection,
  • Créer les index sur la nouvelle collection (copier les index de l'ancienne collection),
  • Déployer la version de l'application qui utilise la nouvelle collection,
  • Rapatrier les documents manquants (créés durant la migration) dans la nouvelle collection (nous avons utilisé la date de création pour identifier le delta),
  • Supprimer l'ancienne collection.

Cette approche fonctionne pour les données de type time series qui ne reçoivent pas de mises à jour avec l'inconvénient d'incohérence temporelle (le temps de rapatrier le delta). Dans notre cas la contrainte d'avoir des données temporairement incohérentes était tout à fait acceptable.

Merci Docker !

La containerisation est arrivée comme un ouragan en modifiant profondément tout le paysage du monde informatique jusqu'à la façon de travailler des développeurs. Bien évidemment, cela n'est absolument pas un mérite de MongoDB.

Je me rappelle encore du monde avant Docker et des pirouettes nécessaires pour faire cohabiter plusieurs versions de MongoDB sur son poste de développement.

Et voilà aujourd'hui, en une seule commande Docker vous avez une instance MongoDB avec la version de votre choix, prête à l'emploi :

docker run --name mongo-4.0 -d -p 27017:27017 mongo:4.0-xenial

Vous voulez lancer le mongo shell qui va avec et commencer à faire des requêtes ? Aucun problème :

docker exec -it mongo-4.0 /bin/bash -c mongo

Vous avez juste besoin de vous connecter avec mongo shell à des bases distantes en différentes versions ? Le fait de ne plus devoir installer toutes ces versions du paquet mongodb-org-shell sur votre poste vous enlève une sacrée épine du pied :

docker run --rm -it --net host mongo:4.0-xenial /bin/bash -c 'mongo --host <host>:<port> -u <user> -p <password> --authenticationDatabase <db_name>'

Quel dommage que le tutoriel officiel de MongoDB ne donne pas ces astuces !

Conclusion

Ainsi s'achève la série de mes deux articles sur MongoDB. Il ne faut surtout pas les considérer comme des listes exhaustives des pour et des contre permettant de faire le choix en faveur ou en défaveur de MongoDB. Il y a beaucoup d'autres facteurs à prendre en compte (mise à l'échelle, performances, coût, etc.), qui ne font pas partie des sujets abordés dans les deux articles.

À mon avis, MongoDB peut être un bon choix dans le cas où avez besoin d'un base orientée document (le schéma de données et leur utilisation s'y prêtent), facile à prendre en main, tolérante aux pannes grâce à la réplication, dont l'offre gratuite est assez complète, et qui pourrait vous servir de base opérationnelle et analytique en même temps.

L'orientation de MongoDB vers JavaScript lui donne un avantage supplémentaire pour les projets Web dont les développeurs sont habitués à ce langage. Cela permet même d'avoir une stack complète en JavaScript (MongoDB + Node.js + React/Angular/Vue).

Je pense également que MongoDB n'est pas la meilleure solution dans le cas où vous n'avez pas du tout besoin d'une base NoSQL (ce n'est pas parce que c'est à la mode que cela vous va bien, PostgreSQL répondra peut-être mieux à vos besoins), ou au contraire vous avez vraiment besoin de la scalabilité et de la haute disponibilité, donc d'une base NoSQL distribuée et sans SPOF (Single Point of Failure). Je trouve que la mise en place d'un cluster MongoDB distribué (sharding) est trop compliquée et son mode de réplication maître-esclave la désavantage par rapport aux solutions masterless en terme de haute disponibilité.

MongoDB n'est pas bien adapté non plus au cas où vous avez besoin d'un débit d'IO important, les requêtes de lecture se retrouvent rapidement pénalisées pas les locks des requêtes d'écriture. Le sharding permet tout de même une bonne répartition des IO (au prix d'une complexité de maintenance importante).

Avec ce retour d'expérience j'espère vous faire éviter certains pièges et vous donner quelques astuces qui vous faciliteront la vie avec MongoDB.