Données et bases de données

MongoDB et le modèle document : conception de schéma, agrégation et sharding

Intermédiaire9 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

MongoDB n'est pas une base relationnelle débarrassée de ses contraintes : il récompense un autre réflexe de modélisation, où l'unité de stockage est ce que vous lisez d'un seul tenant. Ce guide couvre le modèle document et BSON, les patterns de conception de schéma, le pipeline d'agrégation, le choix des index, la durabilité dans un replica set, le choix de la clé de sharding et la place réelle des transactions multi-documents.

Ce que vous allez apprendre

  • Expliquer le modèle document, les types BSON et les limites qui orientent une conception
  • Choisir entre imbrication et référence, et appliquer les patterns extended reference et bucket
  • Écrire des pipelines d'agrégation qui filtrent tôt et mettent en forme les données côté serveur
  • Concevoir des index selon la règle égalité-tri-intervalle et les vérifier avec explain
  • Raisonner sur les élections d'un replica set, les read et write concerns, le sharding et les transactions

MongoDB est la base de données le plus souvent adoptée pour la mauvaise raison et abandonnée pour la bonne. La mauvaise raison, c'est « on ne veut pas définir de schéma » ; la bonne raison de partir, c'est de découvrir deux ans plus tard que les données étaient relationnelles depuis le début. Utilisé en connaissance de cause, pourtant, le modèle document résout un vrai problème : quand ce que vous stockez et ce que vous lisez sont le même objet autonome, le conserver dans un seul document supprime les jointures, les allers-retours et le code de mapping.

Ce guide porte sur MongoDB 8 et suppose que vous maîtrisez déjà la modélisation relationnelle. Les réflexes qui se transposent sont ceux qui concernent les patterns d'accès ; ceux qui concernent les formes normales, pour l'essentiel, ne se transposent pas, et savoir distinguer les uns des autres, c'est tout le savoir-faire.

Documents, BSON et leurs conséquences

Un document est un ensemble de paires champ-valeur stocké en BSON, un encodage binaire de JSON doté de types supplémentaires : ObjectId (un identifiant de 12 octets qui embarque un horodatage), Date, Decimal128 pour les montants exacts, Binary, les entiers 32 et 64 bits, et les expressions régulières. Les documents vivent dans des collections, les collections dans une base de données. Chaque document possède un _id unique, indexé automatiquement.

{
  "_id": { "$oid": "665f1a2b7c9e4f0012ab34cd" },
  "sku": "BK-1042",
  "title": "Field Guide to Estuaries",
  "price": { "$numberDecimal": "24.90" },
  "tags": ["nature", "reference"],
  "dimensions": { "widthMm": 130, "heightMm": 198 },
  "stock": [
    { "warehouse": "LYON", "quantity": 12 },
    { "warehouse": "PORTO", "quantity": 3 }
  ]
}

Trois propriétés de cette structure déterminent tout le reste. Les champs peuvent s'imbriquer à volonté, si bien qu'un agrégat entier tient dans un seul enregistrement. Les tableaux sont des citoyens de première classe et s'indexent, si bien qu'une relation un-à-plusieurs n'exige pas de seconde collection. Enfin, un document est limité à 16 Mo, ce qui est une contrainte de conception déguisée en limite technique : tout tableau susceptible de croître sans borne est une erreur de modélisation, pas un problème de capacité.

Une écriture sur un seul document est atomique, quelle que soit la profondeur d'imbrication. C'est cette garantie qui fait fonctionner la modélisation document : si les données qui doivent changer ensemble vivent dans un même document, vous obtenez un comportement transactionnel gratuitement.

Les documents ne sont « sans schéma » en aucun sens utile — votre code dépend de la présence et du type des champs. Déclarez cette dépendance :

db.createCollection("product", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["sku", "title", "price"],
    properties: {
      sku:   { bsonType: "string", pattern: "^[A-Z]{2}-[0-9]{4}$" },
      price: { bsonType: "decimal", description: "list price, excl. tax" }
    }
  }},
  validationLevel: "strict"
});

Lire et écrire

mongosh est le shell, et les API des drivers le reflètent de très près.

db.product.insertOne({ sku: "BK-1042", title: "Field Guide to Estuaries", price: NumberDecimal("24.90") });

db.product.find({ tags: "nature", "price": { $lt: NumberDecimal("30") } })
          .sort({ title: 1 }).limit(20);

