MongoDBMongooseNestJSPerformanceBackend

Performance MongoDB avec Mongoose : indexes, agrégations et lean()

Accélérez vos requêtes MongoDB dans NestJS : indexes bien placés, pipelines d'agrégation, lean() et explain() pour diagnostiquer les lenteurs.

AMAlexis Mouchon10 min de lecture

Votre API NestJS répondait en 50 ms en développement, et maintenant qu'il y a 200 000 documents en production, certaines requêtes prennent 2 secondes. Le code n'a pas changé. Ce qui a changé, c'est que MongoDB parcourt désormais toute la collection à chaque requête, faute d'index adapté.

C'est l'un des problèmes les plus courants — et les plus faciles à corriger — que je rencontre sur les projets MongoDB. Dans cet article, on va voir comment diagnostiquer une requête lente avec explain(), poser les bons indexes via Mongoose, remplacer les boucles de requêtes par des pipelines d'agrégation, et gagner 30 à 50 % de temps de réponse supplémentaire avec lean().

Diagnostiquer avant d'optimiser : explain()

Avant de poser des indexes partout, il faut savoir ce que MongoDB fait réellement de vos requêtes. C'est le rôle d'explain() :

// Dans un service NestJS, ponctuellement pour diagnostiquer
const explanation = await this.orderModel
  .find({ customerId, status: 'pending' })
  .sort({ createdAt: -1 })
  .explain('executionStats');

console.log(JSON.stringify(explanation, null, 2));

Dans le résultat, trois champs vous intéressent :

{
  "executionStats": {
    "executionTimeMillis": 1840,
    "totalDocsExamined": 198432,
    "nReturned": 12
  },
  "queryPlanner": {
    "winningPlan": { "stage": "COLLSCAN" }
  }
}

Lisez-le comme ceci : MongoDB a examiné 198 432 documents pour en retourner 12, en 1,8 seconde. Le stage: "COLLSCAN" confirme le scan complet de la collection. L'objectif d'un index est de transformer ce COLLSCAN en IXSCAN, et de rapprocher totalDocsExamined de nReturned. Quand les deux sont égaux, votre requête est optimale.

Poser les bons indexes avec Mongoose

Index simple et index composé

Dans un schéma Mongoose (ici avec les décorateurs @nestjs/mongoose), un index simple se déclare directement sur la propriété :

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';

@Schema({ timestamps: true })
export class Order {
  @Prop({ type: Types.ObjectId, ref: 'Customer', required: true, index: true })
  customerId: Types.ObjectId;

  @Prop({ required: true, enum: ['pending', 'paid', 'shipped', 'cancelled'] })
  status: string;

  @Prop({ required: true })
  totalAmount: number;

  createdAt: Date;
}

export type OrderDocument = HydratedDocument<Order>;
export const OrderSchema = SchemaFactory.createForClass(Order);

Mais notre requête filtre sur customerId et status, puis trie sur createdAt. Un index simple sur customerId aide, mais MongoDB doit encore filtrer et trier en mémoire. La bonne réponse est un index composé, déclaré au niveau du schéma :

// L'ordre des champs suit la règle ESR :
// Equality (customerId, status) → Sort (createdAt) → Range
OrderSchema.index({ customerId: 1, status: 1, createdAt: -1 });

La règle ESR (Equality, Sort, Range) est le réflexe à retenir pour ordonner les champs d'un index composé : d'abord les champs comparés par égalité stricte, ensuite le champ de tri, enfin les champs de plage ($gte, $lt...). Avec cet index, notre requête de tout à l'heure passe de 1,8 s à quelques millisecondes, et totalDocsExamined tombe à 12.

Les pièges classiques

Deux erreurs reviennent souvent. La première : multiplier les indexes "au cas où". Chaque index ralentit les écritures (il doit être maintenu à chaque insert/update) et consomme de la RAM. Posez des indexes pour les requêtes que votre application exécute réellement, pas pour celles qu'elle pourrait exécuter un jour.

La seconde : croire qu'un index composé { a: 1, b: 1 } couvre une requête sur b seul. Non — un index composé ne sert que les requêtes qui utilisent un préfixe de ses champs (a, ou a + b, mais pas b seul). Si vous requêtez aussi sur status seul, il faut un index dédié.

Index unique et index partiel

Deux variantes utiles au quotidien :

// Unicité de l'email — la contrainte vit dans la BDD, pas dans le code
@Prop({ required: true, unique: true })
email: string;

// Index partiel : n'indexe que les commandes actives,
// idéal si 95 % de la collection est archivée
OrderSchema.index(
  { customerId: 1, createdAt: -1 },
  { partialFilterExpression: { status: { $in: ['pending', 'paid'] } } },
);

L'index partiel est sous-utilisé alors qu'il est redoutable : il ne stocke que les documents qui matchent le filtre, donc il est plus petit, tient mieux en RAM, et coûte moins cher à maintenir.

Remplacer les boucles de requêtes par des agrégations

