Développement Web

Aggregation Pipeline MongoDB : $match, $lookup, $facet et $merge en pratique

11 min de lecture

L’aggregation pipeline est le compositeur de MongoDB : un enchaînement d’opérations déclaratives qui transforme une collection en réponse métier en une seule requête. Vous y reconnaîtrez le couple SELECT … GROUP BY … HAVING de SQL, mais en bien plus expressif : vous pouvez joindre, dérouler des tableaux, exécuter plusieurs branches en parallèle, et persister le résultat dans une autre collection sans jamais quitter le moteur. Ce tutoriel détaille la grammaire utile en production sur MongoDB 8.0 LTS et applique le match stage pull-up et le $lookup/$unwind coalescing, deux optimisations natives de la version 8.0.

Prérequis

  • MongoDB 8.0 LTS (Community ou Atlas M0).
  • mongosh 2.x ou MongoDB Compass installé.
  • Un schéma minimum : deux collections commandes et produits, plusieurs centaines de documents pour observer le comportement de chaque étage.
  • Avoir lu le tutoriel sur la modélisation embedded vs referenced — la pipeline tire profit d’un schéma bien posé.
  • Temps estimé : 100 minutes pour traverser les 9 étapes avec exemples.

Étape 1 — Anatomie d’une pipeline

Une pipeline est un tableau d’étages (stages) exécutés dans l’ordre. Chaque étage reçoit un flux de documents, le transforme, et passe le résultat à l’étage suivant. La forme de sortie d’un étage devient la forme d’entrée du suivant — comme un pipe Unix mais pour des documents BSON.

db.commandes.aggregate([
  { $match:   { statut: "payee" } },
  { $project: { client_id: 1, montant_xof: 1, mois: { $month: "$cree_le" } } },
  { $group:   { _id: "$mois", ca_mensuel: { $sum: "$montant_xof" } } },
  { $sort:    { _id: 1 } },
  { $limit:   12 }
]);

L’enchaînement ci-dessus filtre, projette les colonnes utiles, agrège par mois, trie, limite — exactement comme on lirait la phrase « les douze derniers chiffres d’affaires mensuels des commandes payées ». Le moteur applique les étages dans l’ordre fourni mais réordonne intelligemment ce qui peut l’être : le moteur d’agrégation applique automatiquement le match stage pull-up qui remonte un $match placé après un $project pour qu’il filtre plus tôt et lise moins de documents. Référence : MongoDB 8.0 Release Notes — Query Planner.

Étape 2 — $match en premier : le filtre payant

Placer $match au tout début est la règle d’or numéro un. Le pipeline lit la collection via les indexes pertinents uniquement quand le filtre est positionné assez tôt ; au-delà du premier étage, le moteur a déjà commencé à matérialiser des documents en mémoire et le filtrage devient un travail de tri post-lecture, pas un scan ciblé d’index.

// Lent — le moteur lit toute la collection avant de filtrer
db.commandes.aggregate([
  { $addFields: { mois: { $month: "$cree_le" } } },
  { $match:     { statut: "payee", cree_le: { $gte: ISODate("2026-01-01") } } }
]);

// Rapide — match en tête, l'index sur (statut, cree_le) est utilisé
db.commandes.aggregate([
  { $match:     { statut: "payee", cree_le: { $gte: ISODate("2026-01-01") } } },
  { $addFields: { mois: { $month: "$cree_le" } } }
]);

L’exécution avec explain("executionStats") sur les deux variantes affichera, dans la première, un COLLSCAN sur l’ensemble des commandes ; dans la seconde, un IXSCAN sur l’index composite (statut, cree_le) avec un nReturned proche du totalKeysExamined. La différence se compte en facteur 50 sur une collection de plusieurs millions de documents.

Étape 3 — $group et accumulators

Le $group agrège les documents par une clé et applique des accumulators sur les autres champs. C’est l’équivalent fonctionnel de GROUP BY, en plus riche : vous pouvez accumuler des sommes, des moyennes, des min/max, mais aussi conserver le premier document de chaque groupe avec $first, empiler les valeurs dans un tableau avec $push, dédoublonner avec $addToSet.

db.commandes.aggregate([
  { $match: { statut: "payee" } },
  { $group: {
      _id:           "$client_id",
      ca_total:      { $sum: "$montant_xof" },
      panier_moyen:  { $avg: "$montant_xof" },
      premiere:      { $min: "$cree_le" },
      derniere:      { $max: "$cree_le" },
      nb_commandes:  { $sum: 1 },
      produits_vus:  { $addToSet: "$produit_id" }
    }
  }
]);

Le document de sortie pour chaque client contient sept champs précalculés en une seule lecture de la collection. Notez l’usage de { $sum: 1 } pour compter les éléments du groupe : c’est l’idiomatique MongoDB, équivalent du COUNT(*) SQL. Les opérateurs $percentile et $median introduits en MongoDB 7.0 sont également disponibles ici pour calculer des latences ou des distributions sans devoir trier l’ensemble du dataset. Référence : $group reference.

Étape 4 — $lookup pour joindre des collections

Le $lookup est l’équivalent du LEFT OUTER JOIN SQL. Il enrichit chaque document du flux avec un tableau de documents correspondants tirés d’une autre collection. MongoDB 8.0 a élargi ses capacités : le $lookup fonctionne maintenant sur des collections sharded à l’intérieur d’une transaction, ce qui n’était pas autorisé avant.

db.commandes.aggregate([
  { $match: { statut: "payee" } },
  { $lookup: {
      from:         "clients",
      localField:   "client_id",
      foreignField: "_id",
      as:           "client"
    }
  },
  { $unwind: "$client" },
  { $project: {
      _id: 0,
      commande_id: "$_id",
      montant: "$montant_xof",
      client_nom: "$client.nom",
      client_email: "$client.email"
    }
  }
]);

Le résultat de $lookup est toujours un tableau, d’où le $unwind qui suit pour aplatir quand vous savez qu’il y a au plus une correspondance. La règle de performance : la collection référencée doit être indexée sur le foreignField. Sans cet index, chaque document du flux principal déclenche un COLLSCAN sur la collection liée — l’agrégation devient quadratique et s’effondre dès quelques milliers de commandes.

Étape 5 — $unwind pour exploser un tableau

Le $unwind prend un champ tableau et produit un document par élément. C’est l’étage qui transforme un document « une commande avec trois lignes » en trois documents « une ligne » — utile quand l’agrégation s’intéresse aux éléments individuellement plutôt qu’au document parent.

// Schéma : commande embarque ses lignes
// { _id, client_id, lignes: [{ produit_id, qte, prix }, ...] }

db.commandes.aggregate([
  { $match: { statut: "payee" } },
  { $unwind: "$lignes" },
  { $group: {
      _id:        "$lignes.produit_id",
      qte_vendue: { $sum: "$lignes.qte" },
      ca:         { $sum: { $multiply: ["$lignes.qte", "$lignes.prix"] } }
    }
  },
  { $sort: { ca: -1 } },
  { $limit: 10 }
]);

L’enchaînement $unwind$group produit le palmarès des 10 produits qui ont rapporté le plus de chiffre d’affaires. Le moteur d’agrégation applique automatiquement l’optimisation officiellement nommée $lookup, $unwind, and $match Coalescence : quand un $unwind suit immédiatement un $lookup sur la même clé, le moteur fusionne les deux étages, ce qui élimine le tableau intermédiaire et libère mémoire. Référence : Aggregation Pipeline Optimization.

Étape 6 — $facet pour pipelines parallèles

Le $facet permet d’exécuter plusieurs sous-pipelines en parallèle sur le même flux d’entrée. C’est ce qui transforme une page de catalogue type Amazon en une seule requête : liste paginée des produits + compteurs par catégorie + tranches de prix + nombre total, tout en un appel.

db.produits.aggregate([
  { $match: { actif: true } },
  { $facet: {
      page_courante: [
        { $sort: { vues: -1 } },
        { $skip: 0 },
        { $limit: 24 },
        { $project: { nom: 1, prix_xof: 1, image: 1 } }
      ],
      facettes_categorie: [
        { $group: { _id: "$categorie", n: { $sum: 1 } } },
        { $sort: { n: -1 } }
      ],
      tranches_prix: [
        { $bucket: {
            groupBy: "$prix_xof",
            boundaries: [0, 5000, 25000, 100000, 500000, 9999999],
            default: "autre",
            output: { n: { $sum: 1 } }
        }}
      ],
      total: [ { $count: "nombre_produits" } ]
    }
  }
]);

Le document de sortie contient quatre clés correspondant aux sous-pipelines. Côté client, vous lisez en une réponse : les produits à afficher, les facettes pour la sidebar, l’histogramme des prix, et le total. Sans $facet, il aurait fallu quatre appels. Référence : $facet reference.

Étape 7 — $bucket et $bucketAuto pour histogrammes

Deux étages spécialisés produisent des histogrammes. Le premier, $bucket, demande que vous fournissiez les bornes manuellement ; le second, $bucketAuto, choisit lui-même des bornes équilibrées pour répartir les documents dans N seaux de tailles approximativement égales.

// Histogramme manuel : tranches de prix métier
db.produits.aggregate([
  { $bucket: {
      groupBy: "$prix_xof",
      boundaries: [0, 1000, 5000, 25000, 100000, 1000000],
      default: "hors_tranches",
      output: {
        nombre: { $sum: 1 },
        exemples: { $push: { nom: "$nom", prix: "$prix_xof" } }
      }
    }
  }
]);

// Histogramme automatique : 5 seaux équirépartis
db.produits.aggregate([
  { $bucketAuto: {
      groupBy: "$prix_xof",
      buckets: 5,
      output: { nombre: { $sum: 1 } }
    }
  }
]);

$bucket rejette les documents dont la valeur tombe hors des bornes, sauf si vous spécifiez default qui les regroupe dans un seau « hors-tranches ». $bucketAuto ne rejette rien et produit des bornes qu’il calcule lui-même — pratique pour explorer une distribution sans connaître ses extrêmes. Les deux étages requièrent que le champ groupBy soit comparable (numérique, date, chaîne).

Étape 8 — $merge et $out pour persister

Une pipeline peut écrire son résultat dans une collection. Deux étages le permettent : $out remplace entièrement la collection cible à chaque exécution, $merge applique un upsert document par document et préserve les enregistrements qui ne sont pas dans le flux. $merge est l’option recommandée en production parce qu’elle supporte les exécutions incrémentales — typiquement, mettre à jour un tableau de bord toutes les nuits sans relire l’historique.

// Matérialiser un tableau de bord ventes par jour
db.commandes.aggregate([
  { $match: { statut: "payee" } },
  { $group: {
      _id: {
        annee: { $year:  "$cree_le" },
        mois:  { $month: "$cree_le" },
        jour:  { $dayOfMonth: "$cree_le" }
      },
      ca_xof:   { $sum: "$montant_xof" },
      commandes: { $sum: 1 }
    }
  },
  { $merge: {
      into: "dashboard_ventes_jour",
      on: "_id",
      whenMatched: "replace",
      whenNotMatched: "insert"
    }
  }
]);

Le tableau de bord est désormais une collection matérialisée prête à être interrogée par un dashboard front. La règle de fraîcheur est sous votre contrôle : lancez la pipeline en cron, en trigger, ou à la volée selon le coût toléré. La collection dashboard_ventes_jour répond en quelques millisecondes là où le calcul direct demandait plusieurs secondes. Référence : $merge reference.

Étape 9 — explain() et optimisation

Comme pour les requêtes simples, l’agrégation s’inspecte avec explain(). Demandez le mode executionStats pour récupérer le détail de chaque étage : nombre de documents lus, durée, indexes utilisés, mémoire allouée.

db.commandes.explain("executionStats").aggregate([
  { $match: { statut: "payee" } },
  { $lookup: { from: "clients", localField: "client_id", foreignField: "_id", as: "c" } },
  { $unwind: "$c" },
  { $group: { _id: "$c.ville", ca: { $sum: "$montant_xof" } } }
]);

Quatre signaux à surveiller dans la sortie. Un : winningPlan.stage doit être IXSCAN, jamais COLLSCAN. Deux : totalKeysExamined doit être proche de nReturned — sinon votre index ne discrimine pas assez. Trois : executionTimeMillis donne la durée totale, à comparer à votre budget côté API. Quatre : la présence d’SBE (Slot-Based Execution) ou de block processing pour les time series sur MongoDB 8.0 — le passage en SBE accélère typiquement les agrégations de 30 à 50 %.

Erreurs fréquentes

Erreur Cause Solution
$match placé après $lookup Filtre tardif, lecture inutile de tout le flux Remonter $match en premier, l’index sur la collection principale prend le relais
$lookup lent sur grosse collection Absence d’index sur le foreignField db.cible.createIndex({ champ_jointure: 1 })
Erreur QueryExceededMemoryLimitNoDiskUseAllowed Étage en mémoire dépasse 100 Mo Ajouter { allowDiskUse: true } à l’option de l’agrégation
Résultat $facet tronqué Sous-pipeline dépasse 16 Mo (limite document de sortie) Ajouter $limit dans chaque sous-pipeline ou matérialiser via $merge
$unwind sur tableau vide perd les documents Comportement par défaut Option { path: "$champ", preserveNullAndEmptyArrays: true }

FAQ

Q : Quelle est la différence entre $lookup et un JOIN SQL ?
R : $lookup retourne un tableau de matchs (zéro, un, ou plusieurs documents), pas une ligne aplatie. C’est plus proche d’un left outer join agrégé. Pour aplatir, faire suivre d’un $unwind.

Q : La pipeline peut-elle modifier la collection source ?
R : Non, jamais. Une agrégation lit la source et écrit dans une autre collection via $out ou $merge. Si vous voulez modifier en place, c’est db.collection.updateMany() qui le permet, pas l’aggregation pipeline.

Q : Limite de taille d’un document de sortie ?
R : 16 Mo, comme tout document MongoDB. Si votre pipeline produit plus de 16 Mo dans un seul résultat, utilisez $out ou $merge pour persister, puis lisez par batches.

Q : Peut-on exécuter une pipeline depuis un driver applicatif ?
R : Oui. La méthode collection.aggregate(pipeline, options) existe dans tous les drivers officiels (Node.js, Python, Java, Go, Rust). Le tableau de la pipeline est sérialisé en BSON et envoyé tel quel au serveur.

Q : $facet est-il plus rapide que plusieurs requêtes ?
R : Oui, parce que la collection n’est lue qu’une seule fois. Les N sous-pipelines partagent le scan d’index initial. La latence cumulée d’un seul appel $facet est typiquement 30 à 60 % inférieure à la somme de N appels séparés sur le même filtre.

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é