// Cibler un même élément du tableau, pas deux conditions indépendantes
db.product.find({ stock: { $elemMatch: { warehouse: "LYON", quantity: { $gt: 0 } } } });

db.product.updateOne(
  { sku: "BK-1042", "stock.warehouse": "LYON" },
  { $inc: { "stock.$.quantity": -1 }, $set: { updatedAt: new Date() } }
);

Deux détails piègent les nouveaux venus. find({ "stock.warehouse": "LYON", "stock.quantity": { $gt: 0 } }) trouve un document dont un élément a cet entrepôt et un élément a du stock — pas forcément le même ; c'est $elemMatch que vous vouliez. Et les opérateurs de mise à jour ($set, $inc, $push, $pull, $addToSet) modifient les champs sur place, alors que passer un document brut le remplace entièrement, en supprimant sans bruit tout ce que vous avez omis. Quand une mise à jour imbriquée se comporte mal, coller le document dans un formateur JSON pour voir sa forme réelle va plus vite que de plisser les yeux devant la sortie du shell.

Modéliser : imbriquer ou référencer

Le réflexe relationnel consiste à normaliser jusqu'à ce que chaque fait n'existe qu'une fois. Le réflexe document consiste à stocker ensemble ce qui se lit ensemble, et à accepter la duplication quand le doublon est immuable ou peu coûteux à rafraîchir. Aucun des deux n'a raison dans l'absolu ; la question est toujours ce que l'application demande.

Imbriquez quand l'enfant n'a pas d'existence propre, qu'il est en nombre borné et qu'il se lit avec son parent — les lignes d'une commande dans la commande, une adresse dans un client, le stock par entrepôt dans un produit. Une lecture, une écriture atomique, aucune jointure.

Référencez quand l'enfant est volumineux, non borné, partagé entre plusieurs parents ou consulté isolément — un auteur référencé par des milliers de livres, ou une piste d'audit qui grossit indéfiniment.

Quatre patterns couvrent l'essentiel du terrain intermédiaire :

  • Extended reference. Stockez la référence plus les deux ou trois champs que vous affichez systématiquement : { "authorId": ..., "authorName": "..." }. La recherche disparaît du chemin de lecture. C'est de la dénormalisation : décidez dès le départ comment se propage un changement de nom — souvent via un job en arrière-plan, parfois pas du tout parce que la valeur est historique.
  • Bucket. Pour les séries temporelles à fort volume, regroupez de nombreuses mesures dans un document par appareil et par heure au lieu d'un document par mesure. Moins de documents, un index plus petit, une surcharge considérablement réduite. Les collections time series natives de MongoDB implémentent ce pattern en interne.
  • Computed. Stockez un total cumulé ou un compteur à côté des données brutes et maintenez-le avec $inc à l'écriture, plutôt que d'agréger à chaque lecture.
  • Subset. Gardez dans le document parent les dix éléments de tableau les plus récents ou les plus pertinents, et l'historique complet dans une collection séparée, pour que le document le plus sollicité reste petit.

L'anti-pattern à nommer explicitement est le tableau non borné : des commentaires imbriqués dans un article, des événements imbriqués dans un utilisateur. Il finit par dépasser 16 Mo, et bien avant cela, chaque mise à jour réécrit un document qui ne cesse de grossir. Le raisonnement relationnel sur les clés et les dépendances présenté dans conception de bases de données et normalisation reste utile ici : vous choisissez quelles dépendances regrouper physiquement, vous ne faites pas semblant qu'elles n'existent pas.

Le pipeline d'agrégation

Une agrégation est une suite d'étapes (stages), chacune transformant le flux de documents qu'elle reçoit. C'est là que MongoDB fait le travail que SQL confie à GROUP BY, aux jointures et aux fonctions de fenêtrage.

db.order.aggregate([
  { $match: { placedAt: { $gte: ISODate("2026-01-01") }, status: "SHIPPED" } },
  { $unwind: "$lines" },
  { $group: {
      _id: "$lines.sku",
      units:   { $sum: "$lines.quantity" },
      revenue: { $sum: { $multiply: ["$lines.quantity", "$lines.unitPrice"] } }
  }},
  { $sort: { revenue: -1 } },
  { $limit: 10 },
  { $lookup: { from: "product", localField: "_id", foreignField: "sku", as: "product" } },
  { $project: { _id: 0, sku: "$_id", units: 1, revenue: 1, title: { $first: "$product.title" } } }
]);

La règle la plus importante de toutes : placez $match en premier, et $sort juste après si un index peut servir les deux — ce sont les seules étapes capables d'utiliser un index, et seulement tant qu'elles restent en tête du pipeline. Tout ce qui suit un $unwind ou un $group travaille sur des documents intermédiaires matérialisés.

D'autres étapes méritent d'être connues : $facet exécute plusieurs sous-pipelines sur la même entrée, ce qui permet de calculer des résultats et leurs décomptes agrégés en un seul aller-retour ; $bucket et $bucketAuto construisent des histogrammes ; $merge et $out écrivent les résultats dans une collection, ce qui transforme un pipeline en vue matérialisée planifiée. $lookup réalise une jointure externe gauche, et cela fonctionne, mais un pipeline qui en a besoin de trois vous dit que le modèle est relationnel.

Index

Les règles d'indexation font écho aux règles relationnelles du guide sur les plans d'exécution, avec quelques particularités propres à MongoDB :

  • Les index composés suivent la règle ESR : d'abord les champs d'Égalité (Equality), puis ceux de tri (Sort), puis ceux d'intervalle (Range). Placer le tri avant l'intervalle, c'est ce qui évite un tri en mémoire, et MongoDB interrompt les tris qui dépassent 100 Mo à moins que vous n'autorisiez l'usage du disque.
  • Les index multikey sont ce que vous obtenez automatiquement en indexant un champ tableau ; chaque élément devient une entrée. Vous ne pouvez pas combiner deux champs tableaux dans un même index composé.
  • Les index partiels (partialFilterExpression) n'indexent que les documents qui correspondent — idéal pour un petit sous-ensemble « en attente » d'une grande collection. Sparse en est la version plus ancienne et plus grossière.
  • Les index TTL suppriment les documents un certain temps après la valeur d'un champ date : c'est ainsi qu'on fait expirer des sessions ou des logs sans job de purge.
  • Les index wildcard couvrent des noms de champs inconnus dans des attributs définis par l'utilisateur. Pratiques, et bien plus volumineux qu'un index ciblé.
  • Les index text et vector prennent en charge la recherche par mots-clés et par similarité ; pour une pertinence plein texte sérieuse, un moteur dédié reste préférable, comme l'explique le guide Solr.

Vérifiez avec explain, en le lisant comme vous liriez un plan relationnel :

db.order.find({ status: "SHIPPED", placedAt: { $gte: ISODate("2026-01-01") } })
        .sort({ placedAt: -1 })
        .explain("executionStats");

Comparez totalKeysExamined, totalDocsExamined et nReturned. Si le nombre de documents examinés dépasse de loin celui des documents renvoyés, l'index filtre mal ; si l'étape gagnante est un COLLSCAN, l'index n'est pas utilisé du tout.

Replica sets, sharding et cohérence

Un replica set est un groupe de processus mongod qui détiennent les mêmes données : un primaire qui accepte les écritures, et des secondaires qui répliquent son oplog. Si le primaire devient injoignable, les membres restants organisent une élection et une majorité désigne un nouveau primaire, généralement en quelques secondes. Faites toujours tourner un nombre impair de membres votants répartis sur des domaines de panne distincts, et n'utilisez un arbitre qu'en dernier recours.

Durabilité et fraîcheur se choisissent opération par opération, et non par un réglage global du serveur :

  • Write concern { w: "majority" } n'acquitte l'écriture qu'une fois qu'une majorité la détient, ce qui la rend durable en cas de basculement. w: 1 acquitte au niveau du primaire, et l'écriture peut être annulée (rollback) si ce primaire tombe. Ajoutez j: true pour exiger l'écriture du journal sur disque.
  • Read concern "local" renvoie ce que le nœud possède ; "majority" ne renvoie que des données qui ne peuvent plus être annulées ; "snapshot" donne une vue cohérente à un instant donné au sein d'une transaction.
  • Read preference décide de quel membre sert la lecture. secondaryPreferred répartit la charge au prix d'une possible obsolescence des données — un bon compromis pour du reporting, un mauvais pour les parcours où l'on doit relire sa propre écriture.

Le sharding partitionne une collection entre plusieurs shards selon une clé de sharding (shard key). Chaque shard est lui-même un replica set ; les routeurs mongos aiguillent les requêtes, et les serveurs de configuration détiennent les métadonnées. Une requête qui inclut la clé de sharding est routée vers un seul shard ; une requête qui ne l'inclut pas est diffusée à tous, et c'est toute la différence entre une milliseconde et une diffusion à l'échelle du cluster.

Choisissez la clé de sharding selon trois critères : forte cardinalité, répartition uniforme des écritures et présence dans vos filtres de requête courants. Un horodatage échoue au deuxième critère — toutes les insertions atterrissent sur le shard qui possède l'intervalle le plus élevé. Hacher la clé répartit les écritures uniformément mais détruit les requêtes par intervalle. En pratique, une clé composée comme { tenantId: 1, orderId: "hashed" } satisfait souvent les trois. Les versions récentes permettent d'affiner une clé de sharding, voire de resharder une collection en production, mais c'est une opération lourde ; visez à peu près juste dès le départ. Et ne shardez pas prématurément : un replica set bien indexé sur un matériel correct encaisse bien plus que ce que la plupart des équipes imaginent, et le sharding ajoute un vrai poids opérationnel, un thème que reprend le guide sur l'architecture big data.

Les transactions couvrent plusieurs documents et plusieurs collections avec une isolation snapshot, sur les replica sets comme sur les clusters shardés. C'est le bon outil pour un virement entre deux comptes stockés dans deux documents. Elles sont le mauvais outil pour ce qu'un meilleur modèle aurait réduit à une mise à jour sur un seul document, et elles ont un coût réel en latence et en contention de verrous.

Points clés à retenir

  • Un document est atomique à l'écriture : stockez ensemble ce qui change ensemble ; la limite de 16 Mo est une contrainte de modélisation qui interdit les tableaux non bornés.
  • Imbriquez les données bornées, dépendantes et lues ensemble ; référencez les données partagées ou non bornées ; recourez aux patterns extended reference, bucket, computed et subset pour les cas intermédiaires.
  • Un pipeline d'agrégation doit commencer par $match (et un $sort bien placé) pour utiliser un index ; $facet, $merge et $bucket couvrent les besoins de reporting sans allers-retours supplémentaires.
  • Construisez les index composés dans l'ordre égalité-tri-intervalle et vérifiez avec explain("executionStats") que le nombre de documents examinés reste proche du nombre de documents renvoyés.
  • Write concern, read concern et read preference se décident opération par opération ; c'est w: "majority" qui permet à une écriture de survivre à un basculement.
  • Ne shardez que si vous y êtes obligé, sur une clé à forte cardinalité, aux écritures bien réparties et présente dans vos filtres courants — et validez avec $jsonSchema le schéma dont vous dépendez déjà.

Sources et lectures complémentaires

Questions fréquentes

MongoDB n'a-t-il vraiment aucun schéma ?
Par défaut, aucun schéma n'est imposé à l'écriture, ce qui n'est pas la même chose que ne pas en avoir. Votre code applicatif, vos index et chacune de vos requêtes encodent un schéma, que vous le déclariez ou non. Les collections acceptent des règles de validation JSON Schema ; les appliquer aux champs dont vous dépendez transforme une corruption silencieuse en erreur immédiate, ce qui est généralement ce que vous voulez en production.
Peut-on utiliser les transactions MongoDB dans du code courant ?
Oui, depuis la version 4.0 sur les replica sets et la 4.2 sur les clusters shardés ; elles offrent une isolation snapshot et un commit en tout-ou-rien. Elles coûtent aussi plus cher qu'une écriture sur un seul document, qui est déjà atomique. Réservez-les aux opérations qui touchent réellement plusieurs documents plutôt que d'en faire l'enveloppe par défaut, et concevez votre modèle pour que la plupart des mises à jour ne touchent qu'un document.
Comment choisir une clé de sharding ?
Il vous faut une forte cardinalité, une répartition uniforme des écritures et, idéalement, un alignement sur le filtre de vos requêtes les plus fréquentes pour que les lectures ciblent un seul shard. Une clé monotone croissante comme un horodatage envoie chaque insertion sur le même shard ; la hacher corrige la répartition mais casse les requêtes par intervalle. Une clé composée associant un tenant ou une catégorie à une composante hachée est souvent le meilleur compromis.
Quand choisir MongoDB plutôt que PostgreSQL ?
Quand vos données arrivent réellement sous forme de documents autonomes, de forme variable, que vous lisez en entier, et quand la montée en charge horizontale des écritures compte davantage que les jointures entre de nombreuses entités. Si vos accès sont relationnels — liens plusieurs-à-plusieurs, jointures ad hoc, gros besoins de reporting —, un moteur relationnel avec une colonne jsonb vous servira généralement mieux.