Deuxième source classique de lenteur : faire en JavaScript ce que MongoDB sait faire nativement. L'exemple typique, un dashboard qui calcule le chiffre d'affaires par client :

// ❌ N requêtes + calcul en mémoire — ne passe pas à l'échelle
const customers = await this.customerModel.find();
const stats = [];
for (const customer of customers) {
  const orders = await this.orderModel.find({
    customerId: customer._id,
    status: 'paid',
  });
  stats.push({
    customer: customer.name,
    revenue: orders.reduce((sum, o) => sum + o.totalAmount, 0),
  });
}

Avec un pipeline d'agrégation, tout se fait côté base, en une seule requête :

// ✅ Une seule requête, le calcul est fait par MongoDB
const stats = await this.orderModel.aggregate([
  { $match: { status: 'paid' } },
  {
    $group: {
      _id: '$customerId',
      revenue: { $sum: '$totalAmount' },
      orderCount: { $sum: 1 },
    },
  },
  {
    $lookup: {
      from: 'customers',
      localField: '_id',
      foreignField: '_id',
      as: 'customer',
    },
  },
  { $unwind: '$customer' },
  {
    $project: {
      _id: 0,
      customer: '$customer.name',
      revenue: 1,
      orderCount: 1,
    },
  },
  { $sort: { revenue: -1 } },
  { $limit: 50 },
]);

Quelques règles pour des pipelines efficaces : placez $match en premier (il peut alors utiliser les indexes — c'est le seul stage qui le fait pleinement), réduisez le volume le plus tôt possible, et gardez $lookup pour la fin, quand il ne reste que les documents utiles. Un $lookup sur une collection non filtrée est l'équivalent MongoDB d'un produit cartésien : à éviter absolument.

Si vous exposez ces données via GraphQL, ce pattern se combine très bien avec ce que je décrivais dans mon article sur GraphQL Code First avec NestJS et MongoDB — et pour les relations résolues champ par champ, c'est DataLoader qui prend le relais.

lean() : le gain gratuit que tout le monde oublie

Par défaut, chaque find() Mongoose retourne des documents hydratés : des instances complètes avec getters, setters, méthodes d'instance, change tracking... C'est utile quand vous comptez modifier puis sauvegarder le document. Mais pour de la lecture pure — ce qui représente la majorité des requêtes d'une API — c'est du travail inutile.

// Document hydraté : ~5x plus lourd en mémoire, plus lent à instancier
const orders = await this.orderModel.find({ customerId });

// Objet JavaScript brut : parfait pour de la lecture seule
const orders = await this.orderModel
  .find({ customerId })
  .select('status totalAmount createdAt')
  .lean<Pick<Order, 'status' | 'totalAmount' | 'createdAt'>[]>();

Sur des listes de quelques centaines de documents, lean() réduit le temps de réponse de 30 à 50 % et la consommation mémoire d'un facteur 5 environ. Combiné à select() pour ne rapatrier que les champs nécessaires, c'est le réflexe à adopter pour tous vos endpoints de lecture : listes, détails, exports, résolveurs GraphQL.

Seule contrepartie : plus de méthodes d'instance, plus de virtuals (sauf option dédiée), plus de save(). Si vous lisez pour modifier, restez sur un document hydraté. Sinon, lean() par défaut.

Mesurer en continu, pas seulement en cas de crise

Les indexes posés aujourd'hui peuvent devenir insuffisants quand les usages évoluent. Deux habitudes pour ne pas le découvrir via un client mécontent :

// main.ts ou un module dédié — log les requêtes lentes en production
import mongoose from 'mongoose';

mongoose.set('debug', (collection, method, query) => {
  // À brancher sur votre logger plutôt que console
  logger.debug(`${collection}.${method}`, JSON.stringify(query));
});

Côté MongoDB, le profiler intégré capture toutes les requêtes au-delà d'un seuil :

// Dans mongosh — capture toute requête > 100 ms
db.setProfilingLevel(1, { slowms: 100 });
db.system.profile.find().sort({ ts: -1 }).limit(5);

Sur MongoDB Atlas, l'onglet Performance Advisor fait ce travail pour vous et suggère même les indexes manquants. Et si vous avez déjà un outil de monitoring applicatif comme Sentry, ses traces de performance montrent précisément quelle requête plombe quel endpoint.

Conclusion

L'optimisation MongoDB n'a rien de mystérieux, c'est une méthode : diagnostiquer avec explain() plutôt que deviner, poser des indexes composés en suivant la règle ESR pour les requêtes réelles de votre application, déléguer les calculs à la base via les pipelines d'agrégation au lieu de boucler en JavaScript, et activer lean() sur toutes les lectures pures. Quatre réflexes qui, ensemble, transforment une API qui rame à 200 000 documents en une API qui tient le million sans broncher.

Le meilleur moment pour les adopter, c'est avant que la production ne vous y oblige.


Vous avez un projet web avec des enjeux de performance, ou une API qui montre des signes de fatigue ? N'hésitez pas à me contacter pour en discuter — ou par email à contact@alexis-mouchon.fr.