So profilieren und optimieren Sie langsame MongoDB-Aggregationspipelines

Meistern Sie die MongoDB-Leistung, indem Sie lernen, langsame Aggregationspipelines zu diagnostizieren. Diese Anleitung beschreibt detailliert, wie Sie den MongoDB-Profiler und die Methode `.explain('executionStats')` aktivieren und nutzen, um Engpässe in komplexen Stufen zu identifizieren. Entdecken Sie umsetzbare Optimierungsstrategien mit Fokus auf optimale Indizierung für `$match` und `$sort` sowie effiziente Nutzung von `$lookup`, um Ihre Datentransformationen drastisch zu beschleunigen.

So profilieren und optimieren Sie langsame MongoDB-Aggregationspipelines

MongoDB-Aggregationspipelines wachsen leicht Schritt für Schritt. Ein Bericht beginnt mit einem $match, dann fügt jemand ein $lookup hinzu, dann ein $group, dann eine Sortierung, und sechs Monate später ist der Endpunkt so langsam, dass jeder Angst hat, ihn anzufassen.

Die Lösung beginnt mit Beweisen. Sie müssen wissen, welche Stufe zu viel liest, zu viel expandiert, zu viel sortiert oder zu spät joined. MongoDB bietet Ihnen zwei praktische Werkzeuge für diese Arbeit: den Datenbank-Profiler für historische langsame Operationen und .explain("executionStats") für eine detaillierte Betrachtung einer einzelnen Pipeline.

Den MongoDB-Profiler verstehen

Der MongoDB-Profiler zeichnet die Ausführungsdetails von Datenbankoperationen auf, einschließlich find, update, delete und, am wichtigsten für diese Anleitung, aggregate-Befehle. Er erfasst, wie lange eine Operation dauerte, welche Ressourcen sie verbrauchte und welche Stufen am meisten zur Latenz beitrugen.

Profilierungsstufen aktivieren und konfigurieren

Bevor Sie profilieren können, müssen Sie sicherstellen, dass der Profiler aktiv ist und auf eine Stufe eingestellt ist, die die notwendigen Daten erfasst. Die Profilierungsstufen reichen von 0 (aus) bis 2 (alle Operationen protokolliert).

Stufe Beschreibung
0 Profiler ist deaktiviert.
1 Protokolliert Operationen, die länger als die slowOpThresholdMs-Einstellung dauern.
2 Protokolliert alle Operationen, die gegen die Datenbank ausgeführt werden.

Um die Profiler-Stufe festzulegen, verwenden Sie den Befehl db.setProfilingLevel(). Es wird allgemein empfohlen, Stufe 1 oder 2 vorübergehend während Leistungstests zu verwenden, um übermäßige Festplatten-E/A zu vermeiden.

Beispiel: Setzen des Profilers auf Stufe 1 (Protokollierung von Operationen langsamer als 100ms)

// Verbinden Sie sich mit Ihrer Datenbank: use myDatabase
db.setProfilingLevel(1, { slowOpThresholdMs: 100 })

// Überprüfen Sie die Einstellung
db.getProfilingStatus()

Bewährte Praxis: Lassen Sie den Profiler niemals dauerhaft auf Stufe 2 in einem Produktionssystem, da das Protokollieren jeder Operation die Schreibleistung erheblich beeinträchtigen kann.

Profilierte Aggregationsdaten anzeigen

Profilierte Operationen werden in der Sammlung system.profile innerhalb der Datenbank gespeichert, die Sie profilieren. Sie können diese Sammlung abfragen, um kürzliche langsame Aggregationen zu finden.

Um langsame Aggregationsabfragen zu finden, filtern Sie die Ergebnisse, bei denen das Feld op den Wert 'aggregate' hat und die Ausführungszeit (millis) Ihren Schwellenwert überschreitet.

// Finden Sie alle langsamen Aggregationsoperationen der letzten Stunde
db.system.profile.find(
  {
    op: 'aggregate',
    millis: { $gt: 100 } // Operationen langsamer als 100ms
  }
).sort({ ts: -1 }).limit(5).pretty()

Analysieren von Ausführungsdetails der Aggregationspipeline

Die Ausgabe des Profilers ist entscheidend. Wenn Sie ein langsames Aggregationsdokument untersuchen, suchen Sie speziell nach planSummary und, was noch wichtiger ist, nach dem stages-Array innerhalb des Ergebnisses.

Nutzen der ausführlichen Ausgabe von .explain('executionStats')

Während der Profiler historische Daten erfasst, liefert die Ausführung einer Aggregation mit .explain('executionStats') Echtzeit-, granulare Details darüber, wie MongoDB die Pipeline auf dem aktuellen Datensatz ausgeführt hat, einschließlich Zeitmessungen pro Stufe.

Beispiel mit Explain:

db.collection('sales').aggregate([
  { $match: { status: 'A' } },
  { $group: { _id: '$customerId', total: { $sum: '$amount' } } }
]).explain('executionStats');

In der Ausgabe detailliert das stages-Array jeden Operator in der Pipeline. Suchen Sie für jede Stufe nach:

  • executionTimeMillis: Die Zeit, die für die Ausführung dieser spezifischen Stufe aufgewendet wurde.
  • nReturned: Die Anzahl der Dokumente, die an die nächste Stufe übergeben wurden.
  • totalKeysExamined / totalDocsExamined: Metriken, die die E/A-Kosten angeben.

Stufen mit sehr hohem executionTimeMillis oder Stufen, die weit mehr Dokumente untersuchen (totalDocsExamined), als sie zurückgeben, sind Ihre primären Optimierungsziele.

Strategien zur Optimierung langsamer Aggregationsstufen

Sobald die Profilerstellung die Engpassstufe identifiziert hat (z.B. $match, $lookup oder Sortierstufen), können Sie gezielte Optimierungstechniken anwenden.

1. Initiale Filterung optimieren ($match)

Die $match-Stufe sollte wenn möglich immer die erste Stufe in Ihrer Pipeline sein. Frühzeitiges Filtern reduziert die Anzahl der Dokumente, die nachfolgende, ressourcenintensive Stufen (wie $group oder $lookup) verarbeiten müssen.

Die Rolle der Indizierung: Wenn Ihre anfängliche $match-Stufe langsam ist, fehlt ihr mit ziemlicher Sicherheit ein Index auf den im Filter verwendeten Feldern. Stellen Sie sicher, dass Indizes die in $match verwendeten Felder abdecken.

Wenn die $match-Stufe Felder betrifft, die nicht indiziert sind, könnte die Stufe einen vollständigen Collection-Scan durchführen, der in der Explain-Ausgabe explizit als hoher totalDocsExamined sichtbar ist.

2. Effiziente Nutzung von $lookup (Joins)

Die $lookup-Stufe ist oft die langsamste Komponente. Sie führt effektiv einen Anti-Join gegen eine andere Sammlung durch.

  • Fremdschlüssel indizieren: Stellen Sie sicher, dass das Feld, auf das Sie in der fremden (nachgeschlagenen) Sammlung joinen, indiziert ist. Dies beschleunigt den internen Lookup-Prozess erheblich.
  • Vor dem Lookup filtern: Wenden Sie wann immer möglich eine $match-Stufe vor dem $lookup an, um sicherzustellen, dass Sie nur gegen notwendige Dokumente joinen.

3. Teure Sortierung angehen ($sort)

Das Sortieren von Dokumenten ist rechenintensiv, insbesondere über große Ergebnismengen. MongoDB kann einen Index nur dann zum Sortieren verwenden, wenn das Indexpräfix mit dem Abfragefilter übereinstimmt und die Sortierreihenfolge mit der Indexdefinition übereinstimmt.

Wichtige Optimierung für $sort: Wenn eine $sort-Stufe teuer erscheint, versuchen Sie, einen abgedeckten Index zu erstellen, der dem Filter und der erforderlichen Sortierreihenfolge entspricht. Wenn Sie beispielsweise nach { status: 1 } filtern und dann nach { date: -1 } sortieren, würde ein Index auf { status: 1, date: -1 } es MongoDB ermöglichen, Dokumente in der erforderlichen Reihenfolge abzurufen, ohne eine kostspielige Sortierung im Arbeitsspeicher.

4. Datenbewegung mit $project minimieren

Setzen Sie die $project-Stufe strategisch ein, um die Datenmenge zu reduzieren, die durch die Pipeline geleitet wird. Wenn spätere Stufen nur wenige Felder benötigen, verwenden Sie $project früh in der Pipeline, um unnötige Felder und eingebettete Dokumente zu verwerfen. Kleinere Dokumente bedeuten weniger Daten, die zwischen den Pipelinestufen bewegt werden, und potenziell bessere Speicherauslastung.

5. Teure Stufen vermeiden, die keine Indizes verwenden können

Stufen wie $unwind können viele neue Dokumente erstellen und den Verarbeitungsaufwand schnell erhöhen. Obwohl manchmal notwendig, stellen Sie sicher, dass die Eingabe für $unwind so klein wie möglich ist. Ebenso sollten Stufen, die eine vollständige Neubewertung des Datensatzes erzwingen, wie solche, die auf Berechnungen oder komplexen Ausdrücken ohne Indexunterstützung basieren, minimiert werden.

Ein realistischer Optimierungsdurchlauf

Stellen Sie sich ein Support-Dashboard vor, das den gesamten Rückerstattungsbetrag pro Kunde für die letzten 30 Tage anzeigt. Es begann schnell, wurde dann aber langsam, nachdem sich ein Jahr lang Bestellungen angesammelt hatten. Die Pipeline sieht harmlos aus:

db.orders.aggregate([
  { $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
  }},
  { $unwind: "$customer" },
  { $match: { status: "refunded", createdAt: { $gte: startDate } } },
  { $group: { _id: "$customerId", totalRefunded: { $sum: "$amount" } } },
  { $sort: { totalRefunded: -1 } },
  { $limit: 50 }
])

Der teure Fehler ist nicht offensichtlich, bis Sie sich die Reihenfolge der Arbeit ansehen. Diese Pipeline joint jede Bestellung mit einem Kunden, bevor sie auf erstattete Bestellungen in den letzten 30 Tagen filtert. Bei einer großen Sammlung bedeutet dies, dass MongoDB viele Joins für Dokumente durchführt, die später verworfen werden.

Eine bessere erste Version filtert frühzeitig:

db.orders.aggregate([
  { $match: { status: "refunded", createdAt: { $gte: startDate } } },
  { $group: { _id: "$customerId", totalRefunded: { $sum: "$amount" } } },
  { $sort: { totalRefunded: -1 } },
  { $limit: 50 },
  { $lookup: {
      from: "customers",
      localField: "_id",
      foreignField: "_id",
      as: "customer"
  }},
  { $unwind: "$customer" }
])

Jetzt findet der Join nur für die Top-50 gruppierten Kunden statt, nicht für jede Bestellung in der Sammlung. Das ist die Art von Änderung, zu der die Profilerstellung führen sollte: Weniger Daten gelangen in die teuren Stufen.

Für diese Version könnte ein nützlicher Index auf orders sein:

db.orders.createIndex({ status: 1, createdAt: -1, customerId: 1 })

Der genaue Index hängt von Ihren tatsächlichen Filtern und Sortierbedürfnissen ab, aber die Idee ist stabil: Unterstützen Sie das frühe $match und fügen Sie Felder hinzu, die der Pipeline helfen, unnötige Dokumentenlesevorgänge zu vermeiden, wenn möglich. In der Sammlung customers ist _id bereits indiziert, daher ist das $lookup normalerweise in Ordnung. Wenn Sie auf ein anderes Feld joinen, indizieren Sie dieses Fremdfeld.

Wenn Sie .explain("executionStats") überprüfen, starren Sie nicht nur auf die Gesamtlaufzeit. Achten Sie auf Fan-Out. Wenn eine Stufe 500 Dokumente zurückgibt und die nächste 2 Millionen aufgrund von $unwind, haben Sie die Stufe gefunden, die die Form des Problems geändert hat. Wenn totalDocsExamined viel größer ist als nReturned, ist der Index nicht selektiv genug oder wird nicht wie erwartet verwendet. Wenn eine Sortierung spät in der Pipeline nach einer großen Gruppe erscheint, überlegen Sie, ob Sie früher begrenzen oder in eine separate Sammlung für Dashboards voraggregieren können, die keine sekundengenaue Aktualität benötigen.

Beobachten Sie auch das Speicherverhalten. $group, $sort, $setWindowFields und einige $lookup-Muster können viel Speicher benötigen. allowDiskUse: true kann verhindern, dass eine Pipeline fehlschlägt, wenn sie die Speichergrenzen überschreitet, aber es ist keine Leistungsverbesserung an sich. Das Auslagern auf die Festplatte bedeutet normalerweise, dass die Pipeline zu viel Arbeit auf einmal erledigt. Es kann für einen nächtlichen Bericht akzeptabel sein. Es ist selten akzeptabel für einen benutzerorientierten API-Endpunkt, der bei jedem Seitenaufruf ausgeführt wird.

Eine praktische Gewohnheit ist es, die langsame Pipeline, die Explain-Ausgabe und die Indizes zusammen in den Incident-Notizen zu speichern. Die nächste Person sollte nicht wiederentdecken müssen, warum ein Index existiert oder warum $lookup nach $limit verschoben wurde. Die Optimierung von Aggregationen ist viel einfacher, wenn die Begründung länger überlebt als die Debugging-Sitzung.

Indizes, die Aggregationen helfen, und Indizes, die nur hilfreich aussehen

Aggregationspipelines decken oft schwache zusammengesetzte Indizes auf. Angenommen, Ihre API filtert nach Mandant und Datum und gruppiert dann nach Status:

db.orders.aggregate([
  { $match: { tenantId, createdAt: { $gte: start, $lt: end } } },
  { $group: { _id: "$status", count: { $sum: 1 } } }
])

Ein Index auf { createdAt: -1 } kann ein wenig helfen, aber in einem Multi-Mandanten-System kann er immer noch einen großen Datumsbereich für jeden Mandanten scannen. Ein Index auf { tenantId: 1, createdAt: -1 } passt normalerweise besser zum Zugriffsmuster, da er zuerst auf den Mandanten eingrenzt und dann den Datumsbereich durchläuft. Wenn die meisten Abfragen auch den Status enthalten, testen Sie, ob { tenantId: 1, status: 1, createdAt: -1 } für diese Arbeitslast besser ist. Raten Sie nicht. Führen Sie explain aus, vergleichen Sie keysExamined, docsExamined und die verstrichene Zeit auf produktionsähnlichen Daten.

Seien Sie vorsichtig mit Feldern mit niedriger Kardinalität am Anfang eines Index. Ein Index, der mit { status: 1 } beginnt, ist möglicherweise nicht selektiv, wenn fast jede Bestellung complete ist. Er kann dennoch nützlich sein, wenn er mit anderen Feldern kombiniert wird, aber er sollte die Abfrageform widerspiegeln. Der beste Index ist nicht der mit den meisten Feldern; es ist derjenige, der den Suchraum frühzeitig einschränkt, ohne unnötigen Schreibaufwand zu erzeugen.

Wann man aufhören sollte, die Pipeline zu optimieren

Manchmal ist die richtige Lösung keine weitere Pipeline-Umschreibung. Wenn ein Dashboard jedes Mal die gleiche teure Aggregation ausführt, wenn ein Manager die Seite öffnet, kann eine Voraggregation sauberer sein. Ein geplanter Job kann stündliche Summen in eine order_stats_hourly-Sammlung schreiben, und das Dashboard kann ein paar kleine Dokumente lesen. Sie tauschen Aktualität gegen vorhersagbare Latenz.

Dieser Tausch ist oft akzeptabel, wenn Menschen Trends lesen. Es ist weniger akzeptabel, wenn die Pipeline eine Checkout-Entscheidung oder eine Betrugsregel unterstützt. Machen Sie die Aktualitätsanforderung explizit. "Innerhalb von fünf Minuten" eröffnet Voraggregation und Caching. "Muss die letzte bestätigte Bestellung enthalten" hält Sie wahrscheinlich näher an Live-Lesevorgängen mit stärkerem Schreib- und Leseverhalten.

Bei der Optimierung von Aggregationen geht es nicht darum, jede Pipeline clever zu machen. Es geht darum, Arbeit zu entfernen, die die Datenbank nicht auf dem Anforderungspfad erledigen sollte.

Zusammenfassung und nächste Schritte

Das Profilieren und Optimieren von MongoDB-Aggregationspipelines erfordert einen systematischen, evidenzbasierten Ansatz. Durch die Nutzung des integrierten Profilers (db.setProfilingLevel) und die Ausführung detaillierter Ausführungsstatistiken (.explain('executionStats')) können Sie komplexe Leistungsprobleme in lösbare Schritte umwandeln.

Der Optimierungs-Workflow ist:

  1. Profilierung aktivieren: Stufe 1 setzen und einen slowOpThresholdMs definieren.
  2. Die Abfrage ausführen: Die langsame Aggregationspipeline ausführen.
  3. Profilierte Daten analysieren: Die spezifische Stufe identifizieren, die die meiste Zeit verbraucht.
  4. Im Detail erklären: .explain('executionStats') auf der problematischen Pipeline verwenden.
  5. Optimieren: Notwendige Indizes erstellen, Stufen neu anordnen (zuerst filtern) und die an teure Operatoren übergebenen Daten vereinfachen.

Kontinuierliche Überwachung stellt sicher, dass neu hinzugefügte Funktionen oder erhöhtes Datenvolumen die von Ihnen behobenen Leistungsprobleme nicht wieder einführen.