Qu’est-ce que MongoDB ?
MongoDB est une base de données orientée documents. Au lieu de lignes dans des tables, elle stocke des documents — des objets de type JSON encodés en BSON — à l’intérieur de collections. Un document peut contenir des objets imbriqués et des tableaux, permettant ainsi à un seul enregistrement de décrire un agrégat complet : une commande avec ses articles, ou un utilisateur avec ses adresses.
MongoDB est apparu en 2009, à une époque où le modèle relationnel semblait trop rigide pour des applications qui croissaient rapidement et dont la structure évoluait sans cesse. Sa promesse était simple : stocker l’objet tel qu’il existe déjà dans votre code et passer à l’échelle horizontalement sans migrations douloureuses. Cette promesse en a fait la base de données NoSQL par défaut pour toute une génération d’équipes Node.js et JavaScript.
Le compromis est réel. MongoDB sacrifie les jointures par défaut et délègue la validation du schéma à votre application. Lorsque vos données ont une structure de document, c’est un avantage considérable. En revanche, lorsque vos données sont profondément relationnelles, une base de données relationnelle est souvent l’outil le plus adapté.
Documents, collections et BSON
Un document est un ensemble ordonné de paires champ-valeur. Une collection est un groupe de documents qui partagent généralement la même structure, bien que rien ne les y oblige. Les collections sont créées au moment où vous effectuez votre première insertion.
db.users.insertOne({
email: "[email protected]",
name: "Ada",
address: { city: "London", country: "GB" },
tags: ["admin", "beta"],
});
Les valeurs possèdent des types BSON, et pas seulement des types JSON. BSON est un encodage binaire qui ajoute ObjectId, Date, Decimal128, BinData et bien plus encore, vous permettant ainsi de stocker de véritables dates et des décimales précises au lieu de simples chaînes de caractères. L’ordre des champs est préservé et les noms de champs sont sensibles à la casse.
Une bonne habitude consiste à donner à chaque document d’une collection une structure cohérente, même si le serveur autorise des variations. Cela vous permet d’obtenir des requêtes et des index prévisibles, tout en conservant la flexibilité nécessaire si vous devez ajouter un champ à un seul document dans un premier temps.
_id et ObjectId
Chaque document nécessite un _id unique. Si vous n’en fournissez pas, le driver génère un ObjectId — une valeur de 12 octets composée d’un horodatage, d’une valeur aléatoire par processus et d’un compteur incrémentiel. Comme l’horodatage arrive en premier, les valeurs ObjectId sont triées approximativement par date de création, ce qui est pratique pour la pagination.
const { ObjectId } = require("mongodb");
const id = new ObjectId("64f1c2a9e13b4a7d8c9e0011");
id.getTimestamp(); // 2023-09-01T...
Le champ _id est indexé automatiquement et est toujours unique. Vous pouvez fournir votre propre valeur — une clé naturelle, une chaîne UUID ou une clé composite — lorsque cela permet d’optimiser les recherches ou lorsque vous avez besoin d’insertions idempotentes.
Le CRUD en pratique
Les opérations de création, lecture, mise à jour et suppression (CRUD) correspondent à un petit ensemble de méthodes qui se comportent de la même manière dans mongosh et dans le driver Node.js.
// Create
db.users.insertOne({ email: "[email protected]", plan: "pro" });
db.users.insertMany([
{ email: "[email protected]", plan: "free" },
{ email: "[email protected]", plan: "team" },
]);
// Read
db.users.findOne({ email: "[email protected]" });
db.users.find({ plan: "pro" }).toArray();
// Update
db.users.updateOne(
{ email: "[email protected]" },
{ $set: { plan: "team" } },
);
// Delete
db.users.deleteOne({ email: "[email protected]" });
updateOne et updateMany prennent un filtre et un document de mise à jour. Le document de mise à jour utilise des opérateurs : $set remplace ou ajoute des champs, $unset les supprime, $inc ajoute une valeur à un nombre, $push ajoute des éléments à un tableau, $addToSet ajoute uniquement si le champ est absent, et $pull supprime les éléments correspondants d’un tableau.
db.users.updateOne(
{ email: "[email protected]" },
{
$set: { lastSeenAt: new Date() },
$inc: { logins: 1 },
$addToSet: { tags: "beta" },
},
);
Il existe également replaceOne, qui remplace l’intégralité du document à l’exception de _id. Privilégiez les opérateurs de champ pour éviter que des écritures concurrentes n’écrasent les modifications les unes des autres.
Effectuer des requêtes avec des opérateurs
Un filtre est lui-même un document. L’égalité est le comportement par défaut, et les opérateurs commencent par $.
db.orders.find({ status: "paid" }); // equality
db.orders.find({ total: { $gt: 5000, $lte: 20000 } }); // range
db.orders.find({ status: { $in: ["paid", "shipped"] } });
db.users.find({ email: { $regex: /@example\.com$/i } });
db.orders.find({ "items.sku": "KB-01" }); // nested field
db.orders.find({
items: { $elemMatch: { qty: { $gte: 2 }, price: { $lt: 3000 } } },
});
$elemMatch est important lorsque plusieurs conditions doivent s’appliquer au même élément d’un tableau. Sans cela, { "items.qty": { $gte: 2 }, "items.price": { $lt: 3000 } } peut correspondre à des éléments différents.
Combinez les filtres avec $and, $or et $not :
db.orders.find({
$or: [
{ status: "paid" },
{ status: "pending", total: { $lt: 1000 } },
],
});
Le langage de requête est composable car il s’agit de données et non d’une chaîne de caractères. C’est pourquoi la création de filtres dans le code de l’application semble naturelle : vous assemblez un objet et vous le passez à find.
Projection, tri et pagination
Une projection permet de sélectionner les champs à retourner. Incluez des champs avec 1 ou excluez-les avec 0, mais ne mélangez jamais les deux styles, sauf pour _id.
db.users.find(
{ plan: "pro" },
{ email: 1, name: 1, _id: 0 },
);
Triez et paginez vos données avec .sort(), .skip() et .limit(). Pour les offsets importants, skip devient plus lent car le serveur doit toujours parcourir les documents ignorés. Privilégiez la pagination par clés (keyset pagination), où vous filtrez à partir de la dernière valeur reçue.
db.orders
.find({ customerId, createdAt: { $lt: lastSeen } })
.sort({ createdAt: -1 })
.limit(20);
L’ajout d’index sur les champs de tri rend le filtrage et l’ordonnancement plus efficaces, ce qui nous amène à la section suivante.
Index : la différence entre la rapidité et l’inutilisabilité
Sans index, MongoDB lit chaque document de la collection — c’est ce qu’on appelle un collection scan. Avec un index, il accède directement à la plage de données correspondante. Presque tous les problèmes de performance dans MongoDB sont dus à un index manquant ou mal ordonné.
db.users.createIndex({ email: 1 }, { unique: true });
db.orders.createIndex({ customerId: 1, createdAt: -1 });
db.sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
db.products.createIndex({ name: "text", description: "text" });
- Les index Single-field accélèrent les requêtes d’égalité et de plage sur un seul champ.
- Les index Compound suivent la règle ESR : d’abord les champs d’égalité, puis le tri, et enfin la plage.
{ customerId: 1, createdAt: -1 }sert à la fois pour le filtre et pour le tri. - Les index Unique rejettent les doublons et sécurisent les upserts.
- Les index TTL suppriment les documents après une certaine date, ce qui est parfait pour les sessions et les jetons à usage unique.
- Les index Text permettent la recherche
$textavec racinisation (stemming) et scoring.
Utilisez explain() pour voir ce que le planificateur a fait. Recherchez IXSCAN plutôt que COLLSCAN, et vérifiez que le nombre de documents examinés est proche du nombre de documents retournés.
db.orders.find({ customerId, status: "paid" }).explain("executionStats");
Le pipeline d’agrégation
Lorsqu’une simple requête ne suffit pas, le framework d’agrégation exécute un pipeline d’étapes. Chaque étape transforme un flux de documents et le transmet à la suivante. Les étapes les plus courantes sont $match, $group, $sort, $project, $lookup, $unwind et $limit.
Voici un exemple concret : les cinq produits générant le plus de revenus pour les commandes payées.
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $unwind: "$items" },
{
$group: {
_id: "$items.sku",
units: { $sum: "$items.qty" },
revenue: {
$sum: { $multiply: ["$items.qty", "$items.price"] },
},
},
},
{ $sort: { revenue: -1 } },
{ $limit: 5 },
{
$project: {
_id: 0,
sku: "$_id",
units: 1,
revenue: 1,
},
},
]);
Lisez-le de haut en bas. $match filtre les données dès le début afin que les étapes suivantes aient moins de travail. $unwind transforme chaque élément de tableau en son propre document. $group cumule les unités et les revenus par SKU. $sort et $limit conservent les cinq premiers. $project renomme _id en sku et supprime le reste.
$lookup effectue une jointure externe gauche (left outer join) avec une autre collection, ce qui permet de combiner des données référencées :
db.orders.aggregate([
{ $match: { status: "paid" } },
{
$lookup: {
from: "users",
localField: "customerId",
foreignField: "_id",
as: "customer",
},
},
{ $unwind: "$customer" },
{ $project: { total: 1, "customer.email": 1 } },
]);
Placez toujours $match en premier pour qu’il puisse utiliser un index, et placez $limit le plus tôt possible sans compromettre la justesse des résultats. L’agrégation est puissante, mais un pipeline qui scanne chaque document à chaque étape est la recette idéale pour une requête lente.
Embedding vs référencement
C’est la décision de modélisation centrale. Demandez-vous comment les données sont lues.
Embed (imbriquez) lorsque les données enfants sont lues avec le parent, écrites avec lui, et que leur taille est limitée. Les lignes de commande d’une commande sont le cas classique : une seule lecture renvoie tout, et il n’y a pas de jointure.
{
_id: ObjectId("..."),
customerId: ObjectId("..."),
items: [
{ sku: "KB-01", qty: 1, price: 8900 },
{ sku: "MS-02", qty: 2, price: 2900 },
],
total: 14700,
}
Reference (référencez) lorsque les données sont volumineuses, partagées ou mises à jour selon leur propre cycle. Les utilisateurs, les produits et les catégories sont référencés par _id, et $lookup ou une seconde requête permet de les joindre.
{
_id: ObjectId("..."),
customerId: ObjectId("64f1c2a9e13b4a7d8c9e0011"),
items: [{ productId: ObjectId("..."), qty: 1, price: 8900 }],
}
La règle d’or est la suivante : les données qui sont accédées ensemble doivent être stockées ensemble. Dupliquez un peu de données lorsque cela permet de transformer une lecture courante en une seule recherche, mais n’oubliez pas que les copies doivent être mises à jour partout où elles se trouvent. Et n’imbriquez jamais un tableau qui croît sans limite — les documents sont plafonnés à 16 Mo.
Transactions
Les écritures sur un seul document sont atomiques. Si vous devez modifier plusieurs documents ou collections comme une seule unité, utilisez une transaction multi-documents. Les sessions sont le mécanisme permettant d’y parvenir.
const session = client.startSession();
try {
await session.withTransaction(async () => {
await accounts.updateOne(
{ _id: from },
{ $inc: { balance: -100 } },
{ session },
);
await accounts.updateOne(
{ _id: to },
{ $inc: { balance: 100 } },
{ session },
);
});
} finally {
await session.endSession();
}
Les transactions nécessitent un replica set ou un cluster sharded, et elles entraînent un surcoût : des verrous (locks), une fenêtre d’exécution plus longue et des tentatives de réexécution en cas de conflit. L’usage optimal des transactions est rare. Si vous vous retrouvez à encapsuler chaque écriture dans une transaction, c’est probablement que votre modèle de données aurait besoin de plus d’imbrication (embedding).
Replica sets et sharding
Un replica set est un groupe de nœuds contenant les mêmes données. L’un d’entre eux est le primaire et gère toutes les écritures ; les autres répliquent l’oplog du primaire et peuvent répondre aux lectures. Si le primaire tombe en panne, le set en élit un nouveau automatiquement. C’est le déploiement de production par défaut, et cela permet également l’utilisation des change streams et des transactions.
Le sharding partitionne une collection sur plusieurs replica sets via une shard key. Chaque shard possède une plage de valeurs de clés. Une bonne shard key présente une cardinalité élevée, distribue les écritures uniformément et apparaît dans la plupart des requêtes afin que le routeur puisse cibler un seul shard. Une mauvaise clé crée un hotspot ou force chaque requête à s’éparpiller (fan out) sur tous les shards.
Choisissez la shard key avant d’avoir des données, car la modifier ultérieurement implique de migrer la collection. Pour la plupart des applications, commencez par un replica set et ne passez au sharding que lorsqu’un seul primaire ne suffit plus à suivre la charge.
Mongoose dans Node
Le driver officiel mongodb suffit pour effectuer des requêtes, mais la plupart des équipes Node utilisent Mongoose pour ses schémas, sa validation et ses modèles. Un schéma décrit la structure d’un document, et un modèle est la classe permettant d’effectuer des requêtes, construite à partir de ce schéma.
import mongoose from "mongoose";
const orderSchema = new mongoose.Schema({
customerId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
},
status: {
type: String,
enum: ["pending", "paid", "shipped"],
default: "pending",
},
items: [
{
sku: String,
qty: { type: Number, min: 1 },
price: { type: Number, min: 0 },
},
],
total: Number,
createdAt: { type: Date, default: Date.now, index: true },
});
export const Order = mongoose.model("Order", orderSchema);
const paid = await Order.find({ status: "paid" })
.sort({ createdAt: -1 })
.limit(20)
.lean();
Mongoose valide les données lors de la sauvegarde, convertit les types et propose le populate() pour les références. Deux bonnes pratiques permettent de maintenir les performances : déclarez les index nécessaires aux requêtes, et utilisez .lean() lorsque vous ne faites que lire des données, ce qui évite la création de documents Mongoose complets.
Écritures en masse et upserts
Exécuter une commande par document gaspille des allers-retours réseau. Lorsque vous devez appliquer de nombreuses modifications, bulkWrite envoie un lot en un seul appel et rapporte précisément ce qui s’est produit.
await db.users.bulkWrite([
{
updateOne: {
filter: { email: "[email protected]" },
update: { $set: { plan: "team" } },
upsert: true,
},
},
{
insertOne: {
document: { email: "[email protected]", plan: "pro" },
},
},
{
deleteOne: { filter: { email: "[email protected]" } },
},
]);
L’option upsert est l’autre outil indispensable. Elle met à jour un document correspondant ou en insère un si aucun n’existe, ce qui facilite les imports idempotents et la gestion des compteurs.
db.stats.updateOne(
{ day: "2026-09-16" },
{ $inc: { visits: 1 } },
{ upsert: true },
);
Les écritures en masse ne sont pas des transactions par défaut. Passez { ordered: false } pour continuer après une erreur et collecter chaque échec, ou conservez le mode ordonné par défaut lorsque chaque écriture dépend de la précédente. Dans tous les cas, vérifiez l’objet de résultat : il compte les documents correspondants, modifiés, insérés et supprimés, et un upsert qui ne trouve aucune correspondance n’est pas considéré comme une erreur.
Change streams
Un replica set enregistre chaque écriture dans un oplog. Les change streams exposent ce journal sous la forme d’un flux reprenable, permettant ainsi à une application de réagir aux insertions, mises à jour et suppressions sans avoir recours au polling.
const changeStream = db.orders.watch([
{ $match: { "fullDocument.status": "paid" } },
]);
for await (const change of changeStream) {
console.log(change.operationType, change.fullDocument._id);
}
Le flux est reprenable : stockez le _id du dernier événement traité comme un jeton de reprise (resume token), puis transmettez-le via resumeAfter après un redémarrage pour ne manquer aucun événement. Comme les change streams s’appuient sur l’oplog, ils nécessitent un replica set et ne signalent que les modifications qui n’ont pas encore été purgées du journal.
Deux règles garantissent leur fiabilité. Rendez le consommateur idempotent, car un événement peut être délivré à nouveau après une reprise. Et assurez-vous que le traitement soit rapide ou déléguez le travail à une file d’attente, car un consommateur lent laisse l’oplog progresser au-delà de sa position.
Bonnes pratiques
- Modélisez pour la lecture : imbriquez les données lues ensemble, référencez celles qui sont partagées ou non bornées.
- Indexez les champs utilisés pour le filtrage et le tri, et suivez la règle ESR pour les index composés.
- Projetez toujours uniquement les champs dont le client a besoin ; ne retournez jamais de secrets.
- Placez
$matchen premier dans un pipeline d’agrégation pour permettre l’utilisation d’un index. - Limitez la croissance des tableaux et surveillez la limite de 16 Mo par document.
- Validez les écritures dans l’application ou via un validateur JSON Schema de collection.
- Utilisez
explain()avant de supposer qu’une requête est optimisée, et testez avec des volumes de données identiques à la production. - Privilégiez la pagination par clés (keyset pagination) aux offsets
skipimportants.
Erreurs courantes
- Considérer MongoDB comme totalement sans schéma et laisser les documents évoluer vers des structures incompatibles.
- Exécuter des requêtes sans index et s’étonner de l’augmentation de la latence à mesure que la collection s’agrandit.
- Créer des index composés dans le mauvais ordre, empêchant ainsi le tri de les utiliser.
- Imbriquer un tableau qui croît indéfiniment jusqu’à ce que les documents atteignent la limite de taille.
- Utiliser
$lookupà chaque requête au lieu d’imbriquer les données qui sont lues ensemble. - Recourir aux transactions multi-documents alors qu’une mise à jour d’un seul document suffirait.
- Retourner des documents entiers et ainsi divulguer des hashs de mots de passe ou des jetons.
- Choisir une clé de shard à faible cardinalité, créant ainsi un point chaud (write hotspot) lors des écritures.
Et après ?
MongoDB enseigne la modélisation de documents, la conception d’index et l’agrégation — des compétences transposables à n’importe quel système de stockage de données. Si vos données sont fortement relationnelles et nécessitent des jointures et des contraintes au niveau de la base de données, consultez le guide PostgreSQL. Pour des lectures en microsecondes, des expirations basées sur le TTL et des compteurs, associez MongoDB à Redis. Si vous souhaitez le connecter à un service Node, revoyez les bases de Node.js, puis comparez le modèle de requête avec le SQL.