Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 715 minIntermédiaire

Index MongoDB et explain() : accélérer les requêtes

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • créer un index simple, composé ou unique avec createIndex ;
  • lire un plan d'exécution avec explain() ;
  • ordonner les champs d'un index composé avec la règle ESR ;
  • garantir une règle d'unicité avec un index unique.

3 exercices corrigés1 schéma

Un index MongoDB est une structure triée, rangée à côté d'une collection, qui permet de trouver les documents d'après la valeur d'un ou plusieurs champs sans lire toute la collection. Sans index, MongoDB fait un parcours complet (COLLSCAN) ; avec le bon index, il va directement aux bonnes entrées (IXSCAN). Ce chapitre explique comment créer les index utiles avec createIndex, comment vérifier avec explain() qu'une requête s'en sert, et comment un index unique garantit une règle de gestion. Le principe est le même qu'en SQL (index SQL) ; ce sont les commandes et quelques règles qui changent.

Pourquoi un index

Sur les 9 fiches du jeu de données, tout est instantané. Pour voir la différence, il faut du volume. Ce script crée une collection fichesVolume de 60 000 fiches : 500 visiteurs, 120 mois chacun, de janvier 2017 à décembre 2026.

js
db.fichesVolume.drop();
const docs = [];
for (let v = 0; v < 500; v++) {
  const visiteur = ObjectId(v.toString(16).padStart(24, '0'));   // 000…000, 000…001, …
  for (let m = 0; m < 120; m++) {
    const mois = `${2017 + Math.floor(m / 12)}-${String((m % 12) + 1).padStart(2, '0')}`;
    const etat = m === 119 ? 'CR' : m === 118 ? 'CL' : ['RB', 'RB', 'RB', 'RB', 'RB', 'RB', 'MP', 'VA'][(v + m) % 8];
    docs.push({ visiteur, mois, etat, montantValide: (v * 7 + m * 13) % 900 });
  }
}
db.fichesVolume.insertMany(docs);
db.fichesVolume.countDocuments()   // 60000

Cherchons la fiche d'octobre 2026 du visiteur n° 42, et demandons à MongoDB comment il l'a trouvée :

js
db.fichesVolume.find({ visiteur: ObjectId('00000000000000000000002a'), mois: '2026-10' })
  .explain('executionStats')

La réponse est un gros document. Les lignes qui comptent :

js
queryPlanner: { winningPlan: { stage: 'COLLSCAN', … } },
executionStats: {
  nReturned: 1,
  executionTimeMillis: 10,
  totalKeysExamined: 0,
  totalDocsExamined: 60000,
  …
}

MongoDB a lu les 60 000 documents pour en renvoyer un. Dix millisecondes, c'est encore rapide ; mais le temps croît avec la collection, et si cette requête est appelée à chaque affichage de la page « Ma fiche du mois », par des centaines d'utilisateurs, le serveur passe son temps à relire toute la base.

Créer un index : createIndex

js
db.fichesVolume.createIndex({ visiteur: 1, mois: 1 }, { unique: true })
// 'visiteur_1_mois_1'

createIndex prend les champs de l'index et le sens du tri (1 croissant, -1 décroissant), puis des options. MongoDB nomme l'index d'après ses champs (visiteur_1_mois_1). La même requête donne maintenant :

js
queryPlanner: { winningPlan: { stage: 'FETCH', inputStage: { stage: 'IXSCAN', indexName: 'visiteur_1_mois_1', … } } },
executionStats: { nReturned: 1, executionTimeMillis: 1, totalKeysExamined: 1, totalDocsExamined: 1 }

Une entrée d'index lue (IXSCAN), un document chargé (FETCH). La structure d'un index MongoDB est un arbre B, comme dans les SGBD relationnels : quelques pages lues suffisent pour trouver une valeur parmi des millions.

Sans index : COLLSCANfind({ visiteur: v42,mois: '2026-10' })… jusqu'au 60 000e documentnReturned: 1totalDocsExamined: 60000totalKeysExamined: 0Avec l'index visiteur_1_mois_1IXSCAN, puis FETCHracine… v41 | v42 | v43 …v42 2026-09v42 2026-10v42 …1 documentnReturned: 1totalDocsExamined: 1totalKeysExamined: 1
COLLSCAN : tout lire ; IXSCAN puis FETCH : aller droit au document

Les commandes de gestion :

js
db.fichesVolume.getIndexes()                   // liste les index, dont _id_ créé d'office
db.fichesVolume.dropIndex('visiteur_1_mois_1') // supprime un index par son nom
db.fichesVolume.stats().indexSizes             // la place occupée par chaque index

Lire un plan avec explain()

explain() accepte trois niveaux : 'queryPlanner' (le plan choisi, sans exécuter), 'executionStats' (le plan et les chiffres de l'exécution) et 'allPlansExecution' (tous les plans essayés). Retiens ces indicateurs :

IndicateurCe qu'il ditCe qu'on veut
winningPlan.stageLes étapes du plan, de la dernière à la première (inputStage)IXSCAN plutôt que COLLSCAN
nReturnedLes documents renvoyés
totalKeysExaminedLes entrées d'index luesProche de nReturned
totalDocsExaminedLes documents lusProche de nReturned ; 0 pour une requête couverte
executionTimeMillisLa durée, en millisecondesPetite, mais elle varie d'une exécution à l'autre : fie-toi plutôt aux compteurs

Les étapes que tu rencontreras :

ÉtapeSignification
COLLSCANParcours complet de la collection
IXSCANParcours d'une partie de l'index
FETCHChargement des documents trouvés par l'index
SORTTri en mémoire : aucun index ne donnait l'ordre demandé
PROJECTION_COVEREDProjection servie par l'index seul, sans lire les documents
EXPRESS_IXSCANAccès direct optimisé, sans planification, par exemple pour une recherche par _id

L'index composé et la règle du préfixe

Un index sur plusieurs champs est trié par le premier champ, puis par le deuxième à l'intérieur de chaque valeur du premier, comme un annuaire trié par nom puis par prénom. Il sert donc pour toute requête qui porte sur un préfixe de ses champs, en partant de la gauche :

Requête sur fichesVolumeIndex { visiteur: 1, mois: 1 } utilisé ?Documents lus
{ visiteur: v42, mois: '2026-10' }Oui1
{ visiteur: v42 }Oui (préfixe visiteur)120
{ visiteur: v42, mois: { $gte: '2026-01' } } trié par mois: -1Oui, et il donne aussi l'ordre12
{ mois: '2026-10' }Non : mois seul n'est pas un préfixe60 000 (COLLSCAN)

Un index peut être parcouru dans les deux sens : { visiteur: 1, mois: 1 } sert aussi un tri { mois: -1 } pour un visiteur donné. Quand un tri ne correspond à aucun index, le plan contient une étape SORT : MongoDB trie en mémoire, et passe par le disque au-delà de 100 Mio, ce qui est encore plus lent. Trier les fiches d'un visiteur par montantValide donne ainsi SORT ← FETCH ← IXSCAN.

L'ordre des champs : la règle ESR

Pour un index composé qui sert un filtre et un tri, place les champs dans cet ordre, qu'on appelle la règle ESR :

  1. Egalité : les champs testés par égalité (etat: 'CL') ;
  2. Sort : les champs du tri (mois: -1) ;
  3. Range : les champs testés par intervalle (montantValide: { $gte: 500 }).

La liste du comptable, « les fiches clôturées, des plus récentes aux plus anciennes », demande un index { etat: 1, mois: -1 } :

js
db.fichesVolume.createIndex({ etat: 1, mois: -1 })
db.fichesVolume.find({ etat: 'CL' }).sort({ mois: -1 }).explain('executionStats')
PlanClés luesDocuments lus
Sans cet indexSORT ← COLLSCAN060 000
Avec { etat: 1, mois: -1 }FETCH ← IXSCAN500500

L'égalité d'abord : l'index isole d'un coup les 500 fiches CL, déjà rangées par mois décroissant. Le tri devient gratuit.

Requête couverte

Si la projection ne demande que des champs présents dans l'index (et exclut _id), MongoDB répond avec l'index seul, sans lire un seul document :

js
db.fichesVolume.find({ etat: 'CL' }, { _id: 0, etat: 1, mois: 1 })
// plan : PROJECTION_COVERED ← IXSCAN, totalDocsExamined: 0

C'est la requête la plus rapide possible. Pense-y pour les listes qui n'affichent que deux ou trois champs.

Les index sur les tableaux

Un index sur un champ qui contient un tableau crée une entrée par élément : c'est un index multiclé (multikey). Il accélère les filtres comme { 'lignesForfait.code': 'NUI' } ou $elemMatch :

js
db.fiches.createIndex({ 'lignesForfait.code': 1 })

MongoDB le crée automatiquement en multiclé dès qu'il rencontre un tableau. Une limite : un index composé ne peut pas contenir deux champs tableaux du même document.

L'index unique : une règle de gestion

Un index unique refuse deux documents qui ont la même valeur, ou la même combinaison de valeurs. C'est l'équivalent d'une contrainte UNIQUE en SQL, et c'est le seul moyen sûr de garantir l'unicité : vérifier « est-ce que la fiche existe déjà ? » dans le code, puis l'insérer, laisse passer deux requêtes simultanées.

Sur le jeu de données d'AppliFrais, la règle « une seule fiche par visiteur et par mois » devient :

js
db.fiches.createIndex({ visiteur: 1, mois: 1 }, { unique: true })
db.fiches.createIndex({ etat: 1, mois: -1 })     // la liste du comptable

db.fiches.insertOne({ visiteur: ObjectId('6a0000000000000000000001'), mois: '2026-10', etat: 'CR' })
text
MongoServerError: E11000 duplicate key error collection: appli_frais.fiches
index: visiteur_1_mois_1 dup key: { visiteur: ObjectId('6a0000000000000000000001'), mois: "2026-10" }

Les autres types d'index

TypeCréationUsage
TTL (durée de vie)createIndex({ creeLe: 1 }, { expireAfterSeconds: 3600 })MongoDB supprime les documents une heure après creeLe : jetons de réinitialisation de mot de passe, sessions, codes à usage unique. La suppression passe toutes les 60 secondes environ
PartielcreateIndex({ email: 1 }, { unique: true, partialFilterExpression: { actif: true } })N'indexe que les documents qui satisfont le filtre : ici, l'adresse n'est unique que parmi les comptes actifs
TextecreateIndex({ 'fraisHorsForfait.libelle': 'text' }, { default_language: 'french' })Recherche de mots avec $text: { $search: 'taxis' }, avec la racine des mots (« taxis » trouve « Taxi Lyon Part-Dieu »)
Géospatial (2dsphere)createIndex({ position: '2dsphere' })« Les cabinets médicaux à moins de 10 km » avec $near
HachécreateIndex({ visiteur: 'hashed' })Répartir une collection entre plusieurs serveurs (sharding)

Le coût d'un index

Un index n'est pas gratuit :

  • il ralentit chaque écriture : chaque insertion, modification ou suppression met à jour tous les index concernés ;
  • il occupe de la place, sur disque et surtout en mémoire vive, où MongoDB essaie de garder les index utilisés : sur fichesVolume, l'index visiteur_1_mois_1 pèse environ 1 Mio pour 60 000 documents ;
  • un index inutilisé n'apporte que ces coûts.

Crée donc les index à partir des requêtes réelles : celles des écrans fréquents, celles des règles d'unicité, celles que explain() montre lentes. Avec Mongoose, tu les déclares dans le schéma (ficheSchema.index({ visiteur: 1, mois: 1 }, { unique: true }), chapitre 10).

Exercices

Exercice 1 sur 3 Lire des plans d'exécution Réussi À revoir

Pour chaque résumé de explain('executionStats') obtenu sur fichesVolume, dis si la requête est efficace et, sinon, quel index tu crées.

  1. find({ etat: 'VA', mois: '2026-09' }) : COLLSCAN, nReturned: 63, totalDocsExamined: 60000.
  2. find({ visiteur: v7 }).sort({ mois: -1 }) : FETCH ← IXSCAN(visiteur_1_mois_1), nReturned: 120, totalKeysExamined: 120, totalDocsExamined: 120.
  3. find({ visiteur: v7 }).sort({ montantValide: -1 }).limit(5) : SORT ← FETCH ← IXSCAN(visiteur_1_mois_1), nReturned: 5, totalDocsExamined: 120.
  4. find({ mois: '2026-10' }) : COLLSCAN, nReturned: 500, totalDocsExamined: 60000, alors que l'index visiteur_1_mois_1 existe.
Voir la réponseMasquer la réponse de l'exercice 1
  1. Inefficace : 60 000 documents lus pour 63. L'index { etat: 1, mois: -1 } (ou { etat: 1, mois: 1 }) sert ce filtre : deux égalités, l'ordre importe peu ici, mais etat en premier sert aussi la liste du comptable.
  2. Efficace : autant de clés et de documents lus que renvoyés, et le tri est fourni par l'index (pas d'étape SORT).
  3. Acceptable : l'index réduit à 120 documents, puis MongoDB trie en mémoire. Pour 120 documents, c'est négligeable. Si la requête était très fréquente sur des milliers de fiches par visiteur, l'index { visiteur: 1, montantValide: -1 } supprimerait l'étape SORT.
  4. Inefficace : mois n'est pas un préfixe de l'index { visiteur: 1, mois: 1 }. Si la requête « toutes les fiches d'un mois » est fréquente, crée { mois: 1 }. Si elle sert surtout au comptable avec un état, { etat: 1, mois: -1 } suffit, à condition de filtrer aussi sur l'état.

Tu avais trouvé ?

Exercice 2 sur 3 Choisir les index d'AppliFrais Réussi À revoir

Voici les requêtes de l'API d'AppliFrais, avec leur fréquence. Propose la liste minimale d'index, avec leurs options.

  1. Connexion : visiteurs.findOne({ email }), à chaque connexion.
  2. « Ma fiche du mois » : fiches.findOne({ visiteur, mois }), très fréquente.
  3. « Mes fiches » : fiches.find({ visiteur }).sort({ mois: -1 }), fréquente.
  4. Liste du comptable : fiches.find({ etat: 'CL' }).sort({ mois: -1 }), quelques fois par jour.
  5. Jetons de réinitialisation de mot de passe, dans une collection jetons, valables 30 minutes, retrouvés par leur valeur jeton.
Voir la réponseMasquer la réponse de l'exercice 2
  • visiteurs : createIndex({ email: 1 }, { unique: true }). Il accélère la connexion et garantit qu'une adresse ne sert qu'à un compte.
  • fiches : createIndex({ visiteur: 1, mois: 1 }, { unique: true }). Il sert les requêtes 2 et 3 (préfixe visiteur, tri par mois dans l'index, parcouru à l'envers) et garantit la règle « une fiche par mois ».
  • fiches : createIndex({ etat: 1, mois: -1 }) pour la requête 4 (règle ESR : égalité, puis tri).
  • jetons : createIndex({ jeton: 1 }, { unique: true }) pour retrouver un jeton, et createIndex({ creeLe: 1 }, { expireAfterSeconds: 1800 }) pour que MongoDB les supprime après 30 minutes. L'API doit quand même vérifier la date du jeton : la suppression TTL peut avoir jusqu'à une minute de retard.

Trois index sur fiches et visiteurs, plus l'_id, qui existe toujours. Un index séparé { visiteur: 1 } serait inutile : { visiteur: 1, mois: 1 } le remplace.

Tu avais trouvé ?

Exercice 3 sur 3 Mesurer Réussi À revoir

Crée fichesVolume avec le script du chapitre, puis mesure avec explain('executionStats') la requête find({ etat: 'MP', mois: { $gte: '2026-01' } }) sans index, puis avec l'index { etat: 1, mois: -1 }. Combien de documents renvoie-t-elle ? Combien de documents MongoDB lit-il dans chaque cas ?

Voir la réponseMasquer la réponse de l'exercice 3
js
db.fichesVolume.dropIndexes()        // repartir sans index (sauf _id)
db.fichesVolume.find({ etat: 'MP', mois: { $gte: '2026-01' } }).explain('executionStats')
// COLLSCAN : totalDocsExamined 60000
db.fichesVolume.createIndex({ etat: 1, mois: -1 })
db.fichesVolume.find({ etat: 'MP', mois: { $gte: '2026-01' } }).explain('executionStats')
// FETCH ← IXSCAN(etat_1_mois_-1) : totalKeysExamined = totalDocsExamined = nReturned

La requête renvoie 626 fiches. Pour chaque visiteur, les dix mois de janvier à octobre 2026 (indices 108 à 117) parcourent le cycle de huit états : MP y apparaît une fois, ou deux fois pour les visiteurs dont le numéro, divisé par 8, a pour reste 1 ou 2. Ils sont 126 (63 + 63) ; les 374 autres en ont une. 126 × 2 + 374 = 626. Novembre et décembre sont à CL et CR. Sans index, MongoDB lit 60 000 documents ; avec, il lit exactement les 626 qu'il renvoie.

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Agrégation MongoDB : pipeline, $match, $group, $lookupRevoir le sommaire du cours