Développement Web

Indexes MongoDB : single, composite, multikey, partial, TTL et règle ESR

11 دقائق للقراءة

Un index MongoDB transforme une lecture O(n) en O(log n). C’est la différence entre une requête à 12 ms et la même requête à 4 secondes sur la même collection — sans qu’aucun autre paramètre n’ait bougé. La doctrine d’indexation est universelle : chaque champ filtré, trié, ou utilisé comme clé de jointure mérite un index. Ce tutoriel détaille les sept familles d’indexes MongoDB 8.0 LTS, la règle ESR (Equality, Sort, Range) qui ordonne les champs des indexes composites, et la lecture d’explain() pour décider si l’index sert vraiment.

Prérequis

  • MongoDB 8.0 LTS, accès mongosh ou Compass.
  • Une collection de test avec au moins 100 000 documents — sans volume, les différences d’index restent invisibles.
  • Avoir lu le tutoriel Modélisation embedded vs referenced : la stratégie d’index dépend du schéma.
  • Niveau intermédiaire : vous savez écrire un find avec opérateurs, un aggregate basique.
  • Temps estimé : 90 minutes pour traverser les 9 étapes.

Étape 1 — Pourquoi un index change tout

Sans index, MongoDB exécute un COLLSCAN : il lit chaque document de la collection, applique le filtre, retourne ceux qui matchent. À 1 million de documents, c’est une dizaine de secondes de lecture disque + déserialisation BSON + comparaison — pour finalement retourner trois résultats. Avec un index, le moteur saute directement aux bonnes entrées via une structure B-tree et lit uniquement les documents qui correspondent.

// Sans index — explain() montre COLLSCAN
db.commandes.find({ statut: "payee" }).explain("executionStats");
// "executionTimeMillis": 1842, "totalDocsExamined": 1000000

// Création de l'index sur le champ filtré
db.commandes.createIndex({ statut: 1 });

// Avec index — explain() montre IXSCAN
db.commandes.find({ statut: "payee" }).explain("executionStats");
// "executionTimeMillis": 12, "totalDocsExamined": 24500

Le rapport totalDocsExamined baisse au nombre exact de matchs, et executionTimeMillis tombe d’un facteur 100. Notez la valeur 1 dans l’objet d’index : c’est l’ordre ascendant. Pour les requêtes par égalité, le sens est sans importance ; pour les tris, il devient critique (voir étape 4).

Étape 2 — Index simple sur un seul champ

Le single field index couvre toutes les requêtes qui filtrent ou trient sur ce champ unique. C’est le point de départ. Créez-le pour chaque champ utilisé dans les requêtes principales de votre application.

// Index sur un champ utilisé en filtre fréquent
db.produits.createIndex({ categorie: 1 });

// Index avec contrainte d'unicité — rejette les doublons à l'insertion
db.utilisateurs.createIndex({ email: 1 }, { unique: true });

// Index sur un champ imbriqué (notation pointée)
db.produits.createIndex({ "attributs.marque": 1 });

// Lister les indexes existants
db.produits.getIndexes();

Le retour de getIndexes() liste toujours au minimum l’index _id_, créé automatiquement par MongoDB sur le champ _id. La création d’un index unique sur un champ existant échoue si la collection contient déjà des doublons — c’est le moment de découvrir les données sales qui auraient passé une validation insuffisante. La sortie est explicite : E11000 duplicate key error.

Étape 3 — Index composé et règle ESR

Le compound index couvre les requêtes qui filtrent ou trient sur plusieurs champs simultanément. L’ordre des champs dans l’index n’est pas anodin : il doit suivre la règle ESR publiée par MongoDB Inc. — Equality first, Sort second, Range last.

// Requête type : tous les utilisateurs payants de la ville X, triés par date
db.utilisateurs.find({
  ville: "Dakar",       // égalité
  abonnement: "actif",  // égalité
  inscrit_le: { $gte: ISODate("2026-01-01") }  // range
}).sort({ inscrit_le: -1 });

// Index respectant ESR : égalités → tri → range
db.utilisateurs.createIndex(
  { ville: 1, abonnement: 1, inscrit_le: -1 }
);

L’ordre ESR maximise le taux de filtrage par étape. MongoDB navigue d’abord vers le bloc ville = Dakar, puis dans ce bloc vers abonnement = actif, puis applique le tri sur inscrit_le qui est déjà ordonné dans l’index — pas de tri en mémoire. Inverser l’ordre (range avant égalité) force le moteur à scanner toute la branche et à trier après coup. Référence officielle : The ESR (Equality, Sort, Range) Rule.

Étape 4 — Index multikey sur tableaux

Quand le champ indexé est un tableau, MongoDB crée automatiquement un multikey index avec une entrée par élément du tableau. C’est ce qui permet d’interroger un tableau de tags ou un tableau de coordonnées comme s’il s’agissait d’un champ scalaire.

// Document type avec un tableau de tags
// { _id, titre, tags: ["mongodb", "tutoriel", "performance"] }

db.articles.createIndex({ tags: 1 });  // multikey automatique

// Requêtes couvertes
db.articles.find({ tags: "mongodb" });                    // un tag
db.articles.find({ tags: { $all: ["mongodb", "perf"] } }); // tous les tags
db.articles.find({ tags: { $in: ["mongodb", "redis"] } }); // au moins un

// Limitation : pas de composé sur deux champs tableau
// db.articles.createIndex({ tags: 1, auteurs: 1 }); // ERREUR si auteurs est aussi un array

La limitation tient en une phrase : un index composé ne peut indexer qu’un seul champ tableau parmi ses clés. Sinon, MongoDB devrait stocker le produit cartésien des deux tableaux, ce qui ferait exploser la taille de l’index. Le contournement classique est de stocker l’un des deux comme champ scalaire — par exemple, un seul tag « principal » extrait du tableau et indexable séparément.

Étape 5 — Index partiel pour ne pas tout indexer

Un partial index n’indexe que les documents qui satisfont un filtre. Trois avantages immédiats : l’index est plus petit, donc plus rapide à parcourir et moins coûteux en RAM ; les écritures sur documents hors-filtre ne déclenchent pas de maintenance d’index ; et la couverture des requêtes typiques reste identique parce que les documents non indexés sont précisément ceux qu’on n’interroge jamais.

// Sur 10 millions d'utilisateurs, seuls 50 000 sont premium
// On indexe leur date d'expiration uniquement
db.utilisateurs.createIndex(
  { expire_le: 1 },
  { partialFilterExpression: { plan: "premium" } }
);

// Requête qui utilise l'index — filtre doit contenir le critère partiel
db.utilisateurs.find({
  plan: "premium",
  expire_le: { $lte: new Date() }
});

// Requête qui n'utilise PAS l'index — pas de filtre "plan: premium"
db.utilisateurs.find({ expire_le: { $lte: new Date() } });

La règle d’or : la requête doit contenir la même clause que le partialFilterExpression pour que MongoDB consente à utiliser l’index partiel. Si vous oubliez, c’est un COLLSCAN silencieux — vérifier toujours avec explain(). Référence : Partial Indexes — Manual.

Étape 6 — Index TTL pour les expirations automatiques

Un TTL index (Time To Live) déclenche la suppression automatique des documents N secondes après une date stockée dans un champ. C’est l’outil idéal pour les sessions, les tokens d’authentification, les caches expirables, les logs avec rétention bornée — toutes les données qui n’ont pas vocation à rester indéfiniment.

// Sessions expirent 24 heures après création
db.sessions.createIndex(
  { cree_le: 1 },
  { expireAfterSeconds: 86400 }
);

// Logs gardés 30 jours
db.logs.createIndex(
  { date: 1 },
  { expireAfterSeconds: 2592000 }
);

// Insertion : aucune logique applicative à écrire
db.sessions.insertOne({ user_id: 42, cree_le: new Date() });
// → supprimé automatiquement 24h plus tard

MongoDB exécute un TTL monitor en arrière-plan toutes les 60 secondes. La suppression n’est donc pas exactement instantanée à l’expiration mais arrive dans la minute qui suit. Le champ indexé doit être de type Date ou un tableau de Date — un champ string ou nombre ne déclenche pas la purge même si l’index est créé. Référence : TTL Indexes — Manual.

Étape 7 — Index text pour la recherche plein texte

Le text index indexe les mots d’un ou plusieurs champs chaîne pour permettre une recherche full-text via l’opérateur $text. C’est l’option intégrée la plus simple — pour des besoins plus poussés (fautes de frappe, accents, synonymes), passer à Atlas Search ou à un Elasticsearch dédié.

// Index text sur le titre et la description
db.produits.createIndex(
  { nom: "text", description: "text" },
  { default_language: "french", weights: { nom: 5, description: 1 } }
);

// Recherche full-text
db.produits.find(
  { $text: { $search: "ordinateur portable" } },
  { score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } });

// Recherche exacte (entre guillemets)
db.produits.find({ $text: { $search: "\"clé USB\"" } });

// Exclusion d'un mot
db.produits.find({ $text: { $search: "ordinateur -reconditionné" } });

L’option default_language: "french" active la tokenisation et le stemming français — « ordinateur » et « ordinateurs » deviennent la même clé. Les poids permettent de favoriser un champ : un match dans le titre vaut 5 fois un match dans la description. Limitation à connaître : une collection ne peut avoir qu’un seul index text, même s’il peut couvrir plusieurs champs.

Étape 8 — explain() : la lecture qui décide

Toute optimisation d’index passe par explain(). Les trois modes queryPlanner (plan retenu), executionStats (mesures réelles), allPlansExecution (tous les plans testés) donnent une vue progressive de plus en plus détaillée.

db.commandes.find(
  { statut: "payee", cree_le: { $gte: ISODate("2026-01-01") } }
).sort({ cree_le: -1 }).explain("executionStats");

// Cinq champs à lire dans la sortie :
// 1. winningPlan.stage            -> IXSCAN ou COLLSCAN (objectif : IXSCAN)
// 2. winningPlan.inputStage.indexName -> nom de l'index utilisé
// 3. executionStats.executionTimeMillis -> durée totale
// 4. executionStats.totalKeysExamined  -> nb d'entrées d'index lues
// 5. executionStats.totalDocsExamined  -> nb de documents matérialisés

Le ratio à surveiller est nReturned / totalDocsExamined. Si vous retournez 100 résultats et examinez 100 000 documents, votre index ne discrimine pas assez — il faut ajouter un champ à l’index composé ou repenser la requête. À l’inverse, quand nReturned == totalDocsExamined, l’index est parfaitement sélectif. Le passage en SBE (Slot-Based Execution) introduit en MongoDB 5.x et étendu en 8.0 est visible dans winningPlan.slotBasedPlan — gain typique de 30 à 50 % sur les pipelines complexes.

Étape 9 — Maintenance et hygiène

Un index a un coût : chaque insert, update, delete doit mettre à jour la structure B-tree. Avoir 30 indexes sur une collection ralentit toutes les écritures. La discipline de maintenance vise à éliminer les indexes inutilisés et à valider que les indexes existants servent bien.

// Statistiques d'utilisation des indexes
db.commandes.aggregate([{ $indexStats: {} }]);

// Sortie : { name, key, accesses: { ops, since } }
// Un index avec accesses.ops = 0 depuis plusieurs semaines = candidat à la suppression

// Suppression d'un index par son nom
db.commandes.dropIndex("statut_1_cree_le_-1");

// Reconstruction (utile après une corruption ou un long oplog d'inserts)
db.commandes.reIndex();

La règle empirique tient en deux phrases : auditez $indexStats tous les trimestres et supprimez ce qui n’a pas servi. Et : chaque nouvel index doit être justifié par une requête concrète, pas par « au cas où ». Un index créé « au cas où » coûte de la RAM et ralentit les écritures pendant des années ; il sera vu pour ce qu’il est lors du prochain audit.

Erreurs fréquentes

Erreur Cause Solution
Requête lente malgré l’index Ordre des champs ne suit pas ESR Réordonner : égalités → tri → range
E11000 duplicate key Index unique créé sur champ avec doublons existants Nettoyer les doublons avant la création, ou utiliser { partialFilterExpression: { ... } }
Index TTL ne purge rien Champ indexé n’est pas un Date BSON Convertir via script de migration : { $set: { champ: { $toDate: "$champ" } } }
Index multikey impossible sur deux tableaux Limitation moteur Dénormaliser : garder un seul champ scalaire principal indexable
Index text non utilisé Plusieurs index text incompatibles Une collection a droit à un seul index text, le supprimer + recréer
RAM saturée à collMod Index trop gros vs working set Index partiel, ou sharding sur la clé qui isole les hot subsets

FAQ

Q : Combien d’indexes par collection est raisonnable ?
R : MongoDB autorise jusqu’à 64 indexes par collection. En pratique, 5 à 12 indexes bien choisis suffisent à couvrir 95 % des requêtes. Au-delà, le coût d’écriture devient sensible.

Q : Faut-il indexer _id ?
R : MongoDB crée automatiquement un index unique sur _id à la création de la collection. Il est non-supprimable et toujours présent.

Q : Index ascendant ou descendant ?
R : Sans importance pour les requêtes par égalité ou $in. Critique pour les tris : un index { date: -1 } sert un sort({ date: -1 }) sans tri en mémoire, alors qu’un index { date: 1 } y impose un tri inversé après lecture.

Q : Création d’index en production sans downtime ?
R : MongoDB 4.2+ crée les indexes en arrière-plan par défaut. Sur un cluster sharded, l’opération peut prendre des heures sur de grosses collections mais ne bloque pas les écritures.

Q : Quand passer à Atlas Search ?
R : Quand l’index text MongoDB devient limité : tolérance aux fautes, synonymes, autocompletion, scoring fin. Atlas Search est inclus sur le cluster M0 gratuit avec des limites de test (moins de 2 M documents indexés, moins de 10 Go, moins de 10 000 requêtes par semaine) ; les Dedicated Search Nodes nécessitent un cluster M10 ou supérieur.

Pour aller plus loin

